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 01/71] 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 02/71] 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 03/71] 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 04/71] 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 05/71] 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 06/71] 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 07/71] 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 08/71] 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 09/71] 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") + }) }) From caa0043030a02079e463b0827e3510c87a5771f5 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 15 Jul 2026 23:25:55 -0400 Subject: [PATCH 10/71] fix(jellyfin): honor AlbumIds filter on /Items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feishin fetches an album's tracks with AlbumIds=&IncludeItemTypes=Audio&Recursive=true, but the /Items handler never read the AlbumIds parameter, so the request degenerated into the entire library sorted by album: every track (with MediaSources, when requested) was counted and streamed for what should be one album's worth of songs — very slow on large libraries, and wrong results. Parse AlbumIds like GenreIds (comma-separated and repeated spellings, hex-decoded) and filter songs through a new filter.ByAlbumID helper, keeping the album_id column knowledge in the filter package. --- server/filter/filters.go | 5 +++++ server/jellyfin/e2e/browsing_test.go | 21 +++++++++++++++++++++ server/jellyfin/items.go | 6 ++++++ 3 files changed, 32 insertions(+) diff --git a/server/filter/filters.go b/server/filter/filters.go index e149dbced..5fa53daba 100644 --- a/server/filter/filters.go +++ b/server/filter/filters.go @@ -189,6 +189,11 @@ func ByGenreID(genreIds []string) Sqlizer { return genreTagFilter(Eq{"value": genreIds}) } +// ByAlbumID matches media files belonging to any of the given albums. +func ByAlbumID(albumIds []string) Sqlizer { + return Eq{"album_id": albumIds} +} + // 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 { diff --git a/server/jellyfin/e2e/browsing_test.go b/server/jellyfin/e2e/browsing_test.go index 0b2769856..18565c635 100644 --- a/server/jellyfin/e2e/browsing_test.go +++ b/server/jellyfin/e2e/browsing_test.go @@ -179,6 +179,27 @@ var _ = Describe("Browsing", func() { }) }) + // Feishin fetches an album's tracks with AlbumIds=&IncludeItemTypes=Audio&Recursive=true. + Describe("album filtering (AlbumIds)", func() { + It("filters songs by AlbumIds", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&AlbumIds=" + enc(albumID("Abbey Road")))) + Expect(names(q.Items)).To(ConsistOf("Come Together", "Something")) + Expect(q.TotalRecordCount).To(Equal(2)) + }) + + It("matches any of multiple comma-separated AlbumIds", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&AlbumIds=" + enc(albumID("Abbey Road")) + "," + enc(albumID("IV")))) + Expect(names(q.Items)).To(ConsistOf("Come Together", "Something", "Stairway To Heaven")) + Expect(q.TotalRecordCount).To(Equal(3)) + }) + + It("returns nothing for an unknown album id", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&AlbumIds=" + enc("no-such-album"))) + Expect(q.Items).To(BeEmpty()) + Expect(q.TotalRecordCount).To(Equal(0)) + }) + }) + // Finamp's genre screen sends ParentId= (scoping) plus GenreIds=. Describe("genre filtering (GenreIds)", func() { lib1 := enc("1") diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go index 43c8de12c..b9372c397 100644 --- a/server/jellyfin/items.go +++ b/server/jellyfin/items.go @@ -221,6 +221,7 @@ type itemsQuery struct { artistId string contributingOnly bool genreIds []string + albumIds []string } // parseItemsQuery also resolves the entity types (inferring them from the parent when @@ -243,6 +244,8 @@ func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) itemsQu parentId: dto.DecodeID(p.StringOr("parentid", "")), // Finamp's genre screen sends ParentId= for scoping plus GenreIds for the genre. genreIds: decodedQueryIDs(r, "genreids"), + // Feishin fetches an album's tracks with AlbumIds instead of ParentId. + albumIds: decodedQueryIDs(r, "albumids"), } // An artist's page filters by artist, not ParentId: Finamp sends ParentId= for scoping // plus AlbumArtistIds/ArtistIds/contributingArtistIds for the artist. @@ -528,6 +531,9 @@ func (api *Router) listSongs(ctx context.Context, opts model.QueryOptions, q ite default: filters = append(filters, notMissing) } + if len(q.albumIds) > 0 { + filters = append(filters, filter.ByAlbumID(q.albumIds)) + } if len(q.genreIds) > 0 { filters = append(filters, filter.ByGenreID(q.genreIds)) } From c582ed31fa45beecf7064bab30c1c9fc7480a0d1 Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 16 Jul 2026 08:49:00 -0400 Subject: [PATCH 11/71] feat(jellyfin): add authenticated System/Info endpoint Wire up GET /System/Info returning the previously unused dto.SystemInfo, available to any authenticated user, matching real Jellyfin's authorization (FirstTimeSetupOrIgnoreParentalControl, not admin-only). Feishin calls this endpoint on connect and reads Version to feature-gate; it previously got the unhandled-route 404. The advertised version stays 10.8.13: Feishin unlocks structured lyrics and public-playlist share permissions at >=10.9.0, and this API serves neither (no lyrics endpoint; playlist user permissions are stubs), so a higher version would falsely advertise capabilities. --- server/jellyfin/README.md | 2 +- server/jellyfin/api.go | 1 + server/jellyfin/e2e/system_test.go | 21 +++++++++++++++++++++ server/jellyfin/system.go | 7 +++++++ server/jellyfin/system_test.go | 21 +++++++++++++++++++++ 5 files changed, 51 insertions(+), 1 deletion(-) diff --git a/server/jellyfin/README.md b/server/jellyfin/README.md index 2e4c19950..8e0e08ce3 100644 --- a/server/jellyfin/README.md +++ b/server/jellyfin/README.md @@ -115,7 +115,7 @@ favorites-only (`Filters=IsFavorite` or the standalone `isFavorite=true`); `Sort | Area | Endpoints | |---|---| -| Handshake / system | `GET System/Info/Public`, `GET`/`POST System/Ping`, `GET QuickConnect/Enabled` | +| Handshake / system | `GET System/Info/Public`, `GET System/Info` (authenticated), `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) | diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go index 0740901aa..3aa870704 100644 --- a/server/jellyfin/api.go +++ b/server/jellyfin/api.go @@ -92,6 +92,7 @@ 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("/system/info", api.getSystemInfo) r.Get("/userviews", api.getUserViews) r.Get("/users/{userId}/views", api.getUserViews) r.Get("/users/me", api.getCurrentUser) diff --git a/server/jellyfin/e2e/system_test.go b/server/jellyfin/e2e/system_test.go index d4d2f2777..c6f9145d5 100644 --- a/server/jellyfin/e2e/system_test.go +++ b/server/jellyfin/e2e/system_test.go @@ -30,6 +30,27 @@ var _ = Describe("System", func() { }) }) + Describe("GET /System/Info", func() { + It("returns system info to any authenticated user, matching the public Version", func() { + w := getAs(regularUser, "/System/Info") + var info map[string]any + parseInto(w, &info) + Expect(info["Version"]).ToNot(BeEmpty()) + Expect(info["SupportsLibraryMonitor"]).To(BeTrue()) + + pub := rawReq("GET", "/System/Info/Public", "") + var pubInfo map[string]any + parseInto(pub, &pubInfo) + Expect(info["Version"]).To(Equal(pubInfo["Version"])) + Expect(info["Id"]).To(Equal(pubInfo["Id"])) + }) + + It("rejects unauthenticated requests", func() { + w := rawReq("GET", "/System/Info", "") + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) + }) + Describe("GET/POST /System/Ping", func() { It("answers GET with a plain-text server name", func() { w := rawReq("GET", "/System/Ping", "") diff --git a/server/jellyfin/system.go b/server/jellyfin/system.go index baa31fdeb..7e539be98 100644 --- a/server/jellyfin/system.go +++ b/server/jellyfin/system.go @@ -83,6 +83,13 @@ func (api *Router) getPublicSystemInfo(w http.ResponseWriter, r *http.Request) { api.ok(w, r, api.publicInfo(r)) } +func (api *Router) getSystemInfo(w http.ResponseWriter, r *http.Request) { + api.ok(w, r, dto.SystemInfo{ + PublicSystemInfo: api.publicInfo(r), + SupportsLibraryMonitor: true, + }) +} + // 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) { diff --git a/server/jellyfin/system_test.go b/server/jellyfin/system_test.go index 5ea525100..7846e4a20 100644 --- a/server/jellyfin/system_test.go +++ b/server/jellyfin/system_test.go @@ -38,6 +38,27 @@ var _ = Describe("System", func() { Expect(info.ServerName).To(HavePrefix("Navidrome")) }) + It("returns authenticated system info with the public fields plus library monitor support", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Jellyfin.ServerName = "" + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/System/Info", nil) + api.getSystemInfo(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).To(ContainSubstring("application/json")) + var info dto.SystemInfo + 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")) + Expect(info.SupportsLibraryMonitor).To(BeTrue()) + Expect(info.HasPendingRestart).To(BeFalse()) + Expect(info.IsShuttingDown).To(BeFalse()) + }) + It("advertises a LocalAddress with the request scheme, host and Jellyfin base path", func() { DeferCleanup(configtest.SetupConfig()) From 29f481cd7b24336615ffe80410ce90e47a9348b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Thu, 16 Jul 2026 14:39:11 -0400 Subject: [PATCH 12/71] feat(jellyfin): lyrics endpoint and Lyric stream advertising (#5791) * feat(jellyfin): add LyricDto and lyrics mapper * feat(jellyfin): advertise Lyric media stream for embedded lyrics * feat(jellyfin): implement GET /Audio/{itemId}/Lyrics * feat(jellyfin): advertise pipeline-resolved lyrics in PlaybackInfo * feat(jellyfin): advertise server version 10.9.11 for client lyrics gates * test(jellyfin): e2e coverage for lyrics endpoint and advertising Seeds "Stairway To Heaven" with an embedded LRC lyric tag (lyrics:eng) and covers PlaybackInfo's Lyric MediaStream, GET /Audio/{id}/Lyrics, and the HasLyrics badge end to end. Fixes a bug the new seed exposed: HasLyrics and the Lyric MediaStream gate compared mf.Lyrics against "", but the persistence layer never stores an empty string post-scan (it normalizes to the JSON sentinel "[]"), so every track was reporting HasLyrics=true. Both call sites now parse the column via StructuredLyrics()/LyricList.Main() instead. * fix(jellyfin): cheap sentinel check for embedded lyrics advertising * chore(jellyfin): trim over-budget comments in lyrics code * test(jellyfin): cover lyrics pipeline error and nil-start cue skip * docs(jellyfin): document lyrics support and follow-ups in README * refactor(jellyfin): promote embedded-lyrics sentinel check to MediaFile The "[]" no-lyrics sentinel is persistence-layer knowledge; expose it as model.MediaFile.HasEmbeddedLyrics() instead of a dto-local helper. Also dedupe the test lyrics-cache construction and pre-size the media stream slice. * refactor(jellyfin): consolidate tick conversions around one constant ticksPerMillis is now the single source of the 100ns-tick unit; the scrobble handlers' three inline /10_000 divisions become dto.MillisFromTicks. * fix(jellyfin): align lyric advertising with the serving predicate PlaybackInfo advertised on any non-empty LyricList while the endpoint 404s when the main lyric has no lines; both now share servableLyric. Handler tests also send hex-encoded ids to match real traffic. * chore(jellyfin): drop unneeded lyrics package alias in e2e suite --- cmd/wire_gen.go | 3 +- model/mediafile.go | 6 ++ model/mediafile_test.go | 9 ++ server/jellyfin/README.md | 20 +++- server/jellyfin/api.go | 14 ++- server/jellyfin/api_test.go | 10 +- server/jellyfin/dto/dto.go | 29 ++++++ server/jellyfin/dto/mappers.go | 87 ++++++++++++++--- server/jellyfin/dto/mappers_test.go | 114 +++++++++++++++++++++- server/jellyfin/e2e/e2e_suite_test.go | 10 +- server/jellyfin/e2e/lyrics_test.go | 71 ++++++++++++++ server/jellyfin/lyrics.go | 47 +++++++++ server/jellyfin/lyrics_test.go | 132 ++++++++++++++++++++++++++ server/jellyfin/routing_test.go | 2 +- server/jellyfin/sessions.go | 6 +- server/jellyfin/socket_test.go | 2 +- server/jellyfin/stream.go | 10 ++ server/jellyfin/stream_test.go | 88 ++++++++++++++++- server/jellyfin/system.go | 5 +- 19 files changed, 627 insertions(+), 38 deletions(-) create mode 100644 server/jellyfin/e2e/lyrics_test.go create mode 100644 server/jellyfin/lyrics.go create mode 100644 server/jellyfin/lyrics_test.go diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index bd211cbfc..463ac7a44 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -137,7 +137,8 @@ func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router { imageUploadService := core.NewImageUploadService() playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService) sonicSonic := sonic.New(dataStore, manager, matcherMatcher) - router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic) + lyricsLyrics := lyrics.NewLyrics(dataStore, manager) + router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic, lyricsLyrics) return router } diff --git a/model/mediafile.go b/model/mediafile.go index 0fd172cee..90db80f71 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -147,6 +147,12 @@ func (mf MediaFile) StructuredLyrics() (LyricList, error) { return lyrics, nil } +// HasEmbeddedLyrics reports whether the lyrics column holds any lyrics. It is never "" post-scan; +// no-lyrics is normalized to the "[]" sentinel, so string emptiness alone is meaningless. +func (mf MediaFile) HasEmbeddedLyrics() bool { + return mf.Lyrics != "" && mf.Lyrics != "[]" +} + // String is mainly used for debugging func (mf MediaFile) String() string { return mf.Path diff --git a/model/mediafile_test.go b/model/mediafile_test.go index f070f4649..c94653530 100644 --- a/model/mediafile_test.go +++ b/model/mediafile_test.go @@ -604,6 +604,15 @@ var _ = Describe("MediaFile", func() { }) +var _ = DescribeTable("MediaFile.HasEmbeddedLyrics", + func(lyrics string, expected bool) { + Expect(MediaFile{Lyrics: lyrics}.HasEmbeddedLyrics()).To(Equal(expected)) + }, + Entry("empty string (never-scanned zero value)", "", false), + Entry(`the post-scan "[]" no-lyrics sentinel`, "[]", false), + Entry("a stored lyric list", `[{"lang":"eng","line":[{"value":"la"}]}]`, true), +) + var _ = Describe("MediaFile.Works", func() { It("returns nil when there are no work tags", func() { mf := MediaFile{} diff --git a/server/jellyfin/README.md b/server/jellyfin/README.md index 8e0e08ce3..7afc2b7a9 100644 --- a/server/jellyfin/README.md +++ b/server/jellyfin/README.md @@ -338,11 +338,21 @@ make test PKG=./server/jellyfin/... 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.) +- **Lyrics.** `GET Audio/{id}/Lyrics` serves the main lyric track as a `LyricDto` (`Start` in + 100ns ticks, word-level `Cues` when present), resolved through the full `core/lyrics` pipeline + (embedded, `.lrc` sidecars, plugins per `LyricsPriority`) behind a 5-minute TTL cache that also + caches misses — Jellify fetches for every played track, Feishin per song change, so lyric-less + tracks are the hot path. No lyrics → 404 (never an empty 200), which all three clients degrade + gracefully. Finamp gates its lyrics view on a `Lyric` `MediaStream` (not `HasLyrics`, which is + just a list badge): browse lists advertise it from embedded lyrics only (the `"[]"` sentinel + check — the column is never `""` post-scan), while `PlaybackInfo` runs the full pipeline per + track so sidecar/plugin lyrics also light up. Feishin additionally requires server version + ≥ 10.9 — the reason `jellyfinVersion` is 10.9.11. + Follow-ups: the lyrics cache loader is not singleflighted, so concurrent misses on the same + track can double-invoke the plugin pipeline (fix belongs in `utils/cache.SimpleCache` via + ttlcache's `SuppressedLoader`, affecting all callers — separate change); tracks whose only + lyrics are sidecar/plugin-sourced show no `HasLyrics` badge in lists (request-time sources + can't be known at list time without per-row I/O). - **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 diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go index 3aa870704..5e7cad0b8 100644 --- a/server/jellyfin/api.go +++ b/server/jellyfin/api.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "sync" + "time" "github.com/go-chi/chi/v5" "github.com/go-chi/httprate" @@ -13,6 +14,7 @@ import ( "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/core/sonic" @@ -21,6 +23,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server" "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/utils/cache" ) type Router struct { @@ -34,6 +37,8 @@ type Router struct { playlists playlists.Playlists provider external.Provider sonic sonic.Engine + lyrics lyrics.Lyrics + lyricsCache cache.SimpleCache[string, model.LyricList] similarFlight singleflight.Group serverIDMu sync.Mutex serverIDVal string @@ -42,11 +47,15 @@ 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, - sonicSvc sonic.Engine) *Router { + sonicSvc sonic.Engine, lyricsSvc lyrics.Lyrics) *Router { r := &Router{ ds: ds, artwork: artwork, streamer: streamer, transcodeDecider: transcodeDecider, players: players, scrobbler: scrobbler, playlists: playlists, provider: provider, - sonic: sonicSvc, + sonic: sonicSvc, lyrics: lyricsSvc, + lyricsCache: cache.NewSimpleCache[string, model.LyricList](cache.Options{ + SizeLimit: 1000, + DefaultTTL: 5 * time.Minute, + }), } r.Handler = r.routes() return r @@ -155,6 +164,7 @@ func (api *Router) routes() http.Handler { 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}/lyrics", api.getLyrics) // 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) diff --git a/server/jellyfin/api_test.go b/server/jellyfin/api_test.go index 1b390e75a..62af74794 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, nil) + api := New(ds, nil, 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, nil) + api := New(&tests.MockDataStore{}, nil, 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, nil) + api := New(&tests.MockDataStore{}, nil, 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, nil) + api := New(ds, nil, nil, nil, fp, nil, 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, nil) + api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil) login := func() int { w := httptest.NewRecorder() diff --git a/server/jellyfin/dto/dto.go b/server/jellyfin/dto/dto.go index 7720640c5..9847a239c 100644 --- a/server/jellyfin/dto/dto.go +++ b/server/jellyfin/dto/dto.go @@ -256,3 +256,32 @@ type PlaybackInfoResponse struct { MediaSources []MediaSourceInfo `json:"MediaSources"` PlaySessionId string `json:"PlaySessionId"` } + +// LyricDto mirrors Jellyfin's GET /Audio/{itemId}/Lyrics response. Feishin and Jellify read only +// Lyrics[].Text and Start (ticks); Metadata is for completeness/Finamp. +type LyricDto struct { + Metadata LyricMetadata `json:"Metadata"` + Lyrics []LyricLine `json:"Lyrics"` +} + +type LyricMetadata struct { + Artist string `json:"Artist,omitempty"` + Album string `json:"Album,omitempty"` + Title string `json:"Title,omitempty"` + Length int64 `json:"Length,omitempty"` + Offset *int64 `json:"Offset,omitempty"` + IsSynced bool `json:"IsSynced"` +} + +type LyricLine struct { + Text string `json:"Text"` + Start *int64 `json:"Start,omitempty"` + Cues []LyricLineCue `json:"Cues,omitempty"` +} + +type LyricLineCue struct { + Position int `json:"Position"` + EndPosition int `json:"EndPosition"` + Start int64 `json:"Start"` + End *int64 `json:"End,omitempty"` +} diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go index bd817e432..c65b8933d 100644 --- a/server/jellyfin/dto/mappers.go +++ b/server/jellyfin/dto/mappers.go @@ -8,7 +8,16 @@ import ( "github.com/navidrome/navidrome/model" ) -func TicksFromSeconds(sec float32) int64 { return int64(float64(sec) * 1e7) } +// Jellyfin wire times are ticks: 100ns units, i.e. 10,000 per millisecond. +const ticksPerMillis = 10_000 + +func TicksFromSeconds(sec float32) int64 { return int64(float64(sec) * 1000 * ticksPerMillis) } + +// TicksFromMillis converts milliseconds (Navidrome lyric timestamps) to ticks. +func TicksFromMillis(ms int64) int64 { return ms * ticksPerMillis } + +// MillisFromTicks converts ticks (client-reported playback positions) to milliseconds. +func MillisFromTicks(ticks int64) int64 { return ticks / ticksPerMillis } // 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. @@ -59,6 +68,20 @@ func channelLayout(n int) string { // 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 { + streams := make([]MediaStream, 1, 2) + streams[0] = 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), + } + // Finamp gates its lyrics view on a Lyric stream in PlaybackInfo, not on HasLyrics. + if mf.HasEmbeddedLyrics() { + streams = append(streams, MediaStream{Type: "Lyric", Index: 1, IsExternal: true}) + } return MediaSourceInfo{ Id: EncodeID(mf.ID), Protocol: "Http", @@ -73,17 +96,9 @@ func MediaSourceFromMediaFile(mf model.MediaFile) MediaSourceInfo { 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{}, + MediaStreams: streams, + MediaAttachments: []any{}, + Formats: []string{}, } } @@ -119,7 +134,7 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto { MediaType: "Audio", IsFolder: false, LocationType: "FileSystem", - HasLyrics: mf.Lyrics != "", + HasLyrics: mf.HasEmbeddedLyrics(), ParentId: EncodeID(mf.AlbumID), Album: mf.Album, AlbumId: EncodeID(mf.AlbumID), @@ -254,3 +269,49 @@ func PlaylistToBaseItem(p model.Playlist) BaseItemDto { UserData: UserData(p.Annotations, p.ID), } } + +// LyricDtoFromLyrics maps one lyric track to Jellyfin's LyricDto. Clients infer synced-vs-plain +// from per-line Start presence, so synced drops start-less lines and unsynced never emits Start. +func LyricDtoFromLyrics(mf model.MediaFile, lyrics model.Lyrics) LyricDto { + d := LyricDto{ + Metadata: LyricMetadata{ + Artist: cmp.Or(lyrics.DisplayArtist, mf.Artist), + Album: mf.Album, + Title: cmp.Or(lyrics.DisplayTitle, mf.Title), + Length: TicksFromSeconds(mf.Duration), + IsSynced: lyrics.Synced, + }, + Lyrics: make([]LyricLine, 0, len(lyrics.Line)), + } + if lyrics.Offset != nil { + offset := TicksFromMillis(*lyrics.Offset) + d.Metadata.Offset = &offset + } + for _, line := range lyrics.Line { + out := LyricLine{Text: line.Value} + if lyrics.Synced { + if line.Start == nil { + continue + } + start := TicksFromMillis(*line.Start) + out.Start = &start + for _, cue := range line.Cue { + if cue.Start == nil { + continue + } + c := LyricLineCue{ + Position: cue.ByteStart, + EndPosition: cue.ByteEnd, + Start: TicksFromMillis(*cue.Start), + } + if cue.End != nil { + end := TicksFromMillis(*cue.End) + c.End = &end + } + out.Cues = append(out.Cues, c) + } + } + d.Lyrics = append(d.Lyrics, out) + } + return d +} diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go index bd351bee9..e72efd617 100644 --- a/server/jellyfin/dto/mappers_test.go +++ b/server/jellyfin/dto/mappers_test.go @@ -39,7 +39,7 @@ var _ = Describe("mappers", func() { 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"}]`} + SortTitle: "sort song", Lyrics: `[{"line":[{"value":"la"}]}]`} It("omits MediaSources and SortName when Fields does not ask for them", func() { item := SongToBaseItem(mf, nil) @@ -60,6 +60,8 @@ var _ = Describe("mappers", func() { 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()) + // "[]" is the no-lyrics sentinel, not a truthy value. + Expect(SongToBaseItem(model.MediaFile{ID: "s3", Title: "Empty Lyrics", Lyrics: "[]"}, nil).HasLyrics).To(BeFalse()) }) }) @@ -149,6 +151,29 @@ var _ = Describe("mappers", func() { Expect(j).To(ContainSubstring(`"SupportsExternalStream":false`)) }) + Describe("Lyric media stream advertising", func() { + It("adds a Lyric media stream when the file has embedded lyrics", func() { + mf := model.MediaFile{ID: "s1", Lyrics: `[{"line":[{"value":"la"}]}]`} + src := MediaSourceFromMediaFile(mf) + Expect(src.MediaStreams).To(HaveLen(2)) + Expect(src.MediaStreams[0].Type).To(Equal("Audio")) + Expect(src.MediaStreams[1].Type).To(Equal("Lyric")) + Expect(src.MediaStreams[1].Index).To(Equal(1)) + Expect(src.MediaStreams[1].IsExternal).To(BeTrue()) + }) + + It("emits only the Audio stream without lyrics", func() { + src := MediaSourceFromMediaFile(model.MediaFile{ID: "s1"}) + Expect(src.MediaStreams).To(HaveLen(1)) + Expect(src.MediaStreams[0].Type).To(Equal("Audio")) + }) + + It("emits only the Audio stream for the post-scan empty-lyrics sentinel", func() { + src := MediaSourceFromMediaFile(model.MediaFile{ID: "s1", Lyrics: "[]"}) + Expect(src.MediaStreams).To(HaveLen(1)) + }) + }) + 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", @@ -278,3 +303,90 @@ var _ = Describe("mappers", func() { Expect(PlaylistToBaseItem(p).ImageTags).To(Equal(PlaylistToBaseItem(p).ImageTags)) }) }) + +var _ = Describe("LyricDtoFromLyrics", func() { + ms := func(v int64) *int64 { return &v } + + mf := model.MediaFile{ID: "s1", Title: "Song", Artist: "Artist", Album: "Album", Duration: 100} + + It("maps synced lyrics with tick conversion", func() { + l := model.Lyrics{ + DisplayArtist: "Display Artist", + DisplayTitle: "Display Title", + Synced: true, + Offset: ms(-150), + Line: []model.Line{ + {Start: ms(1000), Value: "line one"}, + {Start: ms(2500), Value: "line two"}, + }, + } + d := LyricDtoFromLyrics(mf, l) + Expect(d.Metadata.Artist).To(Equal("Display Artist")) + Expect(d.Metadata.Title).To(Equal("Display Title")) + Expect(d.Metadata.Album).To(Equal("Album")) + Expect(d.Metadata.IsSynced).To(BeTrue()) + Expect(*d.Metadata.Offset).To(Equal(int64(-1_500_000))) + Expect(d.Metadata.Length).To(Equal(TicksFromSeconds(100))) + Expect(d.Lyrics).To(HaveLen(2)) + Expect(d.Lyrics[0].Text).To(Equal("line one")) + Expect(*d.Lyrics[0].Start).To(Equal(int64(10_000_000))) + Expect(*d.Lyrics[1].Start).To(Equal(int64(25_000_000))) + }) + + It("falls back to the media file's artist and title", func() { + d := LyricDtoFromLyrics(mf, model.Lyrics{Line: []model.Line{{Value: "x"}}}) + Expect(d.Metadata.Artist).To(Equal("Artist")) + Expect(d.Metadata.Title).To(Equal("Song")) + }) + + It("drops start-less lines from synced lyrics", func() { + l := model.Lyrics{Synced: true, Line: []model.Line{ + {Start: ms(0), Value: "kept"}, + {Value: "dropped"}, + }} + d := LyricDtoFromLyrics(mf, l) + Expect(d.Lyrics).To(HaveLen(1)) + Expect(d.Lyrics[0].Text).To(Equal("kept")) + }) + + It("emits no Start on unsynced lyrics even when lines have one", func() { + l := model.Lyrics{Synced: false, Line: []model.Line{{Start: ms(1000), Value: "plain"}}} + d := LyricDtoFromLyrics(mf, l) + Expect(d.Lyrics).To(HaveLen(1)) + Expect(d.Lyrics[0].Start).To(BeNil()) + Expect(d.Metadata.IsSynced).To(BeFalse()) + }) + + It("maps word cues", func() { + end := int64(1500) + l := model.Lyrics{Synced: true, Line: []model.Line{{ + Start: ms(1000), + Value: "word cue", + Cue: []model.Cue{{Start: ms(1000), End: &end, Value: "word", ByteStart: 0, ByteEnd: 4}}, + }}} + d := LyricDtoFromLyrics(mf, l) + Expect(d.Lyrics[0].Cues).To(HaveLen(1)) + c := d.Lyrics[0].Cues[0] + Expect(c.Position).To(Equal(0)) + Expect(c.EndPosition).To(Equal(4)) + Expect(c.Start).To(Equal(int64(10_000_000))) + Expect(*c.End).To(Equal(int64(15_000_000))) + }) + + It("skips a start-less cue while keeping its sibling", func() { + l := model.Lyrics{Synced: true, Line: []model.Line{{ + Start: ms(1000), + Value: "word cue", + Cue: []model.Cue{ + {Start: nil, Value: "dropped", ByteStart: 0, ByteEnd: 7}, + {Start: ms(1000), Value: "kept", ByteStart: 8, ByteEnd: 12}, + }, + }}} + d := LyricDtoFromLyrics(mf, l) + Expect(d.Lyrics[0].Cues).To(HaveLen(1)) + c := d.Lyrics[0].Cues[0] + Expect(c.Position).To(Equal(8)) + Expect(c.EndPosition).To(Equal(12)) + Expect(c.Start).To(Equal(int64(10_000_000))) + }) +}) diff --git a/server/jellyfin/e2e/e2e_suite_test.go b/server/jellyfin/e2e/e2e_suite_test.go index ea47dcdaa..e0f7b5bb5 100644 --- a/server/jellyfin/e2e/e2e_suite_test.go +++ b/server/jellyfin/e2e/e2e_suite_test.go @@ -42,6 +42,7 @@ import ( "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/core/matcher" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/scrobbler" @@ -119,9 +120,11 @@ func buildTestFS() storagetest.FakeFS { "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")), + "Rock/Led Zeppelin/IV/01 - Stairway To Heaven.mp3": ledZepIV(track(1, "Stairway To Heaven", _t{ + "lyrics:eng": "[00:01.00]There's a lady who's sure\n[00:05.50]All that glitters is gold", + })), + "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"})), @@ -325,6 +328,7 @@ func setupTestDB() { playlists.NewPlaylists(ds, core.NewImageUploadService()), providerFake, sonicSvc, + lyrics.NewLyrics(ds, nil), ) } diff --git a/server/jellyfin/e2e/lyrics_test.go b/server/jellyfin/e2e/lyrics_test.go new file mode 100644 index 000000000..279f288b8 --- /dev/null +++ b/server/jellyfin/e2e/lyrics_test.go @@ -0,0 +1,71 @@ +package e2e + +import ( + "net/http" + + "github.com/navidrome/navidrome/server/jellyfin/dto" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Lyrics", func() { + BeforeEach(func() { setupTestDB() }) + + Describe("PlaybackInfo", func() { + It("advertises a Lyric stream for a track with embedded lyrics", func() { + id := songID("Stairway To Heaven") + var info dto.PlaybackInfoResponse + parseInto(get("/Items/"+enc(id)+"/PlaybackInfo"), &info) + var found bool + for _, s := range info.MediaSources[0].MediaStreams { + if s.Type == "Lyric" { + found = true + } + } + Expect(found).To(BeTrue()) + }) + + It("does not advertise a Lyric stream for a track without lyrics", func() { + id := songID("So What") + var info dto.PlaybackInfoResponse + parseInto(get("/Items/"+enc(id)+"/PlaybackInfo"), &info) + for _, s := range info.MediaSources[0].MediaStreams { + Expect(s.Type).ToNot(Equal("Lyric")) + } + }) + }) + + Describe("GET /Audio/{id}/Lyrics", func() { + It("returns the LyricDto for a track with embedded synced lyrics", func() { + id := songID("Stairway To Heaven") + var lyrics dto.LyricDto + parseInto(get("/Audio/"+enc(id)+"/Lyrics"), &lyrics) + Expect(lyrics.Lyrics).To(HaveLen(2)) + Expect(lyrics.Lyrics[0].Text).To(Equal("There's a lady who's sure")) + Expect(lyrics.Lyrics[0].Start).ToNot(BeNil()) + Expect(*lyrics.Lyrics[0].Start).To(Equal(int64(10000000))) + Expect(lyrics.Metadata.IsSynced).To(BeTrue()) + }) + + It("returns 404 for a track without lyrics", func() { + id := songID("So What") + Expect(get("/Audio/" + enc(id) + "/Lyrics").Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 404 for a fabricated id", func() { + Expect(get("/Audio/" + enc("nope") + "/Lyrics").Code).To(Equal(http.StatusNotFound)) + }) + }) + + Describe("HasLyrics badge", func() { + It("is true for a track with embedded lyrics and omitted/false otherwise", func() { + var stairway dto.BaseItemDto + parseInto(get("/Items/"+enc(songID("Stairway To Heaven"))), &stairway) + Expect(stairway.HasLyrics).To(BeTrue()) + + var soWhat dto.BaseItemDto + parseInto(get("/Items/"+enc(songID("So What"))), &soWhat) + Expect(soWhat.HasLyrics).To(BeFalse()) + }) + }) +}) diff --git a/server/jellyfin/lyrics.go b/server/jellyfin/lyrics.go new file mode 100644 index 000000000..f6468ceeb --- /dev/null +++ b/server/jellyfin/lyrics.go @@ -0,0 +1,47 @@ +package jellyfin + +import ( + "context" + "net/http" + "time" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/jellyfin/dto" +) + +// cachedLyrics resolves lyrics through the full source pipeline (embedded, sidecar, plugins), +// caching results — including empty: clients poll per played track, so misses are the hot path. +func (api *Router) cachedLyrics(ctx context.Context, mf *model.MediaFile) model.LyricList { + list, err := api.lyricsCache.GetWithLoader(mf.ID, func(string) (model.LyricList, time.Duration, error) { + l, err := api.lyrics.GetLyrics(ctx, mf) + return l, 0, err // 0 → cache DefaultTTL + }) + if err != nil { + log.Error(ctx, "Error getting lyrics", "id", mf.ID, "title", mf.Title, err) + return nil + } + return list +} + +// getLyrics serves GET /Audio/{itemId}/Lyrics. Jellyfin returns 404 when a track has no lyrics +// (never an empty 200); all surveyed clients treat that gracefully. +func (api *Router) getLyrics(w http.ResponseWriter, r *http.Request) { + mf, ok := api.mediaFileForRequest(w, r) + if !ok { + return + } + main, found := servableLyric(api.cachedLyrics(r.Context(), mf)) + if !found { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + api.ok(w, r, dto.LyricDtoFromLyrics(*mf, main)) +} + +// servableLyric is the single predicate for both serving and advertising, so PlaybackInfo never +// advertises a Lyric stream that this endpoint would 404. +func servableLyric(list model.LyricList) (model.Lyrics, bool) { + main, found := list.Main() + return main, found && !main.IsEmpty() +} diff --git a/server/jellyfin/lyrics_test.go b/server/jellyfin/lyrics_test.go new file mode 100644 index 000000000..f2c5993f6 --- /dev/null +++ b/server/jellyfin/lyrics_test.go @@ -0,0 +1,132 @@ +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/navidrome/navidrome/utils/cache" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// fakeLyricsService returns canned lyrics per media-file ID and counts calls. +type fakeLyricsService struct { + lyrics map[string]model.LyricList + err error + calls int +} + +func (f *fakeLyricsService) GetLyrics(_ context.Context, mf *model.MediaFile) (model.LyricList, error) { + f.calls++ + if f.err != nil { + return nil, f.err + } + return f.lyrics[mf.ID], nil +} + +func (f *fakeLyricsService) GetLyricsByArtistTitle(context.Context, string, string) (model.LyricList, error) { + return nil, nil +} + +func p(ms int64) *int64 { return &ms } + +func newTestLyricsCache() cache.SimpleCache[string, model.LyricList] { + return cache.NewSimpleCache[string, model.LyricList](cache.Options{SizeLimit: 1000}) +} + +var _ = Describe("getLyrics", func() { + var api *Router + var ds *tests.MockDataStore + var fake *fakeLyricsService + + BeforeEach(func() { + ds = &tests.MockDataStore{} + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Song", LibraryID: 1}, + {ID: "s2", Title: "Silent Song", LibraryID: 1}, + }) + fake = &fakeLyricsService{lyrics: map[string]model.LyricList{}} + api = &Router{ + ds: ds, + lyrics: fake, + lyricsCache: newTestLyricsCache(), + } + }) + + doRequest := func(id string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + ctx := request.WithUser(context.Background(), model.User{ID: "u1", Libraries: model.Libraries{{ID: 1}}}) + // Clients send hex-encoded ids (matching real traffic and the other handler tests). + enc := dto.EncodeID(id) + r := httptest.NewRequest("GET", "/Audio/"+enc+"/Lyrics", nil).WithContext(ctx) + r = withChiURLParam(r, "itemId", enc) + invoke(api.getLyrics, w, r) + return w + } + + It("returns 200 with a LyricDto for a track with synced lyrics", func() { + fake.lyrics["s1"] = model.LyricList{ + {Kind: "main", Synced: true, Line: []model.Line{{Start: p(1000), Value: "hello"}}}, + } + w := doRequest("s1") + + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.LyricDto + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Lyrics).To(HaveLen(1)) + Expect(res.Lyrics[0].Text).To(Equal("hello")) + Expect(res.Lyrics[0].Start).ToNot(BeNil()) + Expect(*res.Lyrics[0].Start).To(Equal(int64(10000000))) + }) + + It("serves the main-kind lyric when a translation is also present", func() { + fake.lyrics["s1"] = model.LyricList{ + {Kind: "translation", Synced: true, Line: []model.Line{{Start: p(1000), Value: "bonjour"}}}, + {Kind: "main", Synced: true, Line: []model.Line{{Start: p(1000), Value: "hello"}}}, + } + w := doRequest("s1") + + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.LyricDto + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Lyrics).To(HaveLen(1)) + Expect(res.Lyrics[0].Text).To(Equal("hello")) + }) + + It("returns 404 when the service returns no lyrics", func() { + w := doRequest("s2") + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 404 when the main lyric has no lines", func() { + fake.lyrics["s1"] = model.LyricList{{Kind: "main", Lang: "eng"}} + w := doRequest("s1") + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 404 for an unknown item id", func() { + w := doRequest("unknown") + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("caches results so a second request doesn't re-invoke the service", func() { + fake.lyrics["s1"] = model.LyricList{ + {Kind: "main", Synced: true, Line: []model.Line{{Start: p(1000), Value: "hello"}}}, + } + Expect(doRequest("s1").Code).To(Equal(http.StatusOK)) + Expect(doRequest("s1").Code).To(Equal(http.StatusOK)) + Expect(fake.calls).To(Equal(1)) + }) + + It("caches empty results too", func() { + Expect(doRequest("s2").Code).To(Equal(http.StatusNotFound)) + Expect(doRequest("s2").Code).To(Equal(http.StatusNotFound)) + Expect(fake.calls).To(Equal(1)) + }) +}) diff --git a/server/jellyfin/routing_test.go b/server/jellyfin/routing_test.go index e007dedf4..b0bc92caf 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, nil) + api = New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil) }) It("serves a fully lowercase path directly", func() { diff --git a/server/jellyfin/sessions.go b/server/jellyfin/sessions.go index f462f283a..f83e5182d 100644 --- a/server/jellyfin/sessions.go +++ b/server/jellyfin/sessions.go @@ -49,7 +49,7 @@ func (api *Router) reportPlaybackStart(w http.ResponseWriter, r *http.Request) { clientId, clientName := clientIdentity(ctx) err := api.scrobbler.ReportPlayback(ctx, scrobbler.ReportPlaybackParams{ MediaId: body.ItemId, - PositionMs: body.PositionTicks / 10_000, + PositionMs: dto.MillisFromTicks(body.PositionTicks), State: scrobbler.StatePlaying, PlaybackRate: 1.0, ClientId: clientId, @@ -73,7 +73,7 @@ func (api *Router) reportPlaybackProgress(w http.ResponseWriter, r *http.Request clientId, clientName := clientIdentity(ctx) err := api.scrobbler.ReportPlayback(ctx, scrobbler.ReportPlaybackParams{ MediaId: body.ItemId, - PositionMs: body.PositionTicks / 10_000, + PositionMs: dto.MillisFromTicks(body.PositionTicks), State: state, PlaybackRate: 1.0, ClientId: clientId, @@ -97,7 +97,7 @@ func (api *Router) reportPlaybackStopped(w http.ResponseWriter, r *http.Request) err := api.scrobbler.ReportPlayback(ctx, scrobbler.ReportPlaybackParams{ MediaId: body.ItemId, - PositionMs: body.PositionTicks / 10_000, + PositionMs: dto.MillisFromTicks(body.PositionTicks), State: scrobbler.StateStopped, ClientId: clientId, ClientName: clientName, diff --git a/server/jellyfin/socket_test.go b/server/jellyfin/socket_test.go index d9d74bcb9..7d8874476 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, nil) + api = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil) }) It("upgrades when authenticated via the api_key query parameter", func() { diff --git a/server/jellyfin/stream.go b/server/jellyfin/stream.go index 28411f9d1..022c00c96 100644 --- a/server/jellyfin/stream.go +++ b/server/jellyfin/stream.go @@ -5,6 +5,7 @@ import ( "math" "net/http" "net/url" + "slices" "strconv" "strings" @@ -44,6 +45,15 @@ func (api *Router) getPlaybackInfo(w http.ResponseWriter, r *http.Request) { return } src := dto.MediaSourceFromMediaFile(*mf) + // The mapper only sees embedded lyrics; per-track we can afford the full pipeline + // (sidecars, plugins) so Finamp's Lyric-stream gate reflects every source. + if !slices.ContainsFunc(src.MediaStreams, func(s dto.MediaStream) bool { return s.Type == "Lyric" }) { + if _, found := servableLyric(api.cachedLyrics(r.Context(), mf)); found { + src.MediaStreams = append(src.MediaStreams, dto.MediaStream{ + Type: "Lyric", Index: len(src.MediaStreams), IsExternal: true, + }) + } + } // 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 diff --git a/server/jellyfin/stream_test.go b/server/jellyfin/stream_test.go index 7063d321a..f77c10ec1 100644 --- a/server/jellyfin/stream_test.go +++ b/server/jellyfin/stream_test.go @@ -33,7 +33,11 @@ var _ = Describe("Stream", func() { ds = &tests.MockDataStore{} streamer = &fakeMediaStreamer{} decider = &fakeTranscodeDecider{} - api = &Router{ds: ds, streamer: streamer, transcodeDecider: decider} + api = &Router{ + ds: ds, streamer: streamer, transcodeDecider: decider, + lyrics: &fakeLyricsService{lyrics: map[string]model.LyricList{}}, + lyricsCache: newTestLyricsCache(), + } }) Describe("getPlaybackInfo", func() { @@ -76,6 +80,88 @@ var _ = Describe("Stream", func() { Expect(w.Code).To(Equal(http.StatusNotFound)) }) + + playbackInfo := func() dto.PlaybackInfoResponse { + 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) + var res dto.PlaybackInfoResponse + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + return res + } + + lyricStreams := func(res dto.PlaybackInfoResponse) []dto.MediaStream { + var out []dto.MediaStream + for _, s := range res.MediaSources[0].MediaStreams { + if s.Type == "Lyric" { + out = append(out, s) + } + } + return out + } + + It("advertises a Lyric stream for plugin/sidecar-sourced lyrics not embedded in the file", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 1}, + }) + api.lyrics = &fakeLyricsService{lyrics: map[string]model.LyricList{ + "s1": {{Kind: "main", Synced: true, Line: []model.Line{{Value: "hello"}}}}, + }} + + Expect(lyricStreams(playbackInfo())).To(HaveLen(1)) + }) + + It("advertises no Lyric stream when the pipeline finds nothing", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 1}, + }) + + Expect(lyricStreams(playbackInfo())).To(BeEmpty()) + }) + + It("advertises no Lyric stream when the lyrics endpoint would 404 (main lyric has no lines)", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 1}, + }) + api.lyrics = &fakeLyricsService{lyrics: map[string]model.LyricList{ + "s1": {{Kind: "main", Lang: "eng"}}, + }} + + Expect(lyricStreams(playbackInfo())).To(BeEmpty()) + }) + + It("doesn't duplicate the Lyric stream when lyrics are already embedded", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 1, Lyrics: `[{"lang":"xxx","line":[]}]`}, + }) + api.lyrics = &fakeLyricsService{lyrics: map[string]model.LyricList{ + "s1": {{Kind: "main", Synced: true, Line: []model.Line{{Value: "hello"}}}}, + }} + + Expect(lyricStreams(playbackInfo())).To(HaveLen(1)) + }) + + It("still returns 200 with a valid MediaSource and no Lyric stream when the lyrics pipeline errors", func() { + // Own ID: an erroring loader isn't cached, but a shared ID could still pick up + // another test's cached (non-error) result and mask this assertion. + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "s-err", Title: "Song", Suffix: "mp3", Duration: 100, Size: 1000, LibraryID: 1}, + }) + api.lyrics = &fakeLyricsService{err: errors.New("boom")} + + w := httptest.NewRecorder() + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("s-err")+"/PlaybackInfo", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID("s-err")) + 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("s-err"))) + Expect(lyricStreams(res)).To(BeEmpty()) + }) }) Describe("streamAudio", func() { diff --git a/server/jellyfin/system.go b/server/jellyfin/system.go index 7e539be98..6f0c359d4 100644 --- a/server/jellyfin/system.go +++ b/server/jellyfin/system.go @@ -17,8 +17,9 @@ import ( ) // 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" +// on it, so it must stay a real Jellyfin release, not Navidrome's own version. 10.9+ is required +// for Feishin to use the server lyrics endpoint. +const jellyfinVersion = "10.9.11" func (api *Router) serverName() string { if conf.Server.Jellyfin.ServerName != "" { From 756df9decf0cfe55a1eb528f892f31367e466c0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Thu, 16 Jul 2026 20:10:35 -0400 Subject: [PATCH 13/71] fix: dedupe and cap concurrent lyrics plugin fetches (#5792) * fix: dedupe and cap concurrent lyrics plugin fetches Clients like Finamp prefetch lyrics for several queue tracks at once. The resulting burst of concurrent plugin calls can rate-limit the primary lyrics provider into a timeout, making the plugin fall back to a lower quality source and cache the bad result. SimpleCache.GetWithLoader now deduplicates concurrent loads of the same key via singleflight, with every waiter receiving the winner's result or error. The Jellyfin lyrics loader is detached from the request context so one cancelled request cannot fail the load for all waiters, and the lyrics adapter caps in-flight plugin calls at 2 per plugin, queueing the rest. As a side effect, the cached HTTP client used by the Last.fm, Deezer and ListenBrainz agents also collapses identical concurrent requests into a single upstream call. * fix: harden lyrics concurrency fixes per review Replace the stringified singleflight keys with a per-cache flight map keyed by the cache key type itself, eliminating potential key collisions for non-string keys, the nil-interface assertion panic, and the stringification overhead. Release the lyrics semaphore slot via defer so a panicking plugin call cannot leak it, and bound the detached lyrics load with a one-minute timeout so a hung plugin cannot pin its singleflight and semaphore slot indefinitely. --- plugins/lyrics_adapter.go | 10 +++ plugins/lyrics_adapter_test.go | 41 +++++++++++ plugins/manager_loader.go | 1 + plugins/manager_plugin.go | 1 + server/jellyfin/lyrics.go | 9 ++- server/jellyfin/lyrics_test.go | 31 +++++++-- utils/cache/simple_cache.go | 79 ++++++++++++++++------ utils/cache/simple_cache_test.go | 112 +++++++++++++++++++++++++++++++ 8 files changed, 257 insertions(+), 27 deletions(-) diff --git a/plugins/lyrics_adapter.go b/plugins/lyrics_adapter.go index 281f022fb..9e02115e7 100644 --- a/plugins/lyrics_adapter.go +++ b/plugins/lyrics_adapter.go @@ -14,6 +14,10 @@ const ( FuncLyricsGetLyrics = "nd_lyrics_get_lyrics" ) +// maxConcurrentLyricsCalls caps in-flight lyrics calls per plugin: clients prefetch +// lyrics for whole queues, and the resulting burst can rate-limit upstream providers. +const maxConcurrentLyricsCalls = 2 + func init() { registerCapability( CapabilityLyrics, @@ -34,6 +38,12 @@ type LyricsPlugin struct { // GetLyrics calls the plugin to fetch lyrics, then content-sniffs each response // via model.ParseLyrics (TTML/SRT/YAML/LRC/plain). func (l *LyricsPlugin) GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) { + select { + case l.plugin.lyricsSem <- struct{}{}: + defer func() { <-l.plugin.lyricsSem }() + case <-ctx.Done(): + return nil, ctx.Err() + } req := capabilities.GetLyricsRequest{ Track: mediaFileToTrackInfo(l.plugin, mf), } diff --git a/plugins/lyrics_adapter_test.go b/plugins/lyrics_adapter_test.go index 6e82dbfab..d110665f5 100644 --- a/plugins/lyrics_adapter_test.go +++ b/plugins/lyrics_adapter_test.go @@ -3,6 +3,8 @@ package plugins import ( + "context" + "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -71,6 +73,45 @@ var _ = Describe("LyricsPlugin", Ordered, func() { Expect(result[0].Lang).To(Equal("xxx")) }) + It("blocks new calls while the per-plugin concurrency cap is saturated", func() { + sem := provider.plugin.lyricsSem + for range cap(sem) { + sem <- struct{}{} + } + + ctx := GinkgoT().Context() + track := &model.MediaFile{ID: "track-1", Title: "Test Song", Artist: "Test Artist"} + done := make(chan error, 1) + go func() { + _, err := provider.GetLyrics(ctx, track) + done <- err + }() + + Consistently(done, "500ms").ShouldNot(Receive()) + <-sem // free one slot; the pending call should now proceed + Eventually(done).Should(Receive(BeNil())) + for range cap(sem) - 1 { + <-sem + } + }) + + It("gives up waiting for a slot when the context is cancelled", func() { + sem := provider.plugin.lyricsSem + for range cap(sem) { + sem <- struct{}{} + } + defer func() { + for range cap(sem) { + <-sem + } + }() + + ctx, cancel := context.WithCancel(GinkgoT().Context()) + cancel() + _, err := provider.GetLyrics(ctx, &model.MediaFile{ID: "track-1"}) + Expect(err).To(MatchError(context.Canceled)) + }) + It("returns error when plugin returns error", func() { manager, _ := createTestManagerWithPlugins(map[string]map[string]string{ "test-lyrics": {"error": "service unavailable"}, diff --git a/plugins/manager_loader.go b/plugins/manager_loader.go index 757ededb5..675c85e26 100644 --- a/plugins/manager_loader.go +++ b/plugins/manager_loader.go @@ -421,6 +421,7 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { allowedUserIDs: allowedUsers, allUsers: p.AllUsers, libraries: newLibraryAccess(allowedLibraries, p.AllLibraries), + lyricsSem: make(chan struct{}, maxConcurrentLyricsCalls), } m.mu.Unlock() loaded = true diff --git a/plugins/manager_plugin.go b/plugins/manager_plugin.go index f0c7c56d5..155663781 100644 --- a/plugins/manager_plugin.go +++ b/plugins/manager_plugin.go @@ -24,6 +24,7 @@ type plugin struct { allowedUserIDs []string // User IDs this plugin can access (from DB configuration) allUsers bool // If true, plugin can access all users libraries libraryAccess + lyricsSem chan struct{} // Caps concurrent lyrics calls (see LyricsPlugin.GetLyrics) } // instance creates a new plugin instance for the given context. diff --git a/server/jellyfin/lyrics.go b/server/jellyfin/lyrics.go index f6468ceeb..a9d77ba19 100644 --- a/server/jellyfin/lyrics.go +++ b/server/jellyfin/lyrics.go @@ -10,11 +10,18 @@ import ( "github.com/navidrome/navidrome/server/jellyfin/dto" ) +const lyricsLoadTimeout = time.Minute + // cachedLyrics resolves lyrics through the full source pipeline (embedded, sidecar, plugins), // caching results — including empty: clients poll per played track, so misses are the hot path. func (api *Router) cachedLyrics(ctx context.Context, mf *model.MediaFile) model.LyricList { + // The load is shared across requests (singleflight) and cached, so don't let one + // cancelled request abort it for everybody — detach it from the request's lifetime, + // keeping a bound so a hung plugin can't pin the fetch (and its plugin slot) forever. + loadCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), lyricsLoadTimeout) + defer cancel() list, err := api.lyricsCache.GetWithLoader(mf.ID, func(string) (model.LyricList, time.Duration, error) { - l, err := api.lyrics.GetLyrics(ctx, mf) + l, err := api.lyrics.GetLyrics(loadCtx, mf) return l, 0, err // 0 → cache DefaultTTL }) if err != nil { diff --git a/server/jellyfin/lyrics_test.go b/server/jellyfin/lyrics_test.go index f2c5993f6..402d14ed0 100644 --- a/server/jellyfin/lyrics_test.go +++ b/server/jellyfin/lyrics_test.go @@ -17,13 +17,18 @@ import ( // fakeLyricsService returns canned lyrics per media-file ID and counts calls. type fakeLyricsService struct { - lyrics map[string]model.LyricList - err error - calls int + lyrics map[string]model.LyricList + err error + calls int + hadDeadline bool } -func (f *fakeLyricsService) GetLyrics(_ context.Context, mf *model.MediaFile) (model.LyricList, error) { +func (f *fakeLyricsService) GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) { f.calls++ + _, f.hadDeadline = ctx.Deadline() + if err := ctx.Err(); err != nil { + return nil, err + } if f.err != nil { return nil, f.err } @@ -129,4 +134,22 @@ var _ = Describe("getLyrics", func() { Expect(doRequest("s2").Code).To(Equal(http.StatusNotFound)) Expect(fake.calls).To(Equal(1)) }) + + It("completes and caches the fetch even when the request context is cancelled", func() { + fake.lyrics["s1"] = model.LyricList{ + {Kind: "main", Synced: true, Line: []model.Line{{Start: p(1000), Value: "hello"}}}, + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + list := api.cachedLyrics(ctx, &model.MediaFile{ID: "s1"}) + Expect(list).ToNot(BeEmpty()) + Expect(doRequest("s1").Code).To(Equal(http.StatusOK)) + Expect(fake.calls).To(Equal(1)) + }) + + It("bounds the detached fetch with a timeout", func() { + Expect(doRequest("s2").Code).To(Equal(http.StatusNotFound)) + Expect(fake.hadDeadline).To(BeTrue()) + }) }) diff --git a/utils/cache/simple_cache.go b/utils/cache/simple_cache.go index eb3c99995..494451c9e 100644 --- a/utils/cache/simple_cache.go +++ b/utils/cache/simple_cache.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "runtime" + "sync" "sync/atomic" "time" @@ -44,7 +45,8 @@ func NewSimpleCache[K comparable, V any](options ...Options) SimpleCache[K, V] { c := ttlcache.New[K, V](opts...) cache := &simpleCache[K, V]{ - data: c, + data: c, + loads: make(map[K]*flight[V]), } go cache.data.Start() @@ -61,6 +63,23 @@ const evictionTimeout = 1 * time.Hour type simpleCache[K comparable, V any] struct { data *ttlcache.Cache[K, V] evictionDeadline atomic.Pointer[time.Time] + loadsMu sync.Mutex + loads map[K]*flight[V] +} + +// flight tracks an in-progress load so concurrent misses of the same key share it. +type flight[V any] struct { + done chan struct{} + val V + err error +} + +func (f *flight[V]) result() (V, error) { + if f.err != nil { + var zero V + return zero, fmt.Errorf("cache error: loader returned %w", f.err) + } + return f.val, nil } func (c *simpleCache[K, V]) Add(key K, value V) error { @@ -90,31 +109,47 @@ func (c *simpleCache[K, V]) Get(key K) (V, error) { return item.Value(), nil } +// GetWithLoader loads misses via the loader, deduplicating concurrent loads of +// the same key: one loader call runs, and every waiter shares its result (or error). func (c *simpleCache[K, V]) GetWithLoader(key K, loader func(key K) (V, time.Duration, error)) (V, error) { - var err error - loaderWrapper := ttlcache.LoaderFunc[K, V]( - func(t *ttlcache.Cache[K, V], key K) *ttlcache.Item[K, V] { - c.evictExpired() - var value V - var ttl time.Duration - value, ttl, err = loader(key) - if err != nil { - return nil - } - return t.Set(key, value, ttl) - }, - ) - item := c.data.Get(key, ttlcache.WithLoader[K, V](loaderWrapper)) - if item == nil { - var zero V - if err != nil { - return zero, fmt.Errorf("cache error: loader returned %w", err) - } - return zero, errors.New("item not found") + if item := c.data.Get(key); item != nil { + return item.Value(), nil } - return item.Value(), nil + + c.loadsMu.Lock() + if f, ok := c.loads[key]; ok { + c.loadsMu.Unlock() + <-f.done + return f.result() + } + f := &flight[V]{done: make(chan struct{}), err: errLoaderPanicked} + c.loads[key] = f + c.loadsMu.Unlock() + + // Deregister even if the loader panics, so waiters get an error instead of + // blocking forever on a flight that will never complete. + defer func() { + close(f.done) + c.loadsMu.Lock() + delete(c.loads, key) + c.loadsMu.Unlock() + }() + + if item := c.data.Get(key); item != nil { // a flight may have completed since the miss + f.val, f.err = item.Value(), nil + } else { + c.evictExpired() + var ttl time.Duration + f.val, ttl, f.err = loader(key) + if f.err == nil { + c.data.Set(key, f.val, ttl) + } + } + return f.result() } +var errLoaderPanicked = errors.New("loader panicked") + func (c *simpleCache[K, V]) evictExpired() { if c.evictionDeadline.Load() == nil || c.evictionDeadline.Load().Before(time.Now()) { c.data.DeleteExpired() diff --git a/utils/cache/simple_cache_test.go b/utils/cache/simple_cache_test.go index 45ba2c966..1c4f5c9bb 100644 --- a/utils/cache/simple_cache_test.go +++ b/utils/cache/simple_cache_test.go @@ -3,6 +3,8 @@ package cache import ( "errors" "fmt" + "sync" + "sync/atomic" "time" . "github.com/onsi/ginkgo/v2" @@ -69,6 +71,116 @@ var _ = Describe("SimpleCache", func() { _, err := cache.GetWithLoader("key", loader) Expect(err).To(HaveOccurred()) }) + + It("suppresses concurrent loads for the same key", func() { + var calls atomic.Int32 + release := make(chan struct{}) + started := make(chan struct{}, 10) + loader := func(key string) (string, time.Duration, error) { + calls.Add(1) + started <- struct{}{} + <-release + return "shared", time.Minute, nil + } + + const n = 5 + var wg sync.WaitGroup + results := make([]string, n) + errs := make([]error, n) + for i := range n { + wg.Go(func() { + results[i], errs[i] = cache.GetWithLoader("key", loader) + }) + } + + Eventually(started).Should(Receive()) + Consistently(started).ShouldNot(Receive()) + close(release) + wg.Wait() + + Expect(calls.Load()).To(Equal(int32(1))) + for i := range n { + Expect(errs[i]).ToNot(HaveOccurred()) + Expect(results[i]).To(Equal("shared")) + } + }) + + It("returns the loader error to all concurrent callers", func() { + release := make(chan struct{}) + started := make(chan struct{}, 10) + loader := func(key string) (string, time.Duration, error) { + started <- struct{}{} + <-release + return "", 0, errors.New("load failed") + } + + const n = 3 + var wg sync.WaitGroup + errs := make([]error, n) + for i := range n { + wg.Go(func() { + _, errs[i] = cache.GetWithLoader("key", loader) + }) + } + + Eventually(started).Should(Receive()) + Consistently(started).ShouldNot(Receive()) + close(release) + wg.Wait() + + for i := range n { + Expect(errs[i]).To(MatchError(ContainSubstring("load failed"))) + } + }) + + It("supports interface value types with nil results", func() { + c := NewSimpleCache[string, any]() + v, err := c.GetWithLoader("key", func(string) (any, time.Duration, error) { + return nil, time.Minute, nil + }) + Expect(err).ToNot(HaveOccurred()) + Expect(v).To(BeNil()) + }) + + It("cleans up the in-flight registration when the loader panics", func() { + Expect(func() { + _, _ = cache.GetWithLoader("key", func(string) (string, time.Duration, error) { + panic("boom") + }) + }).To(PanicWith("boom")) + + // Without cleanup this would deadlock on the never-completed flight + v, err := cache.GetWithLoader("key", func(string) (string, time.Duration, error) { + return "ok", 0, nil + }) + Expect(err).ToNot(HaveOccurred()) + Expect(v).To(Equal("ok")) + }) + + It("loads different keys independently", func() { + release := make(chan struct{}) + started := make(chan struct{}, 10) + loader := func(key string) (string, time.Duration, error) { + started <- struct{}{} + <-release + return key + "=value", time.Minute, nil + } + + var wg sync.WaitGroup + for _, key := range []string{"key1", "key2"} { + wg.Go(func() { + value, err := cache.GetWithLoader(key, loader) + Expect(err).ToNot(HaveOccurred()) + Expect(value).To(Equal(key + "=value")) + }) + } + + // Both loaders must be in flight at once: distinct keys are not suppressed + Eventually(started).Should(Receive()) + Eventually(started).Should(Receive()) + close(release) + wg.Wait() + }) }) Describe("Keys and Values", func() { From 27f02103923f0927a3cc20ba98ecbca4d73700bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Thu, 16 Jul 2026 20:47:47 -0400 Subject: [PATCH 14/71] fix(scrobbler): tolerate out-of-order playback reports (#5793) * fix(scrobbler): tolerate out-of-order playback reports Clients may fire reportPlayback requests concurrently (Feishin sends 'starting' and 'playing' in parallel, and the previous track's 'stopped' races the next track's start), so reports can be processed out of order. Two cases corrupted the now-playing session: a late 'starting' for the track already playing downgraded the session state, freezing position estimation at 0:00 until the next report; and a late 'stopped' for the previous track removed the new track's session and dispatched a playback report mislabeled with the new track's metadata, causing presence-style plugins (e.g. Discord Rich Presence) to clear or show stale state. ReportPlayback now ignores a 'starting' report when the session already has the same track in playing state, and ignores a 'stopped' report for a track other than the current session's - skipping both the session removal and the plugin dispatch, while still counting the play and dispatching external scrobbles for the stopped track. Reported in https://github.com/jeffvli/feishin/issues/2131 * fix(scrobbler): serialize session writes to close starting/playing race The out-of-order 'starting' guard checked the session cache before the media-file load, leaving a window where a concurrent 'playing' report on a fresh session could write between the check and the write, and still be overwritten back to 'starting'. Session check-then-write sections are now serialized by a mutex, with the guard re-checked after the load. Also tightens the guard comment to say 'playing session', matching the condition. Found by Codex review on #5793. * fix(scrobbler): fully exit ReportPlayback when ignoring out-of-order reports The out-of-order guards used 'break', which only exits the switch, so the post-switch NowPlaying block still ran for an ignored 'starting' report and enqueued a NowPlaying dispatch with the stale report's position - potentially overwriting a pending correct-position entry, since the queue is keyed by client. Return nil instead, so ignored reports have no side effects. Found by Gemini review on #5793. --- core/scrobbler/play_tracker.go | 37 +++++++- core/scrobbler/play_tracker_test.go | 134 +++++++++++++++++++++++++++- 2 files changed, 167 insertions(+), 4 deletions(-) diff --git a/core/scrobbler/play_tracker.go b/core/scrobbler/play_tracker.go index a2d7e8639..e21db42d2 100644 --- a/core/scrobbler/play_tracker.go +++ b/core/scrobbler/play_tracker.go @@ -89,6 +89,7 @@ type playTracker struct { ds model.DataStore broker events.Broker playMap cache.SimpleCache[string, PlaybackSession] + sessionsMu sync.Mutex // serializes playMap check-then-write across concurrent reports builtinScrobblers map[string]Scrobbler pluginScrobblers map[string]Scrobbler pluginLoader PluginLoader @@ -249,6 +250,12 @@ func (p *playTracker) getActiveScrobblers() map[string]Scrobbler { return combined } +// hasPlayingSession reports whether clientId's current session is already playing mediaId. +func (p *playTracker) hasPlayingSession(clientId, mediaId string) bool { + cur, err := p.playMap.Get(clientId) + return err == nil && cur.MediaFile.ID == mediaId && cur.State == StatePlaying +} + func remainingTTL(durationSec float32, positionMs int64, rate float64) time.Duration { if rate <= 0 { rate = 1.0 @@ -268,6 +275,12 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP switch params.State { case StateStarting: + // Clients may send starting/playing unordered; a late "starting" must not downgrade + // a playing session, or position estimation freezes until the next report. + if p.hasPlayingSession(clientId, params.MediaId) { + log.Trace(ctx, "Ignoring out-of-order starting report for playing session", "clientId", clientId, "mediaId", params.MediaId) + return nil + } mf, err := p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId) if err != nil { return err @@ -284,7 +297,15 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP PlaybackRate: params.PlaybackRate, LastReport: now, } + p.sessionsMu.Lock() + // re-check: a concurrent "playing" report may have created the session during the load above + if p.hasPlayingSession(clientId, params.MediaId) { + p.sessionsMu.Unlock() + log.Trace(ctx, "Ignoring out-of-order starting report for playing session", "clientId", clientId, "mediaId", params.MediaId) + return nil + } err = p.playMap.AddWithTTL(clientId, info, remainingTTL(mf.Duration, params.PositionMs, params.PlaybackRate)) + p.sessionsMu.Unlock() if err != nil { log.Warn(ctx, "Error adding PlaybackSession to cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err) } @@ -315,7 +336,9 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP ttl = remainingTTL(info.MediaFile.Duration, params.PositionMs, params.PlaybackRate) } log.Trace(ctx, "Updating PlaybackSession in cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, "positionMs", params.PositionMs, "playbackRate", params.PlaybackRate, "ttl", ttl) + p.sessionsMu.Lock() err := p.playMap.AddWithTTL(clientId, info, ttl) + p.sessionsMu.Unlock() if err != nil { log.Warn(ctx, "Error updating PlaybackSession in cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err) } @@ -339,6 +362,17 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP p.dispatchScrobble(ctx, mf, now) } } + p.sessionsMu.Lock() + info, getErr := p.playMap.Get(clientId) + // A late stop for a previous track must not end the current session nor reach + // playback reporters, or presence-style plugins would clear the active track. + if getErr == nil && info.MediaFile.ID != params.MediaId { + p.sessionsMu.Unlock() + log.Trace(ctx, "Ignoring out-of-order stopped report for different track", "clientId", clientId, "stoppedMediaId", params.MediaId, "currentMediaId", info.MediaFile.ID) + return nil + } + p.playMap.Remove(clientId) + p.sessionsMu.Unlock() stoppedInfo := PlaybackSession{ UserId: user.ID, Username: user.UserName, @@ -349,7 +383,7 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP PlaybackRate: params.PlaybackRate, LastReport: now, } - if info, getErr := p.playMap.Get(clientId); getErr == nil { + if getErr == nil { stoppedInfo.MediaFile = info.MediaFile stoppedInfo.Start = info.Start } else { @@ -364,7 +398,6 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP stoppedInfo.MediaFile = *mf } p.enqueuePlaybackReport(ctx, stoppedInfo) - p.playMap.Remove(clientId) } if conf.Server.EnableNowPlaying { diff --git a/core/scrobbler/play_tracker_test.go b/core/scrobbler/play_tracker_test.go index 831b0ce0d..f49d9a0bf 100644 --- a/core/scrobbler/play_tracker_test.go +++ b/core/scrobbler/play_tracker_test.go @@ -3,6 +3,7 @@ package scrobbler import ( "context" "errors" + "fmt" "net/http" "sync" "sync/atomic" @@ -45,6 +46,17 @@ func (m *mockPluginLoader) LoadScrobbler(name string) (Scrobbler, bool) { return s, ok } +// slowMediaFileRepo widens the window between a report's session check and its +// write, making check-then-write races reproducible. +type slowMediaFileRepo struct { + model.MediaFileRepository +} + +func (s *slowMediaFileRepo) GetWithParticipants(id string) (*model.MediaFile, error) { + time.Sleep(5 * time.Millisecond) + return s.MediaFileRepository.GetWithParticipants(id) +} + var _ = Describe("PlayTracker", func() { var ctx context.Context var ds model.DataStore @@ -376,18 +388,23 @@ var _ = Describe("PlayTracker", func() { Expect(playing).To(BeEmpty()) }) - It("starting replaces existing entry for same player", func() { + It("starting replaces existing entry when switching tracks on same player", func() { + track2 := track + track2.ID = "456" + _ = ds.MediaFile(ctx).Put(&track2) + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 50000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, }) Expect(err).ToNot(HaveOccurred()) err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ - MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + MediaId: "456", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, }) Expect(err).ToNot(HaveOccurred()) playing, err := tracker.GetNowPlaying(ctx) Expect(err).ToNot(HaveOccurred()) Expect(playing).To(HaveLen(1)) + Expect(playing[0].MediaFile.ID).To(Equal("456")) Expect(playing[0].State).To(Equal("starting")) Expect(playing[0].PositionMs).To(Equal(int64(0))) }) @@ -696,6 +713,119 @@ var _ = Describe("PlayTracker", func() { }) }) + Describe("resilience (out-of-order reports)", func() { + BeforeEach(func() { + track2 := track + track2.ID = "456" + _ = ds.MediaFile(ctx).Put(&track2) + }) + + It("does not downgrade an actively playing session when a late starting report arrives for the same track", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 1000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + Expect(playing[0].State).To(Equal("playing")) + Expect(playing[0].PositionMs).To(BeNumerically(">=", int64(1000))) + }) + + It("keeps the current session when a stopped report arrives for a different track", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "456", PositionMs: 0, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + Expect(playing[0].MediaFile.ID).To(Equal("456")) + Expect(playing[0].State).To(Equal("playing")) + }) + + It("still auto-scrobbles the stopped track when the current session is for a different track", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "456", PositionMs: 0, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(track.PlayCount).To(Equal(int64(1))) + }) + + It("does not dispatch NowPlaying from an ignored out-of-order starting report", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 60000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue()) + fake.nowPlayingCalled.Store(false) + + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse()) + }) + + It("never lets a concurrent starting report downgrade the playing session", func() { + ds.(*tests.MockDataStore).MockedMediaFile = &slowMediaFileRepo{MediaFileRepository: ds.MediaFile(ctx)} + for i := range 20 { + raceClientId := fmt.Sprintf("race-client-%d", i) + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + defer GinkgoRecover() + _ = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: raceClientId, + }) + }() + go func() { + defer wg.Done() + defer GinkgoRecover() + _ = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "playing", PlaybackRate: 1.0, ClientId: raceClientId, + }) + }() + wg.Wait() + info, err := tracker.playMap.Get(raceClientId) + Expect(err).ToNot(HaveOccurred()) + Expect(info.State).To(Equal("playing"), "iteration %d", i) + } + }) + + It("does NOT forward a stopped report for a different track to playback reporters", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "456", PositionMs: 0, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue()) + fake.PlaybackReportCalled.Store(false) + fake.LastPlaybackReport.Store(nil) + + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 100000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + + Consistently(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeFalse()) + }) + }) + Describe("external scrobbler dispatch", func() { It("dispatches NowPlaying on starting", func() { fake.nowPlayingCalled.Store(false) From ae7e81e33fcffee25c089cc93630dcccb2d74e64 Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 16 Jul 2026 20:24:15 -0400 Subject: [PATCH 15/71] docs(jellyfin): sync README with current implementation Update the Jellyfin API README to cover changes that landed after it was written: add the Lyrics and AudioMuse-AI endpoints to the implemented-endpoints table, document the AlbumIds filter (Feishin), Recursive=false handling, and the Jellyfin.MaxConcurrentStreams config option. Mention Symfonium as a client of the AudioMuse-AI endpoints. Update the lyrics limitation for the singleflighted cache loader shipped in #5792. Drop two stale known-limitation entries: sonic similarity (plugin metadata agents already feed InstantMix/Similar) and MD5-hash ids (the codec round-trip is symmetric, and with the API unreleased no client can hold a raw un-encoded id). --- server/jellyfin/README.md | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/server/jellyfin/README.md b/server/jellyfin/README.md index 7afc2b7a9..17d2797ab 100644 --- a/server/jellyfin/README.md +++ b/server/jellyfin/README.md @@ -22,6 +22,10 @@ Enabled = true ServerName = "My Music Server" # Optional: usernames to show in the client login user-picker (default: none). See "Public user list". ExposedPublicUsers = "alice, bob" +# Optional: max collection responses streaming at once (default: half the DB connection pool, +# min 2). Each streaming response holds a DB connection for its whole duration; excess requests +# queue rather than fail. +MaxConcurrentStreams = 4 ``` or via environment variables: @@ -104,12 +108,14 @@ authenticated user has access to; a library (or item within it) the user cannot `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 +tracks — Finamp's artist screen sends these *alongside* `ParentId=`); `AlbumIds` (an +album's tracks — Feishin fetches them this way instead of `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). +`StartIndex`/`Limit`; and `Ids` (batch fetch by id). `Recursive=false` with a library `ParentId` +returns direct children only (no tracks — no track is a library's direct child). ## Implemented endpoints @@ -124,9 +130,11 @@ favorites-only (`Filters=IsFavorite` or the standalone `isFavorite=true`); `Sort | 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` | +| Lyrics | `GET Audio/{itemId}/Lyrics` | | Playback reporting | `POST Sessions/Playing`, `POST Sessions/Playing/Progress`, `POST Sessions/Playing/Stopped`, `POST Sessions/Capabilities[/Full]` | | Playlists | `POST Playlists`, `GET Playlists/{playlistId}`, `POST Playlists/{playlistId}` (rename / visibility / replace tracks), `GET Playlists/{playlistId}/Items`, `POST`/`DELETE Playlists/{playlistId}/Items`, `GET Playlists/{playlistId}/Users[/{userId}]` | | Real-time | `GET socket` (WebSocket; keeps clients like Finamp from 404-loop-reconnecting) | +| AudioMuse-AI (see below) | `GET AudioMuseAI/info`, `GET AudioMuseAI/health`, `GET AudioMuseAI/similar_tracks`, `GET AudioMuseAI/find_path` | 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 @@ -217,7 +225,9 @@ The stream endpoints reuse the same transcode-decision pipeline as the Subsonic ## AudioMuse-AI compatible endpoints -Compatibility shim for Jellyfin front-ends that integrate [AudioMuse-AI](https://github.com/NeptuneHub/audiomuse-ai-plugin). +Compatibility shim for Jellyfin front-ends that integrate [AudioMuse-AI](https://github.com/NeptuneHub/audiomuse-ai-plugin) +— e.g. [Symfonium](https://symfonium.app/) can use these endpoints for sonic mixes when +connected as a Jellyfin client. 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. @@ -320,9 +330,6 @@ make test PKG=./server/jellyfin/... 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 @@ -348,15 +355,8 @@ make test PKG=./server/jellyfin/... check — the column is never `""` post-scan), while `PlaybackInfo` runs the full pipeline per track so sidecar/plugin lyrics also light up. Feishin additionally requires server version ≥ 10.9 — the reason `jellyfinVersion` is 10.9.11. - Follow-ups: the lyrics cache loader is not singleflighted, so concurrent misses on the same - track can double-invoke the plugin pipeline (fix belongs in `utils/cache.SimpleCache` via - ttlcache's `SuppressedLoader`, affecting all callers — separate change); tracks whose only - lyrics are sidecar/plugin-sourced show no `HasLyrics` badge in lists (request-time sources - can't be known at list time without per-row I/O). -- **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). + Concurrent misses on the same track share one pipeline invocation (`SimpleCache.GetWithLoader` + is singleflighted), and the load runs detached from the request context with a one-minute bound, + so a cancelled request or hung plugin can't fail or pin the load for other waiters. + Follow-up: tracks whose only lyrics are sidecar/plugin-sourced show no `HasLyrics` badge in + lists (request-time sources can't be known at list time without per-row I/O). From 85132240e0909dc460f604144cfd8578fa409e57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Thu, 16 Jul 2026 22:20:35 -0400 Subject: [PATCH 16/71] feat(smartplaylist): per-playlist refreshDelay for stable daily/weekly playlists (#5790) * feat(utils): add ParseDuration/FormatDuration with day and week units * feat(criteria): add per-playlist refreshDelay to smart playlist rules * feat(smartplaylist): honor per-playlist refreshDelay in refresh gate * feat(subsonic): compute smart playlist validUntil from effective refresh delay * fix(playlists): reset smart playlist evaluation window when rules change via API * refactor(utils): flatten FormatDuration recursion, single-pass duration regex * fix(playlists): address PR review feedback - Reset EvaluatedAt to nil instead of zero-time on rules change and NSP re-import, so getPlaylist(s) never reports year-1 Changed/validUntil in the window between an edit and the next owner read - Parse negative d/w durations so they are rejected with the consistent "negative duration" error instead of "unknown unit" - Quote input in the negative-duration error, matching the parse error - Gate per-playlist RefreshDelay behind IsSmartPlaylist, matching its doc --- core/playlists/import.go | 3 +- core/playlists/rest_adapter.go | 1 + core/playlists/rest_adapter_test.go | 32 +++++++++ model/criteria/criteria.go | 17 +++++ model/criteria/criteria_test.go | 66 +++++++++++++++++ model/playlist.go | 10 +++ model/playlist_test.go | 30 ++++++++ persistence/smart_playlist_repository.go | 3 +- persistence/smart_playlist_repository_test.go | 44 ++++++++++++ server/subsonic/playlists.go | 2 +- server/subsonic/playlists_test.go | 14 ++++ utils/time.go | 64 ++++++++++++++++- utils/time_test.go | 71 +++++++++++++++++++ 13 files changed, 351 insertions(+), 6 deletions(-) diff --git a/core/playlists/import.go b/core/playlists/import.go index 9d3ecabc5..bafb870cd 100644 --- a/core/playlists/import.go +++ b/core/playlists/import.go @@ -8,7 +8,6 @@ import ( "os" "path/filepath" "strings" - "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" @@ -187,7 +186,7 @@ func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist, newPls.OwnerID = pls.OwnerID newPls.Public = pls.Public newPls.UploadedImage = pls.UploadedImage // Preserve manual upload - newPls.EvaluatedAt = &time.Time{} + newPls.EvaluatedAt = nil // force re-evaluation on next read } else { log.Info(ctx, "Adding synced playlist", "playlist", newPls.Name, "path", newPls.Path, "owner", owner.UserName) newPls.OwnerID = owner.ID diff --git a/core/playlists/rest_adapter.go b/core/playlists/rest_adapter.go index 3f886aadd..f34524e27 100644 --- a/core/playlists/rest_adapter.go +++ b/core/playlists/rest_adapter.go @@ -135,6 +135,7 @@ func (s *playlists) applyContentUpdate(ctx context.Context, current, entity *mod } if rulesChanged { current.Rules = entity.Rules + current.EvaluatedAt = nil // force re-evaluation on next read } if sent("sync") && current.Path != "" && current.Sync != entity.Sync { current.Sync = entity.Sync diff --git a/core/playlists/rest_adapter_test.go b/core/playlists/rest_adapter_test.go index 79d72d147..58a327bde 100644 --- a/core/playlists/rest_adapter_test.go +++ b/core/playlists/rest_adapter_test.go @@ -314,6 +314,38 @@ var _ = Describe("REST Adapter", func() { Expect(mockPlsRepo.Last.Public).To(BeTrue()) }) + It("resets EvaluatedAt when rules change", func() { + evaluatedAt := time.Now().Add(-1 * time.Hour) + mockPlsRepo.Data["smart-reset"] = &model.Playlist{ + ID: "smart-reset", + Name: "Smart", + OwnerID: "user-1", + Rules: &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}}, + EvaluatedAt: &evaluatedAt, + } + repo = ps.NewRepository(ctx).(rest.Persistable) + newRules := &criteria.Criteria{Expression: criteria.Is{"genre": "Jazz"}} + err := repo.Update("smart-reset", &model.Playlist{Rules: newRules}, "rules") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.EvaluatedAt).To(BeNil()) + }) + + It("keeps EvaluatedAt when rules are not changed", func() { + evaluatedAt := time.Now().Add(-1 * time.Hour) + mockPlsRepo.Data["smart-keep"] = &model.Playlist{ + ID: "smart-keep", + Name: "Smart", + OwnerID: "user-1", + Rules: &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}}, + EvaluatedAt: &evaluatedAt, + } + repo = ps.NewRepository(ctx).(rest.Persistable) + err := repo.Update("smart-keep", &model.Playlist{Name: "Renamed Smart"}, "name") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.EvaluatedAt).ToNot(BeNil()) + Expect(*mockPlsRepo.Last.EvaluatedAt).To(BeTemporally("~", evaluatedAt, time.Second)) + }) + It("updates name and rules together (smart-playlist Edit form)", func() { mockPlsRepo.Data["smart-edit"] = &model.Playlist{ ID: "smart-edit", diff --git a/model/criteria/criteria.go b/model/criteria/criteria.go index 8c3d183a9..5d7dc3826 100644 --- a/model/criteria/criteria.go +++ b/model/criteria/criteria.go @@ -4,9 +4,12 @@ package criteria import ( "encoding/json" "errors" + "fmt" "slices" + "time" "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/utils" ) type Expression interface { @@ -20,6 +23,7 @@ type Criteria struct { Limit int LimitPercent int Offset int + RefreshDelay time.Duration // 0 = use conf.Server.SmartPlaylistRefreshDelay } // EffectiveLimit resolves the effective limit for a query. If a fixed Limit is @@ -83,6 +87,7 @@ func (c Criteria) MarshalJSON() ([]byte, error) { Limit int `json:"limit,omitempty"` LimitPercent int `json:"limitPercent,omitempty"` Offset int `json:"offset,omitempty"` + RefreshDelay string `json:"refreshDelay,omitempty"` }{ Sort: c.Sort, Order: c.Order, @@ -90,6 +95,9 @@ func (c Criteria) MarshalJSON() ([]byte, error) { LimitPercent: c.LimitPercent, Offset: c.Offset, } + if c.RefreshDelay > 0 { + aux.RefreshDelay = utils.FormatDuration(c.RefreshDelay) + } switch rules := c.Expression.(type) { case Any: aux.Any = rules @@ -110,6 +118,7 @@ func (c *Criteria) UnmarshalJSON(data []byte) error { Limit int `json:"limit"` LimitPercent int `json:"limitPercent"` Offset int `json:"offset"` + RefreshDelay string `json:"refreshDelay"` } if err := json.Unmarshal(data, &aux); err != nil { return err @@ -131,6 +140,14 @@ func (c *Criteria) UnmarshalJSON(data []byte) error { c.Limit = aux.Limit c.Offset = aux.Offset + if aux.RefreshDelay != "" { + d, err := utils.ParseDuration(aux.RefreshDelay) + if err != nil { + return fmt.Errorf("invalid refreshDelay: %w", err) + } + c.RefreshDelay = d + } + // Clamp LimitPercent to [0, 100] if aux.LimitPercent < 0 { log.Warn("limitPercent value out of range, clamping to 0", "value", aux.LimitPercent) diff --git a/model/criteria/criteria_test.go b/model/criteria/criteria_test.go index 7f214e703..5e653150a 100644 --- a/model/criteria/criteria_test.go +++ b/model/criteria/criteria_test.go @@ -3,6 +3,7 @@ package criteria import ( "bytes" "encoding/json" + "time" "github.com/google/uuid" . "github.com/onsi/ginkgo/v2" @@ -255,6 +256,71 @@ var _ = Describe("Criteria", func() { }) }) + Describe("refreshDelay", func() { + newCriteria := func(extra string) []byte { + return []byte(`{"all":[{"is":{"loved":true}}]` + extra + `}`) + } + + It("unmarshals a valid refreshDelay", func() { + var c Criteria + gomega.Expect(json.Unmarshal(newCriteria(`,"refreshDelay":"1d"`), &c)).To(gomega.Succeed()) + gomega.Expect(c.RefreshDelay).To(gomega.Equal(24 * time.Hour)) + }) + + It("supports week units", func() { + var c Criteria + gomega.Expect(json.Unmarshal(newCriteria(`,"refreshDelay":"1w"`), &c)).To(gomega.Succeed()) + gomega.Expect(c.RefreshDelay).To(gomega.Equal(7 * 24 * time.Hour)) + }) + + It("leaves RefreshDelay zero when absent", func() { + var c Criteria + gomega.Expect(json.Unmarshal(newCriteria(``), &c)).To(gomega.Succeed()) + gomega.Expect(c.RefreshDelay).To(gomega.BeZero()) + }) + + It("rejects an invalid refreshDelay", func() { + var c Criteria + err := json.Unmarshal(newCriteria(`,"refreshDelay":"tomorrow"`), &c) + gomega.Expect(err).To(gomega.MatchError(gomega.ContainSubstring("refreshDelay"))) + }) + + It("rejects a negative refreshDelay", func() { + var c Criteria + err := json.Unmarshal(newCriteria(`,"refreshDelay":"-1h"`), &c) + gomega.Expect(err).To(gomega.MatchError(gomega.ContainSubstring("refreshDelay"))) + }) + + It("marshals RefreshDelay back as a duration string", func() { + c := Criteria{ + Expression: All{Is{"loved": true}}, + RefreshDelay: 24 * time.Hour, + } + j, err := json.Marshal(c) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(string(j)).To(gomega.ContainSubstring(`"refreshDelay":"1d"`)) + }) + + It("omits refreshDelay from JSON when zero", func() { + c := Criteria{Expression: All{Is{"loved": true}}} + j, err := json.Marshal(c) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(string(j)).ToNot(gomega.ContainSubstring("refreshDelay")) + }) + + It("round-trips through marshal and unmarshal", func() { + c := Criteria{ + Expression: All{Is{"loved": true}}, + RefreshDelay: 36 * time.Hour, + } + j, err := json.Marshal(c) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + var c2 Criteria + gomega.Expect(json.Unmarshal(j, &c2)).To(gomega.Succeed()) + gomega.Expect(c2.RefreshDelay).To(gomega.Equal(36 * time.Hour)) + }) + }) + Context("with child playlists", func() { var ( topLevelInPlaylistID string diff --git a/model/playlist.go b/model/playlist.go index 40adb8d0a..185f6f942 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -6,6 +6,7 @@ import ( "strconv" "time" + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/model/criteria" ) @@ -39,6 +40,15 @@ func (pls Playlist) IsSmartPlaylist() bool { return pls.Rules != nil && pls.Rules.Expression != nil } +// RefreshDelay returns the playlist's own refresh window when set, falling +// back to the global SmartPlaylistRefreshDelay. +func (pls Playlist) RefreshDelay() time.Duration { + if pls.IsSmartPlaylist() && pls.Rules.RefreshDelay > 0 { + return pls.Rules.RefreshDelay + } + return conf.Server.SmartPlaylistRefreshDelay +} + func (pls Playlist) MediaFiles() MediaFiles { if len(pls.Tracks) == 0 { return nil diff --git a/model/playlist_test.go b/model/playlist_test.go index 9ed24f00f..d936129ce 100644 --- a/model/playlist_test.go +++ b/model/playlist_test.go @@ -1,7 +1,12 @@ package model_test import ( + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -43,4 +48,29 @@ var _ = Describe("Playlist", func() { Expect(pls.ToM3U8()).To(Equal(expected)) }) }) + + Describe("RefreshDelay", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.SmartPlaylistRefreshDelay = 5 * time.Second + }) + + It("returns the global config value when rules have no refreshDelay", func() { + pls := model.Playlist{Rules: &criteria.Criteria{Expression: criteria.All{criteria.Is{"loved": true}}}} + Expect(pls.RefreshDelay()).To(Equal(5 * time.Second)) + }) + + It("returns the per-playlist value when set", func() { + pls := model.Playlist{Rules: &criteria.Criteria{ + Expression: criteria.All{criteria.Is{"loved": true}}, + RefreshDelay: 24 * time.Hour, + }} + Expect(pls.RefreshDelay()).To(Equal(24 * time.Hour)) + }) + + It("returns the global value for non-smart playlists", func() { + pls := model.Playlist{} + Expect(pls.RefreshDelay()).To(Equal(5 * time.Second)) + }) + }) }) diff --git a/persistence/smart_playlist_repository.go b/persistence/smart_playlist_repository.go index 54f316152..f7c41597d 100644 --- a/persistence/smart_playlist_repository.go +++ b/persistence/smart_playlist_repository.go @@ -4,7 +4,6 @@ import ( "time" . "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" ) @@ -77,7 +76,7 @@ func (r *playlistRepository) shouldRefreshSmartPlaylist(pls *model.Playlist, usr if !pls.IsSmartPlaylist() { return false } - if pls.EvaluatedAt != nil && time.Since(*pls.EvaluatedAt) < conf.Server.SmartPlaylistRefreshDelay { + if pls.EvaluatedAt != nil && time.Since(*pls.EvaluatedAt) < pls.RefreshDelay() { return false } if pls.OwnerID != usr.ID { diff --git a/persistence/smart_playlist_repository_test.go b/persistence/smart_playlist_repository_test.go index 7bc705385..8e3ae488f 100644 --- a/persistence/smart_playlist_repository_test.go +++ b/persistence/smart_playlist_repository_test.go @@ -147,6 +147,50 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(Equal(*nestedPlsRead.EvaluatedAt)) }) }) + + Context("per-playlist refreshDelay", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + + It("does NOT refresh when the per-playlist delay has not elapsed, even if global has", func() { + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second + evaluatedAt := time.Now().Add(-1 * time.Hour) + + rules := &criteria.Criteria{ + Expression: criteria.All{criteria.Contains{"title": "Day"}}, + RefreshDelay: 24 * time.Hour, + } + pls := model.Playlist{Name: "Frozen Daily", OwnerID: "userid", Rules: rules, EvaluatedAt: &evaluatedAt} + Expect(repo.Put(&pls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(pls.ID) }) + + got, err := repo.GetWithTracks(pls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + // Not re-evaluated: EvaluatedAt unchanged, no tracks materialized + Expect(*got.EvaluatedAt).To(BeTemporally("~", evaluatedAt, time.Second)) + Expect(got.Tracks).To(BeEmpty()) + }) + + It("refreshes when the per-playlist delay has elapsed, even if global has not", func() { + conf.Server.SmartPlaylistRefreshDelay = 1 * time.Hour + evaluatedAt := time.Now().Add(-10 * time.Minute) + + rules := &criteria.Criteria{ + Expression: criteria.All{criteria.Contains{"title": "Day"}}, + RefreshDelay: 5 * time.Minute, + } + pls := model.Playlist{Name: "Fast Refresh", OwnerID: "userid", Rules: rules, EvaluatedAt: &evaluatedAt} + Expect(repo.Put(&pls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(pls.ID) }) + + got, err := repo.GetWithTracks(pls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + Expect(*got.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second)) + Expect(got.Tracks).To(HaveLen(1)) + Expect(got.Tracks[0].MediaFileID).To(Equal(songDayInALife.ID)) + }) + }) }) }) diff --git a/server/subsonic/playlists.go b/server/subsonic/playlists.go index 7101f9f15..c58fb9ab9 100644 --- a/server/subsonic/playlists.go +++ b/server/subsonic/playlists.go @@ -168,7 +168,7 @@ func buildOSPlaylist(ctx context.Context, p model.Playlist) *responses.OpenSubso pls.Readonly = true if p.EvaluatedAt != nil { - pls.ValidUntil = new(p.EvaluatedAt.Add(conf.Server.SmartPlaylistRefreshDelay)) + pls.ValidUntil = new(p.EvaluatedAt.Add(p.RefreshDelay())) } } else { user, ok := request.UserFrom(ctx) diff --git a/server/subsonic/playlists_test.go b/server/subsonic/playlists_test.go index 697dd5852..f0a2f8ac5 100644 --- a/server/subsonic/playlists_test.go +++ b/server/subsonic/playlists_test.go @@ -248,6 +248,20 @@ var _ = Describe("buildPlaylist", func() { Expect(result.OpenSubsonicPlaylist).To(BeNil()) }) }) + + Context("with a per-playlist refreshDelay", func() { + BeforeEach(func() { + playlist.Rules.RefreshDelay = 24 * time.Hour + player := model.Player{Client: "regular-client"} + ctx = request.WithPlayer(ctx, player) + }) + + It("computes validUntil from the playlist's own delay", func() { + result := router.buildPlaylist(ctx, playlist) + expected := evaluatedAt.Add(24 * time.Hour) + Expect(result.ValidUntil).To(Equal(&expected)) + }) + }) }) Describe("annotation leakage", func() { diff --git a/utils/time.go b/utils/time.go index c1e949589..b3f1a98fb 100644 --- a/utils/time.go +++ b/utils/time.go @@ -1,6 +1,12 @@ package utils -import "time" +import ( + "fmt" + "regexp" + "strconv" + "strings" + "time" +) func TimeNewest(times ...time.Time) time.Time { newest := time.Time{} @@ -11,3 +17,59 @@ func TimeNewest(times ...time.Time) time.Time { } return newest } + +var durationDayWeekRe = regexp.MustCompile(`-?\d+(?:\.\d+)?[dw]`) + +// ParseDuration is time.ParseDuration extended with d (24h) and w (168h) units. +// Negative durations are rejected. +func ParseDuration(s string) (time.Duration, error) { + expanded := durationDayWeekRe.ReplaceAllStringFunc(s, func(match string) string { + value, err := strconv.ParseFloat(match[:len(match)-1], 64) + if err != nil { + return match + } + hours := value * 24 + if match[len(match)-1] == 'w' { + hours = value * 24 * 7 + } + return strconv.FormatFloat(hours, 'f', -1, 64) + "h" + }) + d, err := time.ParseDuration(expanded) + if err != nil { + return 0, fmt.Errorf("invalid duration %q: %w", s, err) + } + if d < 0 { + return 0, fmt.Errorf("negative duration not allowed: %q", s) + } + return d, nil +} + +// FormatDuration renders whole w/d multiples with those units, falling back to +// time.Duration.String for the sub-day remainder, so ParseDuration round-trips. +func FormatDuration(d time.Duration) string { + if d < 24*time.Hour { + return formatSubDay(d) + } + var b strings.Builder + weekDuration := 7 * 24 * time.Hour + if weeks := d / weekDuration; weeks > 0 { + b.WriteString(strconv.Itoa(int(weeks)) + "w") + d %= weekDuration + } + dayDuration := 24 * time.Hour + if days := d / dayDuration; days > 0 { + b.WriteString(strconv.Itoa(int(days)) + "d") + d %= dayDuration + } + if d > 0 { + b.WriteString(formatSubDay(d)) + } + return b.String() +} + +func formatSubDay(d time.Duration) string { + if d >= time.Hour && d%time.Hour == 0 { + return strconv.Itoa(int(d/time.Hour)) + "h" + } + return d.String() +} diff --git a/utils/time_test.go b/utils/time_test.go index f89f0d2be..8460b98f9 100644 --- a/utils/time_test.go +++ b/utils/time_test.go @@ -26,3 +26,74 @@ var _ = Describe("TimeNewest", func() { Expect(utils.TimeNewest(t1, t2, t3)).To(Equal(t2)) }) }) + +var _ = Describe("ParseDuration", func() { + DescribeTable("parses valid durations", + func(input string, expected time.Duration) { + d, err := utils.ParseDuration(input) + Expect(err).ToNot(HaveOccurred()) + Expect(d).To(Equal(expected)) + }, + Entry("standard Go units", "90m", 90*time.Minute), + Entry("hours", "12h", 12*time.Hour), + Entry("days", "1d", 24*time.Hour), + Entry("weeks", "1w", 7*24*time.Hour), + Entry("multiple days", "3d", 72*time.Hour), + Entry("mixed day and hours", "1d12h", 36*time.Hour), + Entry("mixed week, day and hours", "1w2d3h", (7*24+2*24+3)*time.Hour), + Entry("fractional days", "0.5d", 12*time.Hour), + ) + + DescribeTable("rejects invalid durations", + func(input string) { + _, err := utils.ParseDuration(input) + Expect(err).To(HaveOccurred()) + }, + Entry("empty string", ""), + Entry("not a duration", "tomorrow"), + Entry("bare number", "42"), + Entry("unit only", "d"), + Entry("unknown unit", "5y"), + ) + + DescribeTable("rejects negative durations", + func(input string) { + _, err := utils.ParseDuration(input) + Expect(err).To(MatchError(ContainSubstring("negative duration"))) + }, + Entry("negative days", "-1d"), + Entry("negative weeks", "-0.5w"), + Entry("negative Go units", "-30m"), + ) +}) + +var _ = Describe("FormatDuration", func() { + DescribeTable("formats durations using the largest whole units", + func(input time.Duration, expected string) { + Expect(utils.FormatDuration(input)).To(Equal(expected)) + }, + Entry("whole weeks", 7*24*time.Hour, "1w"), + Entry("whole days", 24*time.Hour, "1d"), + Entry("multiple days", 72*time.Hour, "3d"), + Entry("day and hours", 36*time.Hour, "1d12h"), + Entry("week, day and hours", (7*24+2*24+3)*time.Hour, "1w2d3h"), + Entry("hours only", 12*time.Hour, "12h"), + Entry("sub-hour", 90*time.Minute, "1h30m0s"), + Entry("zero", time.Duration(0), "0s"), + ) + + DescribeTable("round-trips through ParseDuration", + func(input string) { + d, err := utils.ParseDuration(input) + Expect(err).ToNot(HaveOccurred()) + formatted := utils.FormatDuration(d) + d2, err := utils.ParseDuration(formatted) + Expect(err).ToNot(HaveOccurred()) + Expect(d2).To(Equal(d)) + }, + Entry("1d", "1d"), + Entry("1w", "1w"), + Entry("1d12h", "1d12h"), + Entry("90m", "90m"), + ) +}) From 64430af9ce442c7266101204bcf53060f7e1443a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Fri, 17 Jul 2026 21:53:17 -0400 Subject: [PATCH 17/71] feat(ui): add Radio to Default View options (#5801) --- ui/src/personal/defaultViews.js | 2 +- ui/src/personal/defaultViews.test.js | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/ui/src/personal/defaultViews.js b/ui/src/personal/defaultViews.js index 09e615419..a5a2c074d 100644 --- a/ui/src/personal/defaultViews.js +++ b/ui/src/personal/defaultViews.js @@ -1,6 +1,6 @@ import albumLists, { defaultAlbumList } from '../album/albumLists' -export const resourceDefaultViews = ['artist', 'song', 'playlist'] +export const resourceDefaultViews = ['artist', 'song', 'playlist', 'radio'] export const isResourceDefaultView = (defaultView) => resourceDefaultViews.includes(defaultView) diff --git a/ui/src/personal/defaultViews.test.js b/ui/src/personal/defaultViews.test.js index 44057a736..4e80b2340 100644 --- a/ui/src/personal/defaultViews.test.js +++ b/ui/src/personal/defaultViews.test.js @@ -25,6 +25,7 @@ describe('defaultViews', () => { { id: 'artist', name: 'resources.artist.name:2' }, { id: 'song', name: 'resources.song.name:2' }, { id: 'playlist', name: 'resources.playlist.name:2' }, + { id: 'radio', name: 'resources.radio.name:2' }, ]), ) }) @@ -33,6 +34,7 @@ describe('defaultViews', () => { expect(isResourceDefaultView('artist')).toBe(true) expect(isResourceDefaultView('song')).toBe(true) expect(isResourceDefaultView('playlist')).toBe(true) + expect(isResourceDefaultView('radio')).toBe(true) expect(isResourceDefaultView('recentlyAdded')).toBe(false) }) From deaa5e6c028abd70acb2427c50c478e79eab61dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 18 Jul 2026 14:45:47 -0400 Subject: [PATCH 18/71] feat(ui): playlist favourites (heart, list filter, sidebar favourites-only toggle) (#5805) * feat(playlists): register starred REST filter on playlist repository * feat(ui): add persisted sidebarPlaylistsOnlyFavourites setting * feat(ui): playlist favourites heart column and list filter * feat(ui): favourite heart on playlist details header * feat(ui): sidebar favourites-only playlist toggle with live refresh * fix(playlists): qualify id in REST filter to avoid ambiguous column The annotation join in selectPlaylist made a bare id filter ambiguous, so GET /api/playlist?id=X (react-admin getMany, used by the sidebar refetch and useResourceRefresh) failed with 'ambiguous column name: id'. Register idFilter("playlist") like the album/artist/mediafile repos. * fix(ui): refresh favourites sidebar on local star toggle The SSE broker skips the client that originated a star, so the acting client never got the refreshResource echo and its favourites-only sidebar went stale. Key the sidebar query on a fingerprint of locally-known starred playlists so a star/unstar on this client refetches; SSE still covers other clients. * style(ui): prettier-format PlaylistsSubMenu test * feat(ui): refine playlist favourites layout - Move the favourite heart column to just before the edit button - Space the Playlists sidebar header text from its action icons - Use a list icon instead of a cog for the playlist-management action * feat(ui): make the playlist Favourite column toggleable Move the heart into the toggleable fields map so users can show/hide it from the column selector like the other optional columns, keeping it last so it stays just before the edit button. * refactor(ui): memoize sidebar star fingerprint and gate it on favourites-only - Derive starFingerprint via useMemo on the playlist data reference instead of recomputing sort/join inside useSelector on every app-wide dispatch. - Only include the fingerprint in the query payload when favourites-only is on, so a star toggle no longer refetches the sidebar when it shows all playlists. * fix(ui): don't refetch favourites sidebar on SSE events when showing all When favourites-only is off the sidebar shows every playlist, so a star event from another client changes nothing visible. Gate the SSE-driven refresh counter (and its payload key) on onlyFavourites so the sidebar no longer redraws on unrelated playlist star events. * fix(ui): address automated review feedback on playlist favourites - Ignore a persisted favourites-only preference when EnableFavourites is off, so disabling the feature later can't strand a filtered sidebar (Codex P2). - Make PlaylistLove's datagrid header props explicit (source/sortable via defaultProps, className forwarded) instead of relying on prop pass-through. - Add aria-label to the SubMenu secondary action button. - Cover the PlaylistLove list column with tests (Codex P1). --- persistence/playlist_repository.go | 6 +- persistence/playlist_repository_test.go | 29 ++++ ui/src/actions/settings.js | 7 + ui/src/i18n/en.json | 4 +- ui/src/layout/PlaylistsSubMenu.jsx | 65 ++++++++- ui/src/layout/PlaylistsSubMenu.test.jsx | 180 ++++++++++++++++++++++++ ui/src/layout/SubMenu.jsx | 31 +++- ui/src/playlist/PlaylistDetails.jsx | 36 +++-- ui/src/playlist/PlaylistList.jsx | 18 +++ ui/src/playlist/PlaylistList.test.jsx | 34 +++++ ui/src/reducers/settingsReducer.js | 7 + ui/src/reducers/settingsReducer.test.js | 36 +++++ 12 files changed, 437 insertions(+), 16 deletions(-) create mode 100644 ui/src/layout/PlaylistsSubMenu.test.jsx create mode 100644 ui/src/playlist/PlaylistList.test.jsx create mode 100644 ui/src/reducers/settingsReducer.test.js diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index e39f0bbd3..c78e0df1e 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -51,8 +51,10 @@ func NewPlaylistRepository(ctx context.Context, db dbx.Builder) model.PlaylistRe r.ctx = ctx r.db = db r.registerModel(&model.Playlist{}, map[string]filterFunc{ - "q": playlistFilter, - "smart": smartPlaylistFilter, + "id": idFilter("playlist"), + "q": playlistFilter, + "smart": smartPlaylistFilter, + "starred": annotationBoolFilter("starred"), }) r.setSortMappings(map[string]string{ "owner_name": "owner_name", diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go index c51ff6222..00d4ff9f2 100644 --- a/persistence/playlist_repository_test.go +++ b/persistence/playlist_repository_test.go @@ -4,6 +4,7 @@ import ( "slices" "github.com/Masterminds/squirrel" + "github.com/deluan/rest" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -155,6 +156,34 @@ var _ = Describe("PlaylistRepository", func() { Expect(count).To(Equal(int64(len(starred)))) }) + It("filters starred playlists through the registered REST filter", func() { + Expect(repo.SetStar(true, plsID)).To(Succeed()) + + res, err := repo.(model.ResourceRepository).ReadAll(rest.QueryOptions{ + Filters: map[string]any{"starred": "true"}, + }) + Expect(err).ToNot(HaveOccurred()) + starred := res.(model.Playlists) + Expect(starred).To(ContainElement(HaveField("ID", plsID))) + for _, p := range starred { + Expect(p.Starred).To(BeTrue()) + } + + res, err = repo.(model.ResourceRepository).ReadAll(rest.QueryOptions{ + Filters: map[string]any{"starred": "false"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(res.(model.Playlists)).ToNot(ContainElement(HaveField("ID", plsID))) + }) + + It("reads a playlist by id through the REST id filter without ambiguity", func() { + res, err := repo.(model.ResourceRepository).ReadAll(rest.QueryOptions{ + Filters: map[string]any{"id": plsID}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(res.(model.Playlists)).To(ContainElement(HaveField("ID", plsID))) + }) + 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/ui/src/actions/settings.js b/ui/src/actions/settings.js index e62ecde8f..89c8f248f 100644 --- a/ui/src/actions/settings.js +++ b/ui/src/actions/settings.js @@ -1,6 +1,8 @@ export const SET_NOTIFICATIONS_STATE = 'SET_NOTIFICATIONS_STATE' export const SET_TOGGLEABLE_FIELDS = 'SET_TOGGLEABLE_FIELDS' export const SET_OMITTED_FIELDS = 'SET_OMITTED_FIELDS' +export const SET_SIDEBAR_PLAYLISTS_FAVOURITES = + 'SET_SIDEBAR_PLAYLISTS_FAVOURITES' export const setNotificationsState = (enabled) => ({ type: SET_NOTIFICATIONS_STATE, @@ -16,3 +18,8 @@ export const setOmittedFields = (obj) => ({ type: SET_OMITTED_FIELDS, data: obj, }) + +export const setSidebarPlaylistsOnlyFavourites = (enabled) => ({ + type: SET_SIDEBAR_PLAYLISTS_FAVOURITES, + data: enabled, +}) diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index 74fb23ab9..c0e226453 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -210,7 +210,8 @@ "songCount": "Songs", "comment": "Comment", "sync": "Auto-import", - "path": "Import from" + "path": "Import from", + "starred": "Favourite" }, "actions": { "selectPlaylist": "Select a playlist:", @@ -635,6 +636,7 @@ }, "albumList": "Albums", "playlists": "Playlists", + "onlyFavourites": "Only show favourites", "sharedPlaylists": "Shared Playlists", "about": "About" }, diff --git a/ui/src/layout/PlaylistsSubMenu.jsx b/ui/src/layout/PlaylistsSubMenu.jsx index b94bebf86..f332f6810 100644 --- a/ui/src/layout/PlaylistsSubMenu.jsx +++ b/ui/src/layout/PlaylistsSubMenu.jsx @@ -1,19 +1,24 @@ -import React, { useCallback } from 'react' +import React, { useCallback, useMemo, useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' import { MenuItemLink, useDataProvider, useNotify, useQueryWithStore, + useTranslate, } from 'react-admin' import { useHistory } from 'react-router-dom' import QueueMusicIcon from '@material-ui/icons/QueueMusic' import { Typography } from '@material-ui/core' import QueueMusicOutlinedIcon from '@material-ui/icons/QueueMusicOutlined' -import { BiCog } from 'react-icons/bi' +import FavoriteIcon from '@material-ui/icons/Favorite' +import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder' +import { BiListUl } from 'react-icons/bi' import { useDrop } from 'react-dnd' import SubMenu from './SubMenu' -import { canChangeTracks, OverflowTooltip } from '../common' +import { canChangeTracks, OverflowTooltip, useRefreshOnEvents } from '../common' import { DraggableTypes } from '../consts' +import { setSidebarPlaylistsOnlyFavourites } from '../actions' import config from '../config' const PlaylistMenuItemLink = ({ pls, sidebarIsOpen }) => { @@ -53,6 +58,37 @@ const PlaylistMenuItemLink = ({ pls, sidebarIsOpen }) => { const PlaylistsSubMenu = ({ state, setState, sidebarIsOpen, dense }) => { const history = useHistory() + const dispatch = useDispatch() + const translate = useTranslate() + const onlyFavourites = useSelector( + (state) => state.settings.sidebarPlaylistsOnlyFavourites, + ) + // Ignore a persisted preference when the feature is off, so disabling it later + // (with the toggle now hidden) doesn't strand the user on a filtered sidebar + const showFavouritesOnly = config.enableFavourites && onlyFavourites + const playlistData = useSelector( + (state) => state.admin.resources.playlist?.data, + ) + // Fingerprint of local star state; changes only when a playlist is (un)starred, + // so a local toggle refetches the sidebar without the SSE echo the actor never gets + const starFingerprint = useMemo(() => { + const data = playlistData || {} + return Object.keys(data) + .filter((id) => data[id]?.starred) + .sort() + .join(',') + }, [playlistData]) + const [refreshCount, setRefreshCount] = useState(0) + + // Only the favourites-only view depends on star state changing elsewhere; + // when showing all playlists a star event from another client changes nothing + // async because useRefreshOnEvents calls .catch() on the returned value + const onRefresh = useCallback(async () => { + if (showFavouritesOnly) setRefreshCount((count) => count + 1) + }, [showFavouritesOnly]) + useRefreshOnEvents({ events: ['playlist'], onRefresh }) + + // A changed payload signature makes useQueryWithStore refetch const { data, loaded } = useQueryWithStore({ type: 'getList', resource: 'playlist', @@ -62,6 +98,11 @@ const PlaylistsSubMenu = ({ state, setState, sidebarIsOpen, dense }) => { perPage: config.maxSidebarPlaylists, }, sort: { field: 'name' }, + ...(showFavouritesOnly && { + filter: { starred: true }, + starFingerprint, + refresh: refreshCount, + }), }, }) @@ -98,6 +139,10 @@ const PlaylistsSubMenu = ({ state, setState, sidebarIsOpen, dense }) => { [history], ) + const handleToggleFavourites = useCallback(() => { + dispatch(setSidebarPlaylistsOnlyFavourites(!onlyFavourites)) + }, [dispatch, onlyFavourites]) + return ( <> { name={'menu.playlists'} icon={} dense={dense} - actionIcon={} + actionIcon={} onAction={onPlaylistConfig} + onSecondaryAction={ + config.enableFavourites ? handleToggleFavourites : undefined + } + secondaryActionIcon={ + onlyFavourites ? ( + + ) : ( + + ) + } + secondaryActionTitle={translate('menu.onlyFavourites')} + secondaryActionActive={onlyFavourites} > {myPlaylists.map(renderPlaylistMenuItemLink)} diff --git a/ui/src/layout/PlaylistsSubMenu.test.jsx b/ui/src/layout/PlaylistsSubMenu.test.jsx new file mode 100644 index 000000000..617f60a4e --- /dev/null +++ b/ui/src/layout/PlaylistsSubMenu.test.jsx @@ -0,0 +1,180 @@ +import React from 'react' +import { render, screen, fireEvent, act } from '@testing-library/react' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { Provider } from 'react-redux' +import { createStore, combineReducers } from 'redux' +import { ThemeProvider, createTheme } from '@material-ui/core/styles' +import { settingsReducer, activityReducer } from '../reducers' +import { processEvent, EVENT_REFRESH_RESOURCE } from '../actions' +import PlaylistsSubMenu from './PlaylistsSubMenu' + +const mockUseQueryWithStore = vi.fn() + +vi.mock('../config', () => ({ + // losslessFormats is read at module-load time by common/QualityInfo.jsx, + // pulled in transitively via the '../common' barrel file + default: { + enableFavourites: true, + maxSidebarPlaylists: 100, + losslessFormats: '', + }, +})) + +vi.mock('react-dnd', () => ({ + useDrop: () => [{}, () => {}], +})) + +vi.mock('react-router-dom', () => ({ + useHistory: () => ({ push: vi.fn() }), +})) + +vi.mock('react-admin', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useTranslate: () => (x) => x, + useDataProvider: () => ({ addToPlaylist: vi.fn() }), + useNotify: () => vi.fn(), + useQueryWithStore: (query) => mockUseQueryWithStore(query), + MenuItemLink: ({ primaryText }) =>
{primaryText}
, + } +}) + +const playlists = { + 'pl-1': { id: 'pl-1', name: 'Mine', ownerId: 'user-1' }, + 'pl-2': { id: 'pl-2', name: 'Theirs', ownerId: 'user-2' }, +} + +const SET_PLAYLIST_DATA = 'TEST/SET_PLAYLIST_DATA' +const adminReducer = (state = { resources: {} }, action) => + action.type === SET_PLAYLIST_DATA + ? { resources: { playlist: { data: action.data } } } + : state + +const renderMenu = (preloadedSettings = {}, preloadedPlaylistData) => { + const store = createStore( + combineReducers({ + settings: settingsReducer, + activity: activityReducer, + admin: adminReducer, + }), + { + settings: preloadedSettings, + activity: {}, + admin: { + resources: preloadedPlaylistData + ? { playlist: { data: preloadedPlaylistData } } + : {}, + }, + }, + ) + const theme = createTheme() + render( + + + + + , + ) + return store +} + +const lastQuery = () => + mockUseQueryWithStore.mock.calls[ + mockUseQueryWithStore.mock.calls.length - 1 + ][0] + +describe('', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.setItem('userId', 'user-1') + mockUseQueryWithStore.mockReturnValue({ data: playlists, loaded: true }) + // SubMenu uses MUI's useMediaQuery, which needs window.matchMedia in jsdom + window.matchMedia = (query) => ({ + matches: false, + media: query, + addListener: () => {}, + removeListener: () => {}, + }) + // OverflowTooltip (via MenuItemLink) needs ResizeObserver, unavailable in jsdom + window.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } + }) + + it('queries without a starred filter by default', () => { + renderMenu() + expect(lastQuery().payload.filter).toBeUndefined() + expect(screen.getByText('Mine')).not.toBeNull() + expect(screen.getByText('Theirs')).not.toBeNull() + }) + + it('adds the starred filter when favourites-only is enabled', () => { + renderMenu({ sidebarPlaylistsOnlyFavourites: true }) + expect(lastQuery().payload.filter).toEqual({ starred: true }) + }) + + it('toggles the setting when the heart action is clicked', () => { + const store = renderMenu() + fireEvent.click(screen.getByTitle('menu.onlyFavourites')) + expect(store.getState().settings.sidebarPlaylistsOnlyFavourites).toBe(true) + expect(lastQuery().payload.filter).toEqual({ starred: true }) + }) + + it('refetches on a playlist SSE event when favourites-only is on', async () => { + const store = renderMenu({ sidebarPlaylistsOnlyFavourites: true }) + const before = lastQuery().payload.refresh + // useRefreshOnEvents compares Date.now() timestamps; make sure it advances + await act(() => new Promise((resolve) => setTimeout(resolve, 5))) + act(() => { + store.dispatch( + processEvent(EVENT_REFRESH_RESOURCE, { playlist: ['pl-1'] }), + ) + }) + expect(lastQuery().payload.refresh).toBe(before + 1) + }) + + it('does not change the query signature on an SSE event when favourites-only is off', async () => { + const store = renderMenu() + const before = JSON.stringify(lastQuery().payload) + await act(() => new Promise((resolve) => setTimeout(resolve, 5))) + act(() => { + store.dispatch( + processEvent(EVENT_REFRESH_RESOURCE, { playlist: ['pl-1'] }), + ) + }) + // Signature unchanged → useQueryWithStore dedupes, no wasted refetch + expect(lastQuery().payload.refresh).toBeUndefined() + expect(JSON.stringify(lastQuery().payload)).toBe(before) + }) + + it('refetches when a playlist is starred locally (no SSE echo)', () => { + const store = renderMenu( + { sidebarPlaylistsOnlyFavourites: true }, + { 'pl-1': { id: 'pl-1', name: 'Mine', ownerId: 'user-1' } }, + ) + const before = lastQuery().payload.starFingerprint + act(() => { + store.dispatch({ + type: SET_PLAYLIST_DATA, + data: { + 'pl-1': { + id: 'pl-1', + name: 'Mine', + ownerId: 'user-1', + starred: true, + }, + }, + }) + }) + expect(lastQuery().payload.starFingerprint).not.toBe(before) + expect(lastQuery().payload.starFingerprint).toContain('pl-1') + }) +}) diff --git a/ui/src/layout/SubMenu.jsx b/ui/src/layout/SubMenu.jsx index 418f4c651..ee1bf343e 100644 --- a/ui/src/layout/SubMenu.jsx +++ b/ui/src/layout/SubMenu.jsx @@ -33,6 +33,9 @@ const useStyles = makeStyles( menuHeader: { width: '100%', }, + headerText: { + flexGrow: 1, + }, headerWrapper: { display: 'flex', '&:hover $actionIcon': { @@ -55,6 +58,10 @@ const SubMenu = ({ dense, onAction, actionIcon, + onSecondaryAction, + secondaryActionIcon, + secondaryActionTitle, + secondaryActionActive, }) => { const translate = useTranslate() const classes = useStyles() @@ -70,6 +77,11 @@ const SubMenu = ({ } } + const handleSecondaryClick = (e) => { + e.stopPropagation() + onSecondaryAction(e) + } + const header = (
{isOpen ? : icon} - + {translate(name)} + {onSecondaryAction && sidebarIsOpen && ( + + {secondaryActionIcon} + + )} {onAction && sidebarIsOpen && ( {
- - - {record.name || translate('ra.page.loading')} - - +
+ + + {record.name || translate('ra.page.loading')} + + + +
{record.songCount ? ( diff --git a/ui/src/playlist/PlaylistList.jsx b/ui/src/playlist/PlaylistList.jsx index 8732725bc..642d90dd5 100644 --- a/ui/src/playlist/PlaylistList.jsx +++ b/ui/src/playlist/PlaylistList.jsx @@ -4,6 +4,7 @@ import { DateField, EditButton, Filter, + NullableBooleanInput, NumberField, ReferenceInput, SearchInput, @@ -22,11 +23,14 @@ import { CoverArtAvatar, DurationField, List, + LoveButton, Writable, isWritable, useSelectedFields, useResourceRefresh, } from '../common' +import FavoriteIcon from '@material-ui/icons/Favorite' +import config from '../config' import PlaylistListActions from './PlaylistListActions' import ChangePublicStatusButton from './ChangePublicStatusButton' @@ -53,6 +57,12 @@ const PlaylistFilter = (props) => { )} + {config.enableFavourites && ( + } + /> + )} ) } @@ -139,6 +149,13 @@ const PlaylistListBulkActions = (props) => { ) } +// Datagrid reads `source`/`sortable`/`label` off this element for the column +// header; only record/resource are forwarded so they never leak onto the button. +export const PlaylistLove = ({ record, className }) => ( + +) +PlaylistLove.defaultProps = { source: 'starred', sortable: false } + const PlaylistList = (props) => { const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md')) @@ -159,6 +176,7 @@ const PlaylistList = (props) => { sync: !isXsmall && ( ), + starred: config.enableFavourites && , }), [isDesktop, isXsmall], ) diff --git a/ui/src/playlist/PlaylistList.test.jsx b/ui/src/playlist/PlaylistList.test.jsx new file mode 100644 index 000000000..4fbc6d516 --- /dev/null +++ b/ui/src/playlist/PlaylistList.test.jsx @@ -0,0 +1,34 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' +import { PlaylistLove } from './PlaylistList' + +vi.mock('../config', () => ({ + default: { enableFavourites: true }, +})) + +vi.mock('../common', () => ({ + LoveButton: ({ record, resource }) => ( + + ), +})) + +describe('', () => { + it('renders a LoveButton bound to the playlist resource', () => { + render() + const btn = screen.getByTestId('love') + expect(btn.getAttribute('data-resource')).toBe('playlist') + expect(btn.textContent).toBe('starred') + }) + + it('exposes datagrid header props so the column renders unsorted', () => { + // The Datagrid reads these off the element; the wrapper body must not + // forward them to the button (which would leak onto the DOM). + expect(PlaylistLove.defaultProps).toEqual({ + source: 'starred', + sortable: false, + }) + }) +}) diff --git a/ui/src/reducers/settingsReducer.js b/ui/src/reducers/settingsReducer.js index 0e598c22d..3f8278ea4 100644 --- a/ui/src/reducers/settingsReducer.js +++ b/ui/src/reducers/settingsReducer.js @@ -1,6 +1,7 @@ import { SET_NOTIFICATIONS_STATE, SET_OMITTED_FIELDS, + SET_SIDEBAR_PLAYLISTS_FAVOURITES, SET_TOGGLEABLE_FIELDS, } from '../actions' @@ -8,6 +9,7 @@ const initialState = { notifications: false, toggleableFields: {}, omittedFields: {}, + sidebarPlaylistsOnlyFavourites: false, } export const settingsReducer = (previousState = initialState, payload) => { @@ -34,6 +36,11 @@ export const settingsReducer = (previousState = initialState, payload) => { ...data, }, } + case SET_SIDEBAR_PLAYLISTS_FAVOURITES: + return { + ...previousState, + sidebarPlaylistsOnlyFavourites: data, + } default: return previousState } diff --git a/ui/src/reducers/settingsReducer.test.js b/ui/src/reducers/settingsReducer.test.js new file mode 100644 index 000000000..7ad68b291 --- /dev/null +++ b/ui/src/reducers/settingsReducer.test.js @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest' +import { settingsReducer } from './settingsReducer' +import { + SET_SIDEBAR_PLAYLISTS_FAVOURITES, + setSidebarPlaylistsOnlyFavourites, +} from '../actions' + +describe('settingsReducer', () => { + it('defaults sidebarPlaylistsOnlyFavourites to false', () => { + const state = settingsReducer(undefined, { type: 'UNKNOWN' }) + expect(state.sidebarPlaylistsOnlyFavourites).toBe(false) + }) + + it('enables the flag via the action creator', () => { + const state = settingsReducer( + undefined, + setSidebarPlaylistsOnlyFavourites(true), + ) + expect(state.sidebarPlaylistsOnlyFavourites).toBe(true) + }) + + it('disables the flag and preserves other settings', () => { + const initial = settingsReducer(undefined, { type: 'UNKNOWN' }) + const on = settingsReducer(initial, { + type: SET_SIDEBAR_PLAYLISTS_FAVOURITES, + data: true, + }) + const off = settingsReducer(on, { + type: SET_SIDEBAR_PLAYLISTS_FAVOURITES, + data: false, + }) + expect(off.sidebarPlaylistsOnlyFavourites).toBe(false) + expect(off.notifications).toEqual(initial.notifications) + expect(off.toggleableFields).toEqual(initial.toggleableFields) + }) +}) From f61b4eee210d692f50d37d3bad5b3714e28a094d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 18 Jul 2026 16:40:10 -0400 Subject: [PATCH 19/71] fix(deezer): pick most-popular artist among same-name matches (#5808) * fix(deezer): pick most-popular artist among same-name matches The Deezer agent searched with order=RANKING and always took the top-ranked result (artists[0]) as long as its name matched. Deezer's RANKING order isn't reliable for homonyms, so for names shared by several artists (e.g. "Queen") it locked onto a low-popularity artist whose Top Tracks are empty, leaving getTopSongs empty. Among the exact-name matches, select the one with the highest fan count instead. This resolves "Queen" to the real band (Deezer ID 412) and preserves the existing ErrNotFound behavior when nothing matches the name exactly. Fixes #5802 * fix(deezer): improve artist disambiguation by ranking exact-case names Signed-off-by: Deluan --------- Signed-off-by: Deluan --- adapters/deezer/deezer.go | 31 +++++++++++++++--- adapters/deezer/deezer_test.go | 60 ++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 5 deletions(-) diff --git a/adapters/deezer/deezer.go b/adapters/deezer/deezer.go index ed3071766..d8e832cf1 100644 --- a/adapters/deezer/deezer.go +++ b/adapters/deezer/deezer.go @@ -1,10 +1,12 @@ package deezer import ( + "cmp" "context" "errors" "fmt" "net/http" + "slices" "strings" "github.com/navidrome/navidrome/conf" @@ -95,13 +97,32 @@ func (s *deezerAgent) searchArtist(ctx context.Context, name string) (*Artist, e } } - // If the first one has the same name, that's the one - if !strings.EqualFold(artists[0].Name, name) { - log.Trace(ctx, "Top artist do not match", "searched_name", name, "found_name", artists[0].Name) + // Deezer's RANKING order isn't reliable for homonyms: rank name matches + // ahead of non-matches, prefer an exact-case match, then the most fans. + rank := func(a Artist) int { + switch { + case a.Name == name: + return 2 + case strings.EqualFold(a.Name, name): + return 1 + default: + return 0 + } + } + slices.SortFunc(artists, func(a, b Artist) int { + return cmp.Or( + cmp.Compare(rank(b), rank(a)), + cmp.Compare(b.NbFan, a.NbFan), + cmp.Compare(a.ID, b.ID), + ) + }) + best := artists[0] + if !strings.EqualFold(best.Name, name) { + log.Trace(ctx, "No artist matched the searched name", "searched_name", name, "found_name", artists[0].Name) return nil, agents.ErrNotFound } - log.Trace(ctx, "Found artist", "name", artists[0].Name, "id", artists[0].ID, "link", artists[0].Link) - return &artists[0], err + log.Trace(ctx, "Found artist", "name", best.Name, "id", best.ID, "link", best.Link, "nb_fan", best.NbFan) + return new(best), nil } func (s *deezerAgent) GetSimilarArtists(ctx context.Context, _, name, _ string, limit int) ([]agents.Artist, error) { diff --git a/adapters/deezer/deezer_test.go b/adapters/deezer/deezer_test.go index 4dd251585..f478af115 100644 --- a/adapters/deezer/deezer_test.go +++ b/adapters/deezer/deezer_test.go @@ -34,6 +34,66 @@ var _ = Describe("deezerAgent", func() { }) }) + Describe("searchArtist", func() { + var agent *deezerAgent + var httpClient *fakeHttpClient + + BeforeEach(func() { + httpClient = &fakeHttpClient{} + agent = &deezerAgent{ + dataStore: &tests.MockDataStore{}, + client: newClient(httpClient), + } + }) + + It("picks the exact-name match with the most fans when several share the name", func() { + // Deezer RANKING order returns a low-popularity homonym first (see issue #5802) + httpClient.mock("https://api.deezer.com/search/artist", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{"data":[ + {"id":61045802,"name":"Queen","nb_fan":75}, + {"id":141954732,"name":"Queen","nb_fan":397}, + {"id":135041032,"name":"Queen(Ares)","nb_fan":133}, + {"id":183179807,"name":"Queen","nb_fan":53}, + {"id":412,"name":"Queen","nb_fan":12744378} + ],"total":5}`)), + }) + + artist, err := agent.searchArtist(ctx, "Queen") + + Expect(err).ToNot(HaveOccurred()) + Expect(artist.ID).To(Equal(412)) + }) + + It("matches the name case-insensitively", func() { + httpClient.mock("https://api.deezer.com/search/artist", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{"data":[ + {"id":1,"name":"QUEEN","nb_fan":10}, + {"id":2,"name":"queen","nb_fan":20} + ],"total":2}`)), + }) + + artist, err := agent.searchArtist(ctx, "Queen") + + Expect(err).ToNot(HaveOccurred()) + Expect(artist.ID).To(Equal(2)) + }) + + It("returns ErrNotFound when no result matches the name exactly", func() { + httpClient.mock("https://api.deezer.com/search/artist", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{"data":[ + {"id":1,"name":"Queens of the Stone Age","nb_fan":100} + ],"total":1}`)), + }) + + _, err := agent.searchArtist(ctx, "Queen") + + Expect(err).To(MatchError(agents.ErrNotFound)) + }) + }) + Describe("GetArtistBiography - Language Fallback", func() { var agent *deezerAgent var httpClient *langAwareHttpClient From e4a423db11bae287a2d41980e36536d8f1c00a9f Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 18 Jul 2026 16:49:31 -0400 Subject: [PATCH 20/71] feat(jellyfin): emit refreshResource events on favorite/rating changes Like Subsonic's setStar/setRating, the Jellyfin favorite and rating endpoints now broadcast a refreshResource event, so the web UI updates immediately when a Jellyfin client changes an annotation. Also fixes model.GetEntityByID to propagate unexpected repository errors instead of reporting them as not-found, preserving the 500-vs-404 distinction for all its callers. --- cmd/wire_gen.go | 2 +- model/get_entity.go | 35 ++++++------- model/get_entity_test.go | 40 +++++++++++++++ server/jellyfin/annotations.go | 72 ++++++++++++--------------- server/jellyfin/annotations_test.go | 64 +++++++++++++++++++++++- server/jellyfin/api.go | 6 ++- server/jellyfin/api_test.go | 10 ++-- server/jellyfin/e2e/e2e_suite_test.go | 1 + server/jellyfin/routing_test.go | 2 +- server/jellyfin/socket_test.go | 2 +- 10 files changed, 163 insertions(+), 71 deletions(-) create mode 100644 model/get_entity_test.go diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 463ac7a44..c4a797ef4 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -138,7 +138,7 @@ func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router { playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService) sonicSonic := sonic.New(dataStore, manager, matcherMatcher) lyricsLyrics := lyrics.NewLyrics(dataStore, manager) - router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic, lyricsLyrics) + router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic, lyricsLyrics, broker) return router } diff --git a/model/get_entity.go b/model/get_entity.go index 60972b2e9..3e1a78d1d 100644 --- a/model/get_entity.go +++ b/model/get_entity.go @@ -2,29 +2,26 @@ package model import ( "context" + "errors" ) // TODO: Should the type be encoded in the ID? func GetEntityByID(ctx context.Context, ds DataStore, id string) (any, error) { - ar, err := ds.Artist(ctx).Get(id) - if err == nil { - return ar, nil + getters := []func() (any, error){ + func() (any, error) { return ds.Artist(ctx).Get(id) }, + func() (any, error) { return ds.Album(ctx).Get(id) }, + func() (any, error) { return ds.Playlist(ctx).Get(id) }, + func() (any, error) { return ds.MediaFile(ctx).Get(id) }, + func() (any, error) { return ds.Radio(ctx).Get(id) }, } - al, err := ds.Album(ctx).Get(id) - if err == nil { - return al, nil + for _, get := range getters { + entity, err := get() + if err == nil { + return entity, nil + } + if !errors.Is(err, ErrNotFound) { + return nil, err + } } - pls, err := ds.Playlist(ctx).Get(id) - if err == nil { - return pls, nil - } - mf, err := ds.MediaFile(ctx).Get(id) - if err == nil { - return mf, nil - } - r, err := ds.Radio(ctx).Get(id) - if err == nil { - return r, nil - } - return nil, err + return nil, ErrNotFound } diff --git a/model/get_entity_test.go b/model/get_entity_test.go new file mode 100644 index 000000000..f8a4c9e8e --- /dev/null +++ b/model/get_entity_test.go @@ -0,0 +1,40 @@ +package model_test + +import ( + "context" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("GetEntityByID", func() { + var ds *tests.MockDataStore + var ctx context.Context + + BeforeEach(func() { + ds = &tests.MockDataStore{} + ctx = GinkgoT().Context() + }) + + It("returns the entity matching the id", func() { + ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + entity, err := model.GetEntityByID(ctx, ds, "a1") + Expect(err).ToNot(HaveOccurred()) + Expect(entity).To(BeAssignableToTypeOf(&model.Album{})) + Expect(entity.(*model.Album).ID).To(Equal("a1")) + }) + + It("returns ErrNotFound when no entity matches", func() { + _, err := model.GetEntityByID(ctx, ds, "missing") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("propagates unexpected repository errors instead of reporting not-found", func() { + ds.Album(ctx).(*tests.MockAlbumRepo).SetError(true) + _, err := model.GetEntityByID(ctx, ds, "a1") + Expect(err).To(HaveOccurred()) + Expect(err).ToNot(MatchError(model.ErrNotFound)) + }) +}) diff --git a/server/jellyfin/annotations.go b/server/jellyfin/annotations.go index 8f900ee87..f2c2f546e 100644 --- a/server/jellyfin/annotations.go +++ b/server/jellyfin/annotations.go @@ -8,53 +8,41 @@ import ( "github.com/go-chi/chi/v5" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/events" "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) { +// resolveAnnotated finds which annotated repo owns id, returning the resource name used in +// refreshResource events. 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 repo is nil 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, resource string) { ctx := r.Context() + entity, err := model.GetEntityByID(ctx, api.ds, id) + if err != nil && !errors.Is(err, model.ErrNotFound) { + api.internalError(w, r, err) + return nil, "" + } 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 + switch e := entity.(type) { + case *model.Album: + if u.HasLibraryAccess(e.LibraryID) { + return api.ds.Album(ctx), "album" } - 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 + case *model.Artist: + return api.ds.Artist(ctx), "artist" + case *model.MediaFile: + if u.HasLibraryAccess(e.LibraryID) { + return api.ds.MediaFile(ctx), "song" } - 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 + case *model.Playlist: + return api.ds.Playlist(ctx), "playlist" } + // Unknown ids, inaccessible-library items and non-annotatable entities (radios) all read as absent. http.Error(w, "Not Found", http.StatusNotFound) - return nil, false + return nil, "" } // getUserItemData returns the caller's play/favorite/rating state for a single item. Jellify @@ -77,14 +65,15 @@ func (api *Router) getUserItemData(w http.ResponseWriter, r *http.Request) { 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 { + repo, resource := api.resolveAnnotated(w, r, id) + if repo == nil { return } if err := repo.SetStar(starred, id); err != nil { api.internalError(w, r, err) return } + api.broker.SendMessage(r.Context(), (&events.RefreshResource{}).With(resource, id)) encodedID := dto.EncodeID(id) api.ok(w, r, &dto.UserItemDataDto{IsFavorite: starred, Key: encodedID, ItemId: encodedID}) } @@ -96,14 +85,15 @@ func (api *Router) unmarkFavorite(w http.ResponseWriter, r *http.Request) { 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 { + repo, resource := api.resolveAnnotated(w, r, id) + if repo == nil { return } if err := repo.SetRating(rating, id); err != nil { api.internalError(w, r, err) return } + api.broker.SendMessage(r.Context(), (&events.RefreshResource{}).With(resource, id)) encodedID := dto.EncodeID(id) d := &dto.UserItemDataDto{Key: encodedID, ItemId: encodedID} if rating > 0 { diff --git a/server/jellyfin/annotations_test.go b/server/jellyfin/annotations_test.go index 4a7efabd7..9e811a379 100644 --- a/server/jellyfin/annotations_test.go +++ b/server/jellyfin/annotations_test.go @@ -8,6 +8,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/server/jellyfin/dto" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" @@ -17,6 +18,7 @@ import ( var _ = Describe("Annotations", func() { var api *Router var ds *tests.MockDataStore + var broker *fakeEventBroker // 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"}}}) @@ -24,7 +26,8 @@ var _ = Describe("Annotations", func() { BeforeEach(func() { ds = &tests.MockDataStore{} - api = &Router{ds: ds} + broker = &fakeEventBroker{} + api = &Router{ds: ds, broker: broker} }) Describe("markFavorite / unmarkFavorite", func() { @@ -134,6 +137,39 @@ var _ = Describe("Annotations", func() { invoke(api.markFavorite, w, r) Expect(w.Code).To(Equal(http.StatusInternalServerError)) }) + + It("emits a refreshResource event when starring a song", 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(broker.Events).To(HaveLen(1)) + Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"song":["s1"]}`)) + }) + + It("emits a refreshResource event when starring 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/FavoriteItems/"+dto.EncodeID("a1"), nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID("a1")) + invoke(api.markFavorite, w, r) + Expect(broker.Events).To(HaveLen(1)) + Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"album":["a1"]}`)) + }) + + It("does not emit an event when the item is not accessible", 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()) + r = withChiURLParam(r, "itemId", dto.EncodeID("a1")) + invoke(api.markFavorite, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(broker.Events).To(BeEmpty()) + }) }) Describe("setRating / removeRating", func() { @@ -253,5 +289,31 @@ var _ = Describe("Annotations", func() { Expect(w.Code).To(Equal(http.StatusOK)) Expect(mfRepo.Data["s1"].Rating).To(Equal(0)) }) + + It("emits a refreshResource event when rating a song", 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(broker.Events).To(HaveLen(1)) + Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"song":["s1"]}`)) + }) }) }) + +type fakeEventBroker struct { + http.Handler + Events []events.Event +} + +func (f *fakeEventBroker) SendMessage(_ context.Context, event events.Event) { + f.Events = append(f.Events, event) +} + +func (f *fakeEventBroker) SendBroadcastMessage(_ context.Context, event events.Event) { + f.Events = append(f.Events, event) +} + +var _ events.Broker = (*fakeEventBroker)(nil) diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go index 5e7cad0b8..0f79011c7 100644 --- a/server/jellyfin/api.go +++ b/server/jellyfin/api.go @@ -22,6 +22,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server" + "github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/server/jellyfin/dto" "github.com/navidrome/navidrome/utils/cache" ) @@ -38,6 +39,7 @@ type Router struct { provider external.Provider sonic sonic.Engine lyrics lyrics.Lyrics + broker events.Broker lyricsCache cache.SimpleCache[string, model.LyricList] similarFlight singleflight.Group serverIDMu sync.Mutex @@ -47,11 +49,11 @@ 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, - sonicSvc sonic.Engine, lyricsSvc lyrics.Lyrics) *Router { + sonicSvc sonic.Engine, lyricsSvc lyrics.Lyrics, broker events.Broker) *Router { r := &Router{ ds: ds, artwork: artwork, streamer: streamer, transcodeDecider: transcodeDecider, players: players, scrobbler: scrobbler, playlists: playlists, provider: provider, - sonic: sonicSvc, lyrics: lyricsSvc, + sonic: sonicSvc, lyrics: lyricsSvc, broker: broker, lyricsCache: cache.NewSimpleCache[string, model.LyricList](cache.Options{ SizeLimit: 1000, DefaultTTL: 5 * time.Minute, diff --git a/server/jellyfin/api_test.go b/server/jellyfin/api_test.go index 62af74794..e8e9cbd4b 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, nil, nil) + api := New(ds, nil, nil, 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, nil, nil) + api := New(&tests.MockDataStore{}, nil, nil, 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, nil, nil) + api := New(&tests.MockDataStore{}, nil, nil, 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, nil, nil) + api := New(ds, nil, nil, nil, fp, nil, nil, 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, nil, nil) + api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) login := func() int { w := httptest.NewRecorder() diff --git a/server/jellyfin/e2e/e2e_suite_test.go b/server/jellyfin/e2e/e2e_suite_test.go index e0f7b5bb5..445566387 100644 --- a/server/jellyfin/e2e/e2e_suite_test.go +++ b/server/jellyfin/e2e/e2e_suite_test.go @@ -329,6 +329,7 @@ func setupTestDB() { providerFake, sonicSvc, lyrics.NewLyrics(ds, nil), + events.NoopBroker(), ) } diff --git a/server/jellyfin/routing_test.go b/server/jellyfin/routing_test.go index b0bc92caf..62b52cfff 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, nil, nil) + api = New(&tests.MockDataStore{}, nil, nil, 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 7d8874476..8c509fe91 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, nil, nil) + api = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) }) It("upgrades when authenticated via the api_key query parameter", func() { From 9c7cf7d734e0a34fd47bdbd66c3e87983f5b383e Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Sat, 18 Jul 2026 21:46:11 +0000 Subject: [PATCH 21/71] feat(jellyfin): expose genreitems for song/album (#5809) * feat(jellyfin): expose genreitems for song/album * use encode id instead --- server/jellyfin/dto/dto.go | 1 + server/jellyfin/dto/mappers.go | 2 ++ server/jellyfin/dto/mappers_test.go | 7 ++++++- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/server/jellyfin/dto/dto.go b/server/jellyfin/dto/dto.go index 9847a239c..2a817739b 100644 --- a/server/jellyfin/dto/dto.go +++ b/server/jellyfin/dto/dto.go @@ -70,6 +70,7 @@ type BaseItemDto struct { Artists []string `json:"Artists,omitempty"` ArtistItems []NameGuidPair `json:"ArtistItems,omitempty"` Genres []string `json:"Genres,omitempty"` + GenreItems []NameGuidPair `json:"GenreItems,omitempty"` ChildCount *int `json:"ChildCount,omitempty"` SongCount *int `json:"SongCount,omitempty"` AlbumCount *int `json:"AlbumCount,omitempty"` diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go index c65b8933d..2894edd6e 100644 --- a/server/jellyfin/dto/mappers.go +++ b/server/jellyfin/dto/mappers.go @@ -175,6 +175,7 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto { if len(mf.Genres) > 0 { for _, g := range mf.Genres { item.Genres = append(item.Genres, g.Name) + item.GenreItems = append(item.GenreItems, NameGuidPair{Id: EncodeID(g.ID), Name: g.Name}) } } else if mf.Genre != "" { item.Genres = []string{mf.Genre} @@ -216,6 +217,7 @@ func AlbumToBaseItem(al model.Album) BaseItemDto { if len(al.Genres) > 0 { for _, g := range al.Genres { item.Genres = append(item.Genres, g.Name) + item.GenreItems = append(item.GenreItems, NameGuidPair{Id: EncodeID(g.ID), Name: g.Name}) } } return item diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go index e72efd617..db0c56aa3 100644 --- a/server/jellyfin/dto/mappers_test.go +++ b/server/jellyfin/dto/mappers_test.go @@ -15,6 +15,7 @@ var _ = Describe("mappers", func() { 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, + Genres: []model.Genre{{ID: "1", Name: "genre 1"}, {ID: "2", Name: "genre 2"}}, } mf.PlayCount = 2 mf.Starred = true @@ -35,6 +36,8 @@ var _ = Describe("mappers", func() { 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)) + Expect(item.Genres).To(Equal([]string{"genre 1", "genre 2"})) + Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID("1"), Name: "genre 1"}, {Id: EncodeID("2"), Name: "genre 2"}})) }) Describe("Fields gating (matches real Jellyfin)", func() { @@ -198,7 +201,7 @@ var _ = Describe("mappers", func() { }) 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} + al := model.Album{ID: "alb-1", Name: "Alb", AlbumArtist: "AA", AlbumArtistID: "art-1", MaxYear: 1999, SongCount: 10, Genres: []model.Genre{{ID: "1", Name: "genre 1"}, {ID: "2", Name: "genre 2"}}} item := AlbumToBaseItem(al) Expect(item.Type).To(Equal("MusicAlbum")) Expect(item.IsFolder).To(BeTrue()) @@ -211,6 +214,8 @@ var _ = Describe("mappers", func() { 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)) + Expect(item.Genres).To(Equal([]string{"genre 1", "genre 2"})) + Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID("1"), Name: "genre 1"}, {Id: EncodeID("2"), Name: "genre 2"}})) }) It("maps an artist to a MusicArtist folder item", func() { From 09ac342f5ae58ca69d60ca2d0cdf2302cf5bf76e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 18 Jul 2026 18:16:52 -0400 Subject: [PATCH 22/71] fix(jellyfin): split Artists/ArtistItems per track artist (#5810) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(jellyfin): split Artists/ArtistItems per track artist The Jellyfin API returned a single ArtistItems entry built from the flattened display artist, so a multi-artist track (e.g. "De La Soul feat. Redman") lost its individual artists and dropped every artist id but the first — while the same track's OpenSubsonic response correctly split them. Real Jellyfin splits Artists/ArtistItems one entry per track artist. Build both from Participants[RoleArtist], which is already loaded on the search and browse paths, falling back to the flattened display fields when absent. AlbumArtists stays a single credit, matching real Jellyfin. * fix(jellyfin): omit empty Artists in the fallback path When a track has no participants and no display artist, the fallback set Artists to a single-element slice holding an empty string. Only populate it when the display artist is non-empty, so untagged tracks omit the field instead of sending [""]. --- server/jellyfin/dto/mappers.go | 21 +++++++++++++++------ server/jellyfin/dto/mappers_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go index 2894edd6e..dc9bc5fbd 100644 --- a/server/jellyfin/dto/mappers.go +++ b/server/jellyfin/dto/mappers.go @@ -6,6 +6,7 @@ import ( "time" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/slice" ) // Jellyfin wire times are ticks: 100ns units, i.e. 10,000 per millisecond. @@ -139,7 +140,6 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto { 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, @@ -153,11 +153,20 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto { 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)}} + // Real Jellyfin splits Artists/ArtistItems per track artist (AlbumArtists stays a single credit). + // Participants holds the per-artist list; fall back to the flattened display fields when absent. + if artists := mf.Participants[model.RoleArtist]; len(artists) > 0 { + item.Artists = slice.Map(artists, func(p model.Participant) string { return p.Name }) + item.ArtistItems = slice.Map(artists, func(p model.Participant) NameGuidPair { + return NameGuidPair{Name: p.Name, Id: EncodeID(p.ID)} + }) + } else { + if mf.Artist != "" { + item.Artists = []string{mf.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)}} diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go index db0c56aa3..6bfc9d95a 100644 --- a/server/jellyfin/dto/mappers_test.go +++ b/server/jellyfin/dto/mappers_test.go @@ -99,6 +99,32 @@ var _ = Describe("mappers", func() { Expect(SongToBaseItem(model.MediaFile{ID: "s1", Title: "Song", Artist: "X"}, nil).ArtistItems).To(BeNil()) }) + It("omits Artists when the track has no artist name or participants", func() { + Expect(SongToBaseItem(model.MediaFile{ID: "s1", Title: "Song"}, nil).Artists).To(BeNil()) + }) + + It("splits Artists and ArtistItems per track artist from Participants", func() { + mf := model.MediaFile{ + ID: "s1", Title: "Oooh", + Artist: "De La Soul feat. Redman", ArtistID: "ar-delasoul", + AlbumArtist: "De La Soul", AlbumArtistID: "ar-delasoul", + } + mf.Participants = model.Participants{ + model.RoleArtist: model.ParticipantList{ + {Artist: model.Artist{ID: "ar-delasoul", Name: "De La Soul"}}, + {Artist: model.Artist{ID: "ar-redman", Name: "Redman"}}, + }, + } + item := SongToBaseItem(mf, nil) + Expect(item.Artists).To(Equal([]string{"De La Soul", "Redman"})) + Expect(item.ArtistItems).To(Equal([]NameGuidPair{ + {Name: "De La Soul", Id: EncodeID("ar-delasoul")}, + {Name: "Redman", Id: EncodeID("ar-redman")}, + })) + // AlbumArtists stays single, matching real Jellyfin. + Expect(item.AlbumArtists).To(Equal([]NameGuidPair{{Name: "De La Soul", Id: EncodeID("ar-delasoul")}})) + }) + 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) From 1e82f515c462bc08c1f0ffe058e286a20560626c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 18 Jul 2026 18:59:08 -0400 Subject: [PATCH 23/71] fix(jellyfin): honor repeated Fields query params (#5811) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Fields param was read with StringOr, which keeps only one value, so a request sending it as repeated params (Fields=Genres&Fields=MediaSources) — as Finamp and Feishin do — lost all but the first. Field-gated data like MediaSources was then omitted for Audio items even though the client asked for it; the comma-separated form happened to work because ParseFields splits on commas. Real Jellyfin accepts both forms. ParseFields is now variadic and a parseFields helper reads every repeated value via req.Values.Strings, applied to the item list, single-item, and playlist endpoints. --- server/jellyfin/dto/fields.go | 13 ++++++++----- server/jellyfin/dto/fields_test.go | 26 ++++++++++++++++++++++++++ server/jellyfin/e2e/browsing_test.go | 10 ++++++++++ server/jellyfin/items.go | 11 +++++++++-- server/jellyfin/playlists.go | 2 +- 5 files changed, 54 insertions(+), 8 deletions(-) create mode 100644 server/jellyfin/dto/fields_test.go diff --git a/server/jellyfin/dto/fields.go b/server/jellyfin/dto/fields.go index faa31ec0d..fb49e43c7 100644 --- a/server/jellyfin/dto/fields.go +++ b/server/jellyfin/dto/fields.go @@ -7,12 +7,15 @@ import "strings" // 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 { +// ParseFields builds a lowercased set from the Fields param. It accepts each value comma-separated +// (Fields=a,b) and across repeated params (Fields=a&Fields=b), both of which real Jellyfin honors. +func ParseFields(values ...string) Fields { f := Fields{} - for name := range strings.SplitSeq(csv, ",") { - if name = strings.TrimSpace(strings.ToLower(name)); name != "" { - f[name] = struct{}{} + for _, csv := range values { + for name := range strings.SplitSeq(csv, ",") { + if name = strings.TrimSpace(strings.ToLower(name)); name != "" { + f[name] = struct{}{} + } } } return f diff --git a/server/jellyfin/dto/fields_test.go b/server/jellyfin/dto/fields_test.go new file mode 100644 index 000000000..59d06734d --- /dev/null +++ b/server/jellyfin/dto/fields_test.go @@ -0,0 +1,26 @@ +package dto + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("ParseFields", func() { + It("parses a single comma-separated value", func() { + f := ParseFields("Genres,MediaSources") + Expect(f.Has("Genres")).To(BeTrue()) + Expect(f.Has("MediaSources")).To(BeTrue()) + }) + + It("parses fields spread across repeated params", func() { + f := ParseFields("Genres", "MediaSources", "SortName") + Expect(f.Has("Genres")).To(BeTrue()) + Expect(f.Has("MediaSources")).To(BeTrue()) + Expect(f.Has("SortName")).To(BeTrue()) + }) + + It("returns an empty set for no values", func() { + Expect(ParseFields()).To(BeEmpty()) + Expect(ParseFields("")).To(BeEmpty()) + }) +}) diff --git a/server/jellyfin/e2e/browsing_test.go b/server/jellyfin/e2e/browsing_test.go index 18565c635..382ec9406 100644 --- a/server/jellyfin/e2e/browsing_test.go +++ b/server/jellyfin/e2e/browsing_test.go @@ -62,6 +62,16 @@ var _ = Describe("Browsing", func() { } }) + // Clients (Finamp, Feishin) send Fields as repeated params rather than one comma-separated + // value; real Jellyfin accepts both, so a later Fields=MediaSources must still take effect. + It("honors MediaSources when Fields is sent as repeated params", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&Fields=Genres&Fields=MediaSources")) + Expect(q.Items).ToNot(BeEmpty()) + for _, it := range q.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)) diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go index b9372c397..6ed24b056 100644 --- a/server/jellyfin/items.go +++ b/server/jellyfin/items.go @@ -224,13 +224,20 @@ type itemsQuery struct { albumIds []string } +// parseFields reads the Fields param, accepting both repeated params (Fields=a&Fields=b) and a +// comma-separated value (Fields=a,b), matching real Jellyfin. StringOr would keep only one value. +func parseFields(p *req.Values) dto.Fields { + values, _ := p.Strings("fields") + return dto.ParseFields(values...) +} + // 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", "")), + fields: parseFields(p), ids: decodedQueryIDs(r, "ids"), rawTypes: p.StringOr("includeitemtypes", ""), search: searchTerm(p), @@ -730,7 +737,7 @@ func (api *Router) itemsByIDs(ctx context.Context, ids []string, fields dto.Fiel 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", "")) + fields := parseFields(req.Params(r)) if item, ok := api.resolveItemByID(r.Context(), id, fields); ok { api.ok(w, r, item) return diff --git a/server/jellyfin/playlists.go b/server/jellyfin/playlists.go index 084ff2975..4d3959a79 100644 --- a/server/jellyfin/playlists.go +++ b/server/jellyfin/playlists.go @@ -191,7 +191,7 @@ func (api *Router) getPlaylistItems(w http.ResponseWriter, r *http.Request) { return } p := req.Params(r) - fields := dto.ParseFields(p.StringOr("fields", "")) + fields := parseFields(p) res, err := api.playlistTrackPage(repo, fields, p.IntOr("startindex", 0), p.IntOr("limit", 0)) if err != nil { api.internalError(w, r, err) From 3158451b8d56cabf63e6b43b818bab99534d0396 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 18 Jul 2026 19:30:04 -0400 Subject: [PATCH 24/71] refactor(server): drop redundant error return from req.Strings parsing (#5812) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(req): drop redundant error return from Strings The error from Strings carried no information beyond emptiness — it fired exactly when the param was absent — and nearly every caller discarded it with a blank identifier. Strings now just returns the values (empty when absent), making the common optional-list reads one clean expression. The few required-param callers (scrobble, createShare) check for emptiness and return the same Subsonic error code 10 as before; their e2e tests now pin that code. Ints and Times keep their contracts by synthesizing ErrMissingParam themselves, so selectedMusicFolderIds is untouched. The jellyfin parseFields helper is inlined away, since ParseFields(p.Strings("fields")...) now compiles directly. * docs(req): clarify Strings returns nil when param is absent --- server/jellyfin/items.go | 11 ++-------- server/jellyfin/playlists.go | 2 +- server/nativeapi/missing.go | 2 +- server/nativeapi/playlists.go | 2 +- server/subsonic/bookmarks.go | 4 ++-- .../e2e/subsonic_media_annotation_test.go | 1 + server/subsonic/e2e/subsonic_sharing_test.go | 1 + server/subsonic/jukebox.go | 4 ++-- server/subsonic/library_scanning.go | 3 ++- server/subsonic/media_annotation.go | 18 +++++++-------- server/subsonic/playlists.go | 4 ++-- server/subsonic/sharing.go | 6 ++--- utils/req/req.go | 22 +++++++++---------- utils/req/req_test.go | 4 +--- 14 files changed, 38 insertions(+), 46 deletions(-) diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go index 6ed24b056..c3c217cfc 100644 --- a/server/jellyfin/items.go +++ b/server/jellyfin/items.go @@ -224,20 +224,13 @@ type itemsQuery struct { albumIds []string } -// parseFields reads the Fields param, accepting both repeated params (Fields=a&Fields=b) and a -// comma-separated value (Fields=a,b), matching real Jellyfin. StringOr would keep only one value. -func parseFields(p *req.Values) dto.Fields { - values, _ := p.Strings("fields") - return dto.ParseFields(values...) -} - // 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: parseFields(p), + fields: dto.ParseFields(p.Strings("fields")...), ids: decodedQueryIDs(r, "ids"), rawTypes: p.StringOr("includeitemtypes", ""), search: searchTerm(p), @@ -737,7 +730,7 @@ func (api *Router) itemsByIDs(ctx context.Context, ids []string, fields dto.Fiel func (api *Router) getItem(w http.ResponseWriter, r *http.Request) { id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId"))) - fields := parseFields(req.Params(r)) + fields := dto.ParseFields(req.Params(r).Strings("fields")...) if item, ok := api.resolveItemByID(r.Context(), id, fields); ok { api.ok(w, r, item) return diff --git a/server/jellyfin/playlists.go b/server/jellyfin/playlists.go index 4d3959a79..9afee5ac3 100644 --- a/server/jellyfin/playlists.go +++ b/server/jellyfin/playlists.go @@ -191,7 +191,7 @@ func (api *Router) getPlaylistItems(w http.ResponseWriter, r *http.Request) { return } p := req.Params(r) - fields := parseFields(p) + fields := dto.ParseFields(p.Strings("fields")...) res, err := api.playlistTrackPage(repo, fields, p.IntOr("startindex", 0), p.IntOr("limit", 0)) if err != nil { api.internalError(w, r, err) diff --git a/server/nativeapi/missing.go b/server/nativeapi/missing.go index 2b455e622..0ad9bb0cc 100644 --- a/server/nativeapi/missing.go +++ b/server/nativeapi/missing.go @@ -68,7 +68,7 @@ func deleteMissingFiles(maintenance core.Maintenance) http.HandlerFunc { ctx := r.Context() p := req.Params(r) - ids, _ := p.Strings("id") + ids := p.Strings("id") var err error if len(ids) == 0 { diff --git a/server/nativeapi/playlists.go b/server/nativeapi/playlists.go index ea1cf579b..90b2f9e94 100644 --- a/server/nativeapi/playlists.go +++ b/server/nativeapi/playlists.go @@ -102,7 +102,7 @@ func deleteFromPlaylist(pls playlists.Playlists) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { p := req.Params(r) playlistId, _ := p.String(":playlistId") - ids, _ := p.Strings("id") + ids := p.Strings("id") err := pls.RemoveTracks(r.Context(), playlistId, ids) if len(ids) == 1 && errors.Is(err, model.ErrNotFound) { log.Warn(r.Context(), "Track not found in playlist", "playlistId", playlistId, "id", ids[0]) diff --git a/server/subsonic/bookmarks.go b/server/subsonic/bookmarks.go index 337712750..4a7ebaa6c 100644 --- a/server/subsonic/bookmarks.go +++ b/server/subsonic/bookmarks.go @@ -103,7 +103,7 @@ func (api *Router) GetPlayQueue(r *http.Request) (*responses.Subsonic, error) { func (api *Router) SavePlayQueue(r *http.Request) (*responses.Subsonic, error) { p := req.Params(r) - ids, _ := p.Strings("id") + ids := p.Strings("id") currentID, _ := p.String("current") position := p.Int64Or("position", 0) @@ -176,7 +176,7 @@ func (api *Router) GetPlayQueueByIndex(r *http.Request) (*responses.Subsonic, er func (api *Router) SavePlayQueueByIndex(r *http.Request) (*responses.Subsonic, error) { p := req.Params(r) - ids, _ := p.Strings("id") + ids := p.Strings("id") position := p.Int64Or("position", 0) diff --git a/server/subsonic/e2e/subsonic_media_annotation_test.go b/server/subsonic/e2e/subsonic_media_annotation_test.go index ec9b070de..74b5238f2 100644 --- a/server/subsonic/e2e/subsonic_media_annotation_test.go +++ b/server/subsonic/e2e/subsonic_media_annotation_test.go @@ -155,6 +155,7 @@ var _ = Describe("Media Annotation Endpoints", Ordered, func() { Expect(resp.Status).To(Equal(responses.StatusFailed)) Expect(resp.Error).ToNot(BeNil()) + Expect(resp.Error.Code).To(Equal(responses.ErrorMissingParameter)) }) }) diff --git a/server/subsonic/e2e/subsonic_sharing_test.go b/server/subsonic/e2e/subsonic_sharing_test.go index 03bf1f80f..0421d96ea 100644 --- a/server/subsonic/e2e/subsonic_sharing_test.go +++ b/server/subsonic/e2e/subsonic_sharing_test.go @@ -109,6 +109,7 @@ var _ = Describe("Sharing Endpoints", Ordered, func() { Expect(resp.Status).To(Equal(responses.StatusFailed)) Expect(resp.Error).ToNot(BeNil()) + Expect(resp.Error.Code).To(Equal(responses.ErrorMissingParameter)) }) It("updateShare returns error when id parameter is missing", func() { diff --git a/server/subsonic/jukebox.go b/server/subsonic/jukebox.go index c4bc643ab..d8bf53360 100644 --- a/server/subsonic/jukebox.go +++ b/server/subsonic/jukebox.go @@ -68,7 +68,7 @@ func (api *Router) JukeboxControl(r *http.Request) (*responses.Subsonic, error) case ActionStatus: return createResponse(pb.Status(ctx)) case ActionSet: - ids, _ := p.Strings("id") + ids := p.Strings("id") return createResponse(pb.Set(ctx, ids)) case ActionStart: return createResponse(pb.Start(ctx)) @@ -82,7 +82,7 @@ func (api *Router) JukeboxControl(r *http.Request) (*responses.Subsonic, error) offset := p.IntOr("offset", 0) return createResponse(pb.Skip(ctx, index, offset)) case ActionAdd: - ids, _ := p.Strings("id") + ids := p.Strings("id") return createResponse(pb.Add(ctx, ids)) case ActionClear: return createResponse(pb.Clear(ctx)) diff --git a/server/subsonic/library_scanning.go b/server/subsonic/library_scanning.go index e6f64456d..9630425d2 100644 --- a/server/subsonic/library_scanning.go +++ b/server/subsonic/library_scanning.go @@ -45,7 +45,8 @@ func (api *Router) StartScan(r *http.Request) (*responses.Subsonic, error) { // Parse optional target parameters for selective scanning var targets []model.ScanTarget - if targetParams, err := p.Strings("target"); err == nil && len(targetParams) > 0 { + if targetParams := p.Strings("target"); len(targetParams) > 0 { + var err error targets, err = model.ParseTargets(targetParams) if err != nil { return nil, newError(responses.ErrorGeneric, fmt.Sprintf("Invalid target parameter: %v", err)) diff --git a/server/subsonic/media_annotation.go b/server/subsonic/media_annotation.go index 27170c11b..cfbff3ecb 100644 --- a/server/subsonic/media_annotation.go +++ b/server/subsonic/media_annotation.go @@ -71,9 +71,9 @@ func (api *Router) setRating(ctx context.Context, id string, rating int) error { func (api *Router) Star(r *http.Request) (*responses.Subsonic, error) { p := req.Params(r) - ids, _ := p.Strings("id") - albumIds, _ := p.Strings("albumId") - artistIds, _ := p.Strings("artistId") + ids := p.Strings("id") + albumIds := p.Strings("albumId") + artistIds := p.Strings("artistId") if len(ids)+len(albumIds)+len(artistIds) == 0 { return nil, newError(responses.ErrorMissingParameter, "Required id parameter is missing") } @@ -90,9 +90,9 @@ func (api *Router) Star(r *http.Request) (*responses.Subsonic, error) { func (api *Router) Unstar(r *http.Request) (*responses.Subsonic, error) { p := req.Params(r) - ids, _ := p.Strings("id") - albumIds, _ := p.Strings("albumId") - artistIds, _ := p.Strings("artistId") + ids := p.Strings("id") + albumIds := p.Strings("albumId") + artistIds := p.Strings("artistId") if len(ids)+len(albumIds)+len(artistIds) == 0 { return nil, newError(responses.ErrorMissingParameter, "Required id parameter is missing") } @@ -163,9 +163,9 @@ func (api *Router) setStar(ctx context.Context, star bool, ids ...string) error func (api *Router) Scrobble(r *http.Request) (*responses.Subsonic, error) { p := req.Params(r) - ids, err := p.Strings("id") - if err != nil { - return nil, err + ids := p.Strings("id") + if len(ids) == 0 { + return nil, newError(responses.ErrorMissingParameter, "missing parameter: 'id'") } times, _ := p.Times("time") if len(times) > 0 && len(times) != len(ids) { diff --git a/server/subsonic/playlists.go b/server/subsonic/playlists.go index c58fb9ab9..17ba1b2c9 100644 --- a/server/subsonic/playlists.go +++ b/server/subsonic/playlists.go @@ -62,7 +62,7 @@ func (api *Router) getPlaylist(ctx context.Context, id string) (*responses.Subso func (api *Router) CreatePlaylist(r *http.Request) (*responses.Subsonic, error) { ctx := r.Context() p := req.Params(r) - songIds, _ := p.Strings("songId") + songIds := p.Strings("songId") playlistId, _ := p.String("playlistId") name, _ := p.String("name") if playlistId == "" && name == "" { @@ -99,7 +99,7 @@ func (api *Router) UpdatePlaylist(r *http.Request) (*responses.Subsonic, error) if err != nil { return nil, err } - songsToAdd, _ := p.Strings("songIdToAdd") + songsToAdd := p.Strings("songIdToAdd") songIndexesToRemove, _ := p.Ints("songIndexToRemove") var plsName *string if s, err := p.String("name"); err == nil { diff --git a/server/subsonic/sharing.go b/server/subsonic/sharing.go index a9ccfdca4..540ae79d7 100644 --- a/server/subsonic/sharing.go +++ b/server/subsonic/sharing.go @@ -52,9 +52,9 @@ func (api *Router) buildShare(r *http.Request, share model.Share) responses.Shar func (api *Router) CreateShare(r *http.Request) (*responses.Subsonic, error) { p := req.Params(r) - ids, err := p.Strings("id") - if err != nil { - return nil, err + ids := p.Strings("id") + if len(ids) == 0 { + return nil, newError(responses.ErrorMissingParameter, "missing parameter: 'id'") } description, _ := p.String("description") diff --git a/utils/req/req.go b/utils/req/req.go index 2757fc3f5..861cca9f7 100644 --- a/utils/req/req.go +++ b/utils/req/req.go @@ -60,12 +60,10 @@ func (r *Values) StringOr(param, def string) string { return v } -func (r *Values) Strings(param string) ([]string, error) { - values := r.URL.Query()[param] - if len(values) == 0 { - return nil, newError(ErrMissingParam, param) - } - return values, nil +// Strings returns all occurrences of the param, or a nil (empty) slice when absent. Callers that +// require the param should check for emptiness themselves. +func (r *Values) Strings(param string) []string { + return r.URL.Query()[param] } func (r *Values) TimeOr(param string, def time.Time) time.Time { @@ -85,9 +83,9 @@ func (r *Values) TimeOr(param string, def time.Time) time.Time { } func (r *Values) Times(param string) ([]time.Time, error) { - pStr, err := r.Strings(param) - if err != nil { - return nil, err + pStr := r.Strings(param) + if len(pStr) == 0 { + return nil, newError(ErrMissingParam, param) } times := make([]time.Time, len(pStr)) for i, t := range pStr { @@ -139,9 +137,9 @@ func (r *Values) Int64Or(param string, def int64) int64 { } func (r *Values) Ints(param string) ([]int, error) { - pStr, err := r.Strings(param) - if err != nil { - return nil, err + pStr := r.Strings(param) + if len(pStr) == 0 { + return nil, newError(ErrMissingParam, param) } ints := make([]int, 0, len(pStr)) for _, s := range pStr { diff --git a/utils/req/req_test.go b/utils/req/req_test.go index d76b3b934..5f9de8483 100644 --- a/utils/req/req_test.go +++ b/utils/req/req_test.go @@ -60,9 +60,7 @@ var _ = Describe("Request Helpers", func() { }) It("returns empty array if param does not exist", func() { - v, err := r.Strings("xx") - Expect(err).To(MatchError(req.ErrMissingParam)) - Expect(v).To(BeEmpty()) + Expect(r.Strings("xx")).To(BeEmpty()) }) }) From 59f1b4206c9fd40b10b1bb32938b546c646c8034 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 18 Jul 2026 19:36:12 -0400 Subject: [PATCH 25/71] fix(jellyfin): serve playlist covers regardless of visibility (#5813) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The image endpoint only served a private playlist's cover when the request carried a token identifying its owner or an admin. But clients fetch cover URLs without credentials — real Jellyfin's image routes are anonymous — so every private playlist rendered the generic placeholder in Jellyfin clients (observed in production), while the same covers displayed fine through the always-authenticated Subsonic/native APIs. Drop the gate and serve playlist covers like album/artist/track artwork: playlist ids are unguessable without credentials, so anonymous access does not meaningfully expose private playlist contents. --- server/jellyfin/e2e/images_test.go | 34 ++++++++---------------------- server/jellyfin/images.go | 16 +++++--------- server/jellyfin/images_test.go | 17 ++------------- server/jellyfin/middlewares.go | 2 +- 4 files changed, 17 insertions(+), 52 deletions(-) diff --git a/server/jellyfin/e2e/images_test.go b/server/jellyfin/e2e/images_test.go index 0c53d75f3..7fb85cba8 100644 --- a/server/jellyfin/e2e/images_test.go +++ b/server/jellyfin/e2e/images_test.go @@ -28,9 +28,9 @@ var _ = Describe("Item images", func() { 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. + It("resolves a private playlist's cover under an elevated context", func() { + // The route carries no user in ctx (public); resolution 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)) @@ -48,27 +48,11 @@ var _ = Describe("Item images", func() { 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)) - }) + // Covers are served regardless of playlist visibility — see getItemImage for the rationale. + It("resolves 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)) + Expect(artworkSpy.lastID).To(ContainSubstring(plID)) }) }) diff --git a/server/jellyfin/images.go b/server/jellyfin/images.go index 722e828c5..0ec34f491 100644 --- a/server/jellyfin/images.go +++ b/server/jellyfin/images.go @@ -25,14 +25,13 @@ import ( ) 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. + // Public endpoint, like real Jellyfin's image routes: clients fetch cover URLs without credentials + // and item ids are unguessable, so resolution runs elevated to bypass the visibility filter. 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) + artID := api.resolveArtworkID(ctx, itemId) reader, _, err := api.artwork.GetOrPlaceholder(ctx, artID, size, false) switch { case errors.Is(err, context.Canceled): @@ -49,7 +48,7 @@ func (api *Router) getItemImage(w http.ResponseWriter, r *http.Request) { // 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 { +func (api *Router) resolveArtworkID(ctx context.Context, itemId string) string { if al, err := api.ds.Album(ctx).Get(itemId); err == nil { return al.CoverArtID().String() } @@ -60,12 +59,7 @@ func (api *Router) resolveArtworkID(ctx context.Context, r *http.Request, itemId 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 pl.CoverArtID().String() } return (model.ArtworkID{}).String() } diff --git a/server/jellyfin/images_test.go b/server/jellyfin/images_test.go index 8099fcf94..e435b99fd 100644 --- a/server/jellyfin/images_test.go +++ b/server/jellyfin/images_test.go @@ -85,20 +85,7 @@ var _ = Describe("Images", func() { 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() { + It("resolves a playlist's cover regardless of visibility, even for an anonymous caller", func() { ds := &tests.MockDataStore{} ds.Playlist(context.Background()).(*tests.MockPlaylistRepo).SetData(model.Playlists{{ID: "pl1", Name: "Mix", OwnerID: "someone"}}) fa := &fakeArtwork{} @@ -108,7 +95,7 @@ var _ = Describe("Images", func() { api.getItemImage(w, r) Expect(w.Code).To(Equal(http.StatusOK)) - Expect(fa.recvId).ToNot(ContainSubstring("pl1")) + Expect(fa.recvId).To(ContainSubstring("pl1")) }) // This endpoint is public (no user in the request), so artwork must be resolved under an diff --git a/server/jellyfin/middlewares.go b/server/jellyfin/middlewares.go index 840143ac8..c90f9c088 100644 --- a/server/jellyfin/middlewares.go +++ b/server/jellyfin/middlewares.go @@ -152,7 +152,7 @@ func tokenFromRequest(r *http.Request) string { } // 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. +// or unknown subject. func (api *Router) userFromToken(r *http.Request) (model.User, bool) { token := tokenFromRequest(r) if token == "" { From 7234ea23b76c228385922cc674f7c56a1d55b3f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 18 Jul 2026 20:26:14 -0400 Subject: [PATCH 26/71] feat(jellyfin): expose NormalizationGain from ReplayGain tags (#5815) * feat(jellyfin): expose NormalizationGain from ReplayGain tags Adds NormalizationGain and AlbumNormalizationGain to Audio BaseItemDtos, sourced from the scanner's ReplayGain values (REPLAYGAIN_* tags, with R128_* already converted to the same -18 LUFS reference). Same wire contract as real Jellyfin: PascalCase keys, omitted when absent, no Fields gating. Album items intentionally omit the field: model.Album has no gain column and Feishin reads gain from song DTOs. * test(jellyfin): e2e coverage for NormalizationGain fields * test(jellyfin): drop redundant NormalizationGain passthrough spec The JSON-casing spec already proves the mapped values (the substring "NormalizationGain":-3.5 can only appear if the passthrough worked), so the direct-struct spec added no coverage. * style(jellyfin): use ASCII punctuation in gain comment --- server/jellyfin/dto/dto.go | 30 ++++++++++++++------------- server/jellyfin/dto/mappers.go | 3 +++ server/jellyfin/dto/mappers_test.go | 16 ++++++++++++++ server/jellyfin/e2e/browsing_test.go | 16 ++++++++++++++ server/jellyfin/e2e/e2e_suite_test.go | 4 +++- 5 files changed, 54 insertions(+), 15 deletions(-) diff --git a/server/jellyfin/dto/dto.go b/server/jellyfin/dto/dto.go index 2a817739b..c8f7129cb 100644 --- a/server/jellyfin/dto/dto.go +++ b/server/jellyfin/dto/dto.go @@ -61,20 +61,22 @@ type BaseItemDto struct { 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"` - GenreItems []NameGuidPair `json:"GenreItems,omitempty"` - ChildCount *int `json:"ChildCount,omitempty"` - SongCount *int `json:"SongCount,omitempty"` - AlbumCount *int `json:"AlbumCount,omitempty"` - ImageTags map[string]string `json:"ImageTags,omitempty"` + 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"` + GenreItems []NameGuidPair `json:"GenreItems,omitempty"` + NormalizationGain *float64 `json:"NormalizationGain,omitempty"` + AlbumNormalizationGain *float64 `json:"AlbumNormalizationGain,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. diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go index dc9bc5fbd..bdb0a1e37 100644 --- a/server/jellyfin/dto/mappers.go +++ b/server/jellyfin/dto/mappers.go @@ -171,6 +171,9 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto { if mf.AlbumArtistID != "" { item.AlbumArtists = []NameGuidPair{{Name: mf.AlbumArtist, Id: EncodeID(mf.AlbumArtistID)}} } + // dB to apply at the RG2 -18 LUFS reference, same convention real Jellyfin uses; no conversion. + item.NormalizationGain = mf.RGTrackGain + item.AlbumNormalizationGain = mf.RGAlbumGain if mf.Year > 0 { item.ProductionYear = new(mf.Year) } diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go index 6bfc9d95a..8867a5e73 100644 --- a/server/jellyfin/dto/mappers_test.go +++ b/server/jellyfin/dto/mappers_test.go @@ -125,6 +125,22 @@ var _ = Describe("mappers", func() { Expect(item.AlbumArtists).To(Equal([]NameGuidPair{{Name: "De La Soul", Id: EncodeID("ar-delasoul")}})) }) + It("serializes normalization gains with Jellyfin's exact key casing", func() { + mf := model.MediaFile{ID: "s1", Title: "Song", + RGTrackGain: new(-3.5), RGAlbumGain: new(-4.25)} + b, err := json.Marshal(SongToBaseItem(mf, nil)) + Expect(err).ToNot(HaveOccurred()) + Expect(string(b)).To(ContainSubstring(`"NormalizationGain":-3.5`)) + Expect(string(b)).To(ContainSubstring(`"AlbumNormalizationGain":-4.25`)) + }) + + It("omits normalization gains when the file has no ReplayGain tags", func() { + b, err := json.Marshal(SongToBaseItem(model.MediaFile{ID: "s1", Title: "Song"}, nil)) + Expect(err).ToNot(HaveOccurred()) + // Substring check covers both keys (AlbumNormalizationGain contains NormalizationGain). + Expect(string(b)).ToNot(ContainSubstring("NormalizationGain")) + }) + 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) diff --git a/server/jellyfin/e2e/browsing_test.go b/server/jellyfin/e2e/browsing_test.go index 382ec9406..a0632994e 100644 --- a/server/jellyfin/e2e/browsing_test.go +++ b/server/jellyfin/e2e/browsing_test.go @@ -415,6 +415,22 @@ var _ = Describe("Browsing", func() { Expect(item.AlbumArtists[0].Name).To(Equal("Miles Davis")) }) + It("exposes NormalizationGain and AlbumNormalizationGain from ReplayGain tags", func() { + var item dto.BaseItemDto + parseInto(get("/Items/"+enc(songID("Stairway To Heaven"))), &item) + Expect(item.NormalizationGain).ToNot(BeNil()) + Expect(*item.NormalizationGain).To(BeNumerically("~", -3.5, 0.001)) + Expect(item.AlbumNormalizationGain).ToNot(BeNil()) + Expect(*item.AlbumNormalizationGain).To(BeNumerically("~", -4.25, 0.001)) + }) + + It("omits normalization gains for files without ReplayGain tags", func() { + var item dto.BaseItemDto + parseInto(get("/Items/"+enc(songID("So What"))), &item) + Expect(item.NormalizationGain).To(BeNil()) + Expect(item.AlbumNormalizationGain).To(BeNil()) + }) + It("resolves an artist", func() { var item dto.BaseItemDto parseInto(get("/Items/"+enc(artistID("Miles Davis"))), &item) diff --git a/server/jellyfin/e2e/e2e_suite_test.go b/server/jellyfin/e2e/e2e_suite_test.go index 445566387..5a4c8c031 100644 --- a/server/jellyfin/e2e/e2e_suite_test.go +++ b/server/jellyfin/e2e/e2e_suite_test.go @@ -121,7 +121,9 @@ func buildTestFS() storagetest.FakeFS { "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", _t{ - "lyrics:eng": "[00:01.00]There's a lady who's sure\n[00:05.50]All that glitters is gold", + "lyrics:eng": "[00:01.00]There's a lady who's sure\n[00:05.50]All that glitters is gold", + "replaygain_track_gain": "-3.50 dB", + "replaygain_album_gain": "-4.25 dB", })), "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")), From 4efd92cf835bd7a8e3c971b5b87e834b0f32ee6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 18 Jul 2026 22:19:29 -0400 Subject: [PATCH 27/71] feat(server): expose album-level ReplayGain in albums (#5816) * feat(model): aggregate album ReplayGain in ToAlbum * feat(db): add nullable album ReplayGain columns with backfill * feat(persistence): persist album ReplayGain fields * feat(jellyfin): expose album NormalizationGain from ReplayGain * refactor(model): lazy-init mostFrequentPtr map; clean up RG test rows * fix(db): backfill album ReplayGain with most-frequent value, not max A plain max() picked a minority outlier that diverged from MediaFiles.ToAlbum (which uses the most-frequent value), and GetTouchedAlbums never re-derives an unchanged album, so the wrong value would persist. Reproduce the modal aggregation via grouped CTEs, which also groups media_file once instead of a per-album correlated scan. * refactor(model): return an owned pointer from mostFrequentPtr Avoid aliasing a MediaFile field so the resulting Album is independent of the source slice. --- .../20260719005427_add_album_replaygain.go | 44 +++++++++++++++++++ model/album.go | 2 + model/mediafile.go | 32 ++++++++++++++ model/mediafile_test.go | 29 ++++++++++++ persistence/album_repository.go | 6 +++ persistence/album_repository_test.go | 27 ++++++++++++ server/jellyfin/dto/mappers.go | 3 ++ server/jellyfin/dto/mappers_test.go | 15 +++++++ 8 files changed, 158 insertions(+) create mode 100644 db/migrations/20260719005427_add_album_replaygain.go diff --git a/db/migrations/20260719005427_add_album_replaygain.go b/db/migrations/20260719005427_add_album_replaygain.go new file mode 100644 index 000000000..79c8b8b3e --- /dev/null +++ b/db/migrations/20260719005427_add_album_replaygain.go @@ -0,0 +1,44 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upAddAlbumReplaygain, downAddAlbumReplaygain) +} + +func upAddAlbumReplaygain(ctx context.Context, tx *sql.Tx) error { + // Backfill the most-frequent non-null value per album, matching MediaFiles.ToAlbum. A plain + // max() would pick a minority outlier, and GetTouchedAlbums never re-derives an unchanged album, + // so a wrong value would persist. The CTEs group media_file once instead of scanning it per album. + _, err := tx.ExecContext(ctx, ` +ALTER TABLE album ADD COLUMN rg_album_gain real; +ALTER TABLE album ADD COLUMN rg_album_peak real; + +WITH gain_mode AS ( + SELECT album_id, rg_album_gain AS val, + row_number() OVER (PARTITION BY album_id ORDER BY count(*) DESC, rg_album_gain) AS rn + FROM media_file WHERE rg_album_gain IS NOT NULL + GROUP BY album_id, rg_album_gain +), +peak_mode AS ( + SELECT album_id, rg_album_peak AS val, + row_number() OVER (PARTITION BY album_id ORDER BY count(*) DESC, rg_album_peak) AS rn + FROM media_file WHERE rg_album_peak IS NOT NULL + GROUP BY album_id, rg_album_peak +) +UPDATE album SET + rg_album_gain = (SELECT val FROM gain_mode WHERE gain_mode.album_id = album.id AND gain_mode.rn = 1), + rg_album_peak = (SELECT val FROM peak_mode WHERE peak_mode.album_id = album.id AND peak_mode.rn = 1); + `) + return err +} + +func downAddAlbumReplaygain(ctx context.Context, tx *sql.Tx) error { + // This code is executed when the migration is rolled back. + return nil +} diff --git a/model/album.go b/model/album.go index ade7f6ee0..16727ea39 100644 --- a/model/album.go +++ b/model/album.go @@ -49,6 +49,8 @@ type Album struct { MbzReleaseGroupID string `structs:"mbz_release_group_id" json:"mbzReleaseGroupId,omitempty"` FolderIDs []string `structs:"folder_ids" json:"-" hash:"set"` // All folders that contain media_files for this album ExplicitStatus string `structs:"explicit_status" json:"explicitStatus"` + RGAlbumGain *float64 `structs:"rg_album_gain" json:"rgAlbumGain"` + RGAlbumPeak *float64 `structs:"rg_album_peak" json:"rgAlbumPeak"` // External metadata fields Description string `structs:"description" json:"description,omitempty" hash:"ignore"` diff --git a/model/mediafile.go b/model/mediafile.go index 90db80f71..910cd998d 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -314,6 +314,8 @@ func (mfs MediaFiles) ToAlbum() Album { originalYears := make([]int, 0, len(mfs)) originalDates := make([]string, 0, len(mfs)) releaseDates := make([]string, 0, len(mfs)) + rgAlbumGains := make([]*float64, 0, len(mfs)) + rgAlbumPeaks := make([]*float64, 0, len(mfs)) tags := make(TagList, 0, len(mfs[0].Tags)*len(mfs)) a.Missing = true @@ -344,6 +346,8 @@ func (mfs MediaFiles) ToAlbum() Album { originalYears = append(originalYears, m.OriginalYear) originalDates = append(originalDates, m.OriginalDate) releaseDates = append(releaseDates, m.ReleaseDate) + rgAlbumGains = append(rgAlbumGains, m.RGAlbumGain) + rgAlbumPeaks = append(rgAlbumPeaks, m.RGAlbumPeak) comments = append(comments, m.Comment) mbzAlbumIds = append(mbzAlbumIds, m.MbzAlbumID) mbzReleaseGroupIds = append(mbzReleaseGroupIds, m.MbzReleaseGroupID) @@ -378,6 +382,8 @@ func (mfs MediaFiles) ToAlbum() Album { a.Comment, _ = allOrNothing(comments) a.MbzAlbumID = slice.MostFrequent(mbzAlbumIds) a.MbzReleaseGroupID = slice.MostFrequent(mbzReleaseGroupIds) + a.RGAlbumGain = mostFrequentPtr(rgAlbumGains) + a.RGAlbumPeak = mostFrequentPtr(rgAlbumPeaks) fixAlbumArtist(&a) return a @@ -407,6 +413,32 @@ func minMax(items []int) (int, int) { return mn, mx } +// mostFrequentPtr returns a pointer to the most common non-nil value, or nil if +// none. It counts by dereferenced value so a genuine 0.0 is a real candidate +// (slice.MostFrequent skips the zero value and compares pointers by identity). +func mostFrequentPtr(items []*float64) *float64 { + var counts map[float64]int + var best float64 + var bestCount int + for _, it := range items { + if it == nil { + continue + } + if counts == nil { + counts = map[float64]int{} + } + counts[*it]++ + if counts[*it] > bestCount { + bestCount = counts[*it] + best = *it + } + } + if bestCount == 0 { + return nil + } + return &best +} + func newer(t1, t2 time.Time) time.Time { if t1.After(t2) { return t1 diff --git a/model/mediafile_test.go b/model/mediafile_test.go index c94653530..6085ccf25 100644 --- a/model/mediafile_test.go +++ b/model/mediafile_test.go @@ -268,6 +268,35 @@ var _ = Describe("MediaFiles", func() { }) }) }) + Context("ReplayGain", func() { + It("picks the most frequent non-nil album gain and peak", func() { + mfs := MediaFiles{ + {Path: "a", RGAlbumGain: new(-8.0), RGAlbumPeak: new(0.9)}, + {Path: "b", RGAlbumGain: new(-8.0), RGAlbumPeak: new(0.9)}, + {Path: "c", RGAlbumGain: new(-5.0), RGAlbumPeak: new(1.0)}, + } + album := mfs.ToAlbum() + Expect(album.RGAlbumGain).ToNot(BeNil()) + Expect(*album.RGAlbumGain).To(Equal(-8.0)) + Expect(album.RGAlbumPeak).ToNot(BeNil()) + Expect(*album.RGAlbumPeak).To(Equal(0.9)) + }) + It("keeps a genuine 0.0 gain instead of dropping it", func() { + mfs := MediaFiles{ + {Path: "a", RGAlbumGain: new(0.0)}, + {Path: "b", RGAlbumGain: new(0.0)}, + } + album := mfs.ToAlbum() + Expect(album.RGAlbumGain).ToNot(BeNil()) + Expect(*album.RGAlbumGain).To(Equal(0.0)) + }) + It("leaves gain and peak nil when no track has a value", func() { + mfs := MediaFiles{{Path: "a"}, {Path: "b"}} + album := mfs.ToAlbum() + Expect(album.RGAlbumGain).To(BeNil()) + Expect(album.RGAlbumPeak).To(BeNil()) + }) + }) Context("Participants", func() { var album Album BeforeEach(func() { diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 6ebbd9202..53e8efe32 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -31,6 +31,10 @@ type dbAlbum struct { Participants string `structs:"-" json:"-"` Tags string `structs:"-" json:"-"` FolderIDs string `structs:"-" json:"-"` + // dbx maps columns to fields by name; RGAlbumGain doesn't convert to + // rg_album_gain, so shim fields carry the read and PostScan copies them over. + RgAlbumGain *float64 `structs:"-" json:"-"` + RgAlbumPeak *float64 `structs:"-" json:"-"` } func (a *dbAlbum) PostScan() error { @@ -58,6 +62,8 @@ func (a *dbAlbum) PostScan() error { } a.Album.FolderIDs = ids } + a.Album.RGAlbumGain = a.RgAlbumGain + a.Album.RGAlbumPeak = a.RgAlbumPeak return nil } diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index 64ff0095e..761255b7d 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -890,6 +890,33 @@ var _ = Describe("AlbumRepository", func() { Expect(albums[0].ID).To(Equal("a1")) }) }) + + Describe("ReplayGain", func() { + BeforeEach(func() { + DeferCleanup(func() { + _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": []string{"rg-1", "rg-2"}})) + }) + }) + It("round-trips album ReplayGain gain and peak", func() { + Expect(albumRepo.Put(&model.Album{ + ID: "rg-1", Name: "rg", LibraryID: 1, + RGAlbumGain: new(-7.5), RGAlbumPeak: new(0.98), + })).To(Succeed()) + got, err := albumRepo.Get("rg-1") + Expect(err).ToNot(HaveOccurred()) + Expect(got.RGAlbumGain).ToNot(BeNil()) + Expect(*got.RGAlbumGain).To(Equal(-7.5)) + Expect(got.RGAlbumPeak).ToNot(BeNil()) + Expect(*got.RGAlbumPeak).To(Equal(0.98)) + }) + It("reads nil when ReplayGain is unset", func() { + Expect(albumRepo.Put(&model.Album{ID: "rg-2", Name: "rg2", LibraryID: 1})).To(Succeed()) + got, err := albumRepo.Get("rg-2") + Expect(err).ToNot(HaveOccurred()) + Expect(got.RGAlbumGain).To(BeNil()) + Expect(got.RGAlbumPeak).To(BeNil()) + }) + }) }) func _p(id, name string, sortName ...string) model.Participant { diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go index bdb0a1e37..036dfbd91 100644 --- a/server/jellyfin/dto/mappers.go +++ b/server/jellyfin/dto/mappers.go @@ -232,6 +232,9 @@ func AlbumToBaseItem(al model.Album) BaseItemDto { item.GenreItems = append(item.GenreItems, NameGuidPair{Id: EncodeID(g.ID), Name: g.Name}) } } + // The album's own ReplayGain gain (dB at the RG2 -18 LUFS reference) — same + // convention as tracks; clients read it off the album item as NormalizationGain. + item.NormalizationGain = al.RGAlbumGain return item } diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go index 8867a5e73..c986e7f81 100644 --- a/server/jellyfin/dto/mappers_test.go +++ b/server/jellyfin/dto/mappers_test.go @@ -260,6 +260,21 @@ var _ = Describe("mappers", func() { Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID("1"), Name: "genre 1"}, {Id: EncodeID("2"), Name: "genre 2"}})) }) + It("sets NormalizationGain on the album from its ReplayGain", func() { + al := model.Album{ID: "al1", Name: "Album", RGAlbumGain: new(-6.0)} + b, err := json.Marshal(AlbumToBaseItem(al)) + Expect(err).ToNot(HaveOccurred()) + Expect(string(b)).To(ContainSubstring(`"NormalizationGain":-6`)) + // Real Jellyfin never sets AlbumNormalizationGain on an album item. + Expect(string(b)).ToNot(ContainSubstring("AlbumNormalizationGain")) + }) + + It("omits NormalizationGain when the album has no ReplayGain", func() { + b, err := json.Marshal(AlbumToBaseItem(model.Album{ID: "al1", Name: "Album"})) + Expect(err).ToNot(HaveOccurred()) + Expect(string(b)).ToNot(ContainSubstring("NormalizationGain")) + }) + 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) From b27d6f61ae7813d47eb4479637187bf26ab35adb Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 18 Jul 2026 22:59:53 -0400 Subject: [PATCH 28/71] fix(db): make album ReplayGain backfill scale to large libraries The windowed-CTE backfill was compiled as a correlated scalar subquery re-evaluated once per album (a full media_file group-by + window sort each time), which never finished on a large library. Stage the ReplayGain-bearing rows into an indexed temp table once and update only the affected albums: ~14s on a 727MB / 6945-album library, vs. effectively never. --- .../20260719005427_add_album_replaygain.go | 31 +++++++++---------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/db/migrations/20260719005427_add_album_replaygain.go b/db/migrations/20260719005427_add_album_replaygain.go index 79c8b8b3e..7f5665947 100644 --- a/db/migrations/20260719005427_add_album_replaygain.go +++ b/db/migrations/20260719005427_add_album_replaygain.go @@ -12,28 +12,25 @@ func init() { } func upAddAlbumReplaygain(ctx context.Context, tx *sql.Tx) error { - // Backfill the most-frequent non-null value per album, matching MediaFiles.ToAlbum. A plain - // max() would pick a minority outlier, and GetTouchedAlbums never re-derives an unchanged album, - // so a wrong value would persist. The CTEs group media_file once instead of scanning it per album. + // Backfill the most-frequent value per album (matching MediaFiles.ToAlbum), staging RG-bearing rows + // into an indexed temp table — a correlated subquery over a windowed CTE re-scans media_file per album. _, err := tx.ExecContext(ctx, ` ALTER TABLE album ADD COLUMN rg_album_gain real; ALTER TABLE album ADD COLUMN rg_album_peak real; -WITH gain_mode AS ( - SELECT album_id, rg_album_gain AS val, - row_number() OVER (PARTITION BY album_id ORDER BY count(*) DESC, rg_album_gain) AS rn - FROM media_file WHERE rg_album_gain IS NOT NULL - GROUP BY album_id, rg_album_gain -), -peak_mode AS ( - SELECT album_id, rg_album_peak AS val, - row_number() OVER (PARTITION BY album_id ORDER BY count(*) DESC, rg_album_peak) AS rn - FROM media_file WHERE rg_album_peak IS NOT NULL - GROUP BY album_id, rg_album_peak -) +CREATE TEMP TABLE _rg_backfill AS + SELECT album_id, rg_album_gain, rg_album_peak FROM media_file + WHERE rg_album_gain IS NOT NULL OR rg_album_peak IS NOT NULL; +CREATE INDEX _rg_backfill_album ON _rg_backfill(album_id); + UPDATE album SET - rg_album_gain = (SELECT val FROM gain_mode WHERE gain_mode.album_id = album.id AND gain_mode.rn = 1), - rg_album_peak = (SELECT val FROM peak_mode WHERE peak_mode.album_id = album.id AND peak_mode.rn = 1); + rg_album_gain = (SELECT rg_album_gain FROM _rg_backfill WHERE _rg_backfill.album_id = album.id AND rg_album_gain IS NOT NULL + GROUP BY rg_album_gain ORDER BY count(*) DESC, rg_album_gain LIMIT 1), + rg_album_peak = (SELECT rg_album_peak FROM _rg_backfill WHERE _rg_backfill.album_id = album.id AND rg_album_peak IS NOT NULL + GROUP BY rg_album_peak ORDER BY count(*) DESC, rg_album_peak LIMIT 1) +WHERE album.id IN (SELECT album_id FROM _rg_backfill); + +DROP TABLE _rg_backfill; `) return err } From 5927e693d128cb3c830f6d87b59bfd5128d35dfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 19 Jul 2026 12:39:31 -0400 Subject: [PATCH 29/71] feat(jellyfin): filter items by year and record label (#5817) * feat(persistence): add AlbumRepository.GetYears for distinct album years * test(persistence): verify GetYears de-duplicates repeated years Regression test that adds two albums with the same non-zero max_year (2005) and verifies that GetYears() returns that year exactly once, ensuring the SQL DISTINCT clause is applied correctly. Catches any future removal of DISTINCT from the GetYears query. * feat(jellyfin): add legacy /Items/Filters endpoint (genres + years) * feat(jellyfin): add /Studios endpoint from record label tags * refactor(persistence): drop duplicate columns in tagRepository.GetAll * fix(jellyfin): exclude missing albums from filter years GetYears only filtered max_year > 0, so albums whose files were all removed (missing=true, kept when Scanner.PurgeMissing=never) contributed stale years to /Items/Filters. Filter them out like the normal album listings do. Also return an empty slice from the MockAlbumRepo to match the real repository. * feat(jellyfin): filter /Items by Years= * feat(jellyfin): filter /Items by StudioIds= (record labels) * feat(jellyfin): scope filter and studio lists to ParentId library * refactor(jellyfin): extract parentIDScope and libraryScopeFilter helpers Collapse the three inline resolveLibraryScope(dto.DecodeID(parentid)) call sites and the duplicated empty-scope guard into two small helpers, so the empty-scope=unrestricted contract lives in one place. Reuse the existing names() helper in the Years= e2e test. * feat(jellyfin): expose record labels as album Studios Add a Studios field to the album BaseItemDto, populated from the record-label tags and gated behind Fields=Studios (matching Jellyfin's ItemFields convention). Studio ids reuse the record-label tag identity, so they round-trip with the /Studios list and the StudioIds= filter. Real Jellyfin leaves Studios empty for music; Feishin reads it as the album's record label. --- model/album.go | 1 + model/tag.go | 1 + persistence/album_repository.go | 15 +++++++ persistence/album_repository_test.go | 60 +++++++++++++++++++++++++ persistence/tag_repository.go | 11 ++++- server/filter/filters.go | 25 +++++++++-- server/jellyfin/api.go | 2 + server/jellyfin/browsing.go | 44 +++++++++++++++++- server/jellyfin/browsing_test.go | 48 ++++++++++++++++++++ server/jellyfin/dto/dto.go | 10 +++++ server/jellyfin/dto/filters_test.go | 22 +++++++++ server/jellyfin/dto/mappers.go | 19 +++++++- server/jellyfin/dto/mappers_test.go | 32 ++++++++++--- server/jellyfin/e2e/browsing_test.go | 65 +++++++++++++++++++++++++++ server/jellyfin/e2e/e2e_suite_test.go | 2 +- server/jellyfin/items.go | 42 ++++++++++++++--- server/jellyfin/library.go | 17 +++++++ server/jellyfin/similar.go | 2 +- tests/mock_album_repo.go | 7 +++ tests/mock_data_store.go | 2 +- tests/mock_genre_repo.go | 10 +++-- tests/mock_tag_repo.go | 24 ++++++++++ 22 files changed, 435 insertions(+), 26 deletions(-) create mode 100644 server/jellyfin/dto/filters_test.go create mode 100644 tests/mock_tag_repo.go diff --git a/model/album.go b/model/album.go index 16727ea39..0afeb2760 100644 --- a/model/album.go +++ b/model/album.go @@ -144,6 +144,7 @@ type AlbumRepository interface { Get(id string) (*Album, error) GetAll(...QueryOptions) (Albums, error) GetCursor(...QueryOptions) (AlbumCursor, error) + GetYears(libraryIDs ...int) ([]int, error) // The following methods are used exclusively by the scanner: Touch(ids ...string) error diff --git a/model/tag.go b/model/tag.go index 02ccac05d..1bc011495 100644 --- a/model/tag.go +++ b/model/tag.go @@ -153,6 +153,7 @@ func (t Tags) Add(name TagName, v string) { type TagRepository interface { Add(libraryID int, tags ...Tag) error UpdateCounts() error + GetAll(name TagName, options ...QueryOptions) (TagList, error) } type TagName string diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 53e8efe32..2bb541003 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -3,6 +3,7 @@ package persistence import ( "context" "encoding/json" + "errors" "fmt" "iter" "maps" @@ -262,6 +263,20 @@ func (r *albumRepository) GetCursor(options ...model.QueryOptions) (model.AlbumC return wrapAlbumCursor(cursor), nil } +func (r *albumRepository) GetYears(libraryIDs ...int) ([]int, error) { + cond := And{Gt{"max_year": 0}, Eq{"missing": false}} + if len(libraryIDs) > 0 { + cond = append(cond, Eq{"library_id": libraryIDs}) + } + sq := r.applyLibraryFilter(Select("distinct max_year").From("album").Where(cond).OrderBy("max_year")) + years := []int{} + err := r.queryAllSlice(sq, &years) + if err != nil && !errors.Is(err, model.ErrNotFound) { + return nil, err + } + return years, 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) diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index 761255b7d..465617b10 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -3,6 +3,7 @@ package persistence import ( "errors" "fmt" + "sort" "time" "github.com/Masterminds/squirrel" @@ -851,6 +852,65 @@ var _ = Describe("AlbumRepository", func() { }) }) + Describe("GetYears", func() { + It("returns distinct album years ascending, excluding zero", func() { + years, err := albumRepo.GetYears() + Expect(err).ToNot(HaveOccurred()) + // Sorted ascending, no duplicates, no zero-year entries. + Expect(sort.IsSorted(sort.IntSlice(years))).To(BeTrue()) + Expect(years).ToNot(ContainElement(0)) + for i := 1; i < len(years); i++ { + Expect(years[i]).To(BeNumerically(">", years[i-1])) // strictly increasing = distinct + } + }) + + It("deduplicates repeated years", func() { + // Regression test: verify that DISTINCT is applied in the SQL. + // Insert two albums with the same non-zero max_year (2005). + album1 := &model.Album{LibraryID: 1, ID: "dedup-test-1", Name: "Album 1", MaxYear: 2005} + album2 := &model.Album{LibraryID: 1, ID: "dedup-test-2", Name: "Album 2", MaxYear: 2005} + Expect(albumRepo.Put(album1)).To(Succeed()) + Expect(albumRepo.Put(album2)).To(Succeed()) + DeferCleanup(func() { + _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": []string{"dedup-test-1", "dedup-test-2"}})) + }) + + years, err := albumRepo.GetYears() + Expect(err).ToNot(HaveOccurred()) + + // Count occurrences of 2005 in the result + count := 0 + for _, y := range years { + if y == 2005 { + count++ + } + } + Expect(count).To(Equal(1), "year 2005 should appear exactly once despite two albums having it") + }) + + It("scopes years to the given libraries", func() { + all, err := albumRepo.GetYears() + Expect(err).ToNot(HaveOccurred()) + // A library with no albums yields no years. + scoped, err := albumRepo.GetYears(99999) + Expect(err).ToNot(HaveOccurred()) + Expect(scoped).To(BeEmpty()) + Expect(all).ToNot(BeEmpty()) + }) + + It("excludes years that belong only to missing albums", func() { + gone := &model.Album{LibraryID: 1, ID: "missing-year-1", Name: "Gone", MaxYear: 1911, Missing: true} + Expect(albumRepo.Put(gone)).To(Succeed()) + DeferCleanup(func() { + _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": "missing-year-1"})) + }) + + years, err := albumRepo.GetYears() + Expect(err).ToNot(HaveOccurred()) + Expect(years).ToNot(ContainElement(1911)) + }) + }) + Describe("wrapAlbumCursor", func() { It("does not panic when the cursor yields a dbAlbum with nil Album", func() { // Simulate what queryWithStableResults does on the rows.Err() path: diff --git a/persistence/tag_repository.go b/persistence/tag_repository.go index 5bb8b3832..f2093c9d8 100644 --- a/persistence/tag_repository.go +++ b/persistence/tag_repository.go @@ -74,13 +74,20 @@ DO UPDATE SET %[1]s_count = excluded.%[1]s_count; return nil } +func (r *tagRepository) GetAll(name model.TagName, options ...model.QueryOptions) (model.TagList, error) { + sq := r.newSelect(options...).Where(Eq{"tag.tag_name": name}) + res := model.TagList{} + err := r.queryAll(sq, &res) + return res, err +} + func (r *tagRepository) purgeUnused() error { - del := Delete(r.tableName).Where(` + del := Delete(r.tableName).Where(` id not in (select jt.value from album left join json_tree(album.tags, '$') as jt where atom is not null and key = 'id' - UNION + UNION select jt.value from media_file left join json_tree(media_file.tags, '$') as jt where atom is not null diff --git a/server/filter/filters.go b/server/filter/filters.go index 5fa53daba..067f7b1f9 100644 --- a/server/filter/filters.go +++ b/server/filter/filters.go @@ -194,6 +194,16 @@ func ByAlbumID(albumIds []string) Sqlizer { return Eq{"album_id": albumIds} } +// AlbumsByYears matches albums whose production year (max_year) is in years. +func AlbumsByYears(years []int) Sqlizer { + return Eq{"max_year": years} +} + +// SongsByYears matches media files whose year is in years. +func SongsByYears(years []int) Sqlizer { + return Eq{"year": years} +} + // 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 { @@ -204,10 +214,17 @@ func ArtistsByGenreID(genreIds []string) Sqlizer { ) } -// 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}) +// tagIDFilter builds an EXISTS over the given tag role's entries in the tags JSON, matching each +// entry against cond (its name via Like, or its tag id via Eq/IN). +func tagIDFilter(tagName string, cond Sqlizer) Sqlizer { + return persistence.Exists(`json_tree(tags, "$.`+tagName+`")`, And{NotEq{"atom": nil}, cond}) +} + +func genreTagFilter(cond Sqlizer) Sqlizer { return tagIDFilter("genre", cond) } + +// ByStudioID matches items (albums or songs) whose record-label tag id is in ids. +func ByStudioID(ids []string) Sqlizer { + return tagIDFilter("recordlabel", Eq{"value": ids}) } func filterByGenre(genre string) Sqlizer { diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go index 0f79011c7..1f46c08b4 100644 --- a/server/jellyfin/api.go +++ b/server/jellyfin/api.go @@ -146,6 +146,8 @@ func (api *Router) routes() http.Handler { r.Get("/items/{itemId}/instantmix", api.getInstantMix) r.Get("/genres", api.getGenres) r.Get("/musicgenres", api.getGenres) + r.Get("/studios", api.getStudios) + r.Get("/items/filters", api.getQueryFiltersLegacy) r.Post("/playlists", api.createPlaylist) r.Get("/playlists/{playlistId}", api.getPlaylist) diff --git a/server/jellyfin/browsing.go b/server/jellyfin/browsing.go index fae21fc0b..29fe48c2b 100644 --- a/server/jellyfin/browsing.go +++ b/server/jellyfin/browsing.go @@ -6,6 +6,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server/jellyfin/dto" "github.com/navidrome/navidrome/utils/req" + "github.com/navidrome/navidrome/utils/slice" ) // getArtists handles GET /Artists (performing artists, Finamp's "Artists" tab); getAlbumArtists @@ -27,7 +28,7 @@ func (api *Router) listArtistsByRole(w http.ResponseWriter, r *http.Request, rol 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", ""))) + scopeIDs, _ := parentIDScope(ctx, r) // 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. q := itemsQuery{ @@ -59,3 +60,44 @@ func (api *Router) getGenres(w http.ResponseWriter, r *http.Request) { } api.ok(w, r, res) } + +// getStudios handles GET /Studios, exposing record labels (Jellyfin's audio "studio" source) as +// Studio items, scoped to ParentId's library when accessible. +func (api *Router) getStudios(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + p := req.Params(r) + scope, _ := parentIDScope(ctx, r) + opts := model.QueryOptions{Sort: "tag_value", Filters: libraryScopeFilter(scope)} + labels, err := api.ds.Tag(ctx).GetAll(model.TagRecordLabel, opts) + if err != nil { + api.internalError(w, r, err) + return + } + items := slice.Map(labels, dto.StudioToBaseItem) + offset, max := p.IntOr("startindex", 0), p.IntOr("limit", 0) + api.ok(w, r, result(paginate(items, offset, max), len(items), offset)) +} + +// getQueryFiltersLegacy handles GET /Items/Filters. Genres and Years are scoped to ParentId's +// library when accessible. Tags/OfficialRatings have no music source, so they are always empty. +func (api *Router) getQueryFiltersLegacy(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + scope, _ := parentIDScope(ctx, r) + genreOpts := model.QueryOptions{Sort: "name", Filters: libraryScopeFilter(scope)} + genres, err := api.ds.Genre(ctx).GetAll(genreOpts) + if err != nil { + api.internalError(w, r, err) + return + } + years, err := api.ds.Album(ctx).GetYears(scope...) + if err != nil { + api.internalError(w, r, err) + return + } + api.ok(w, r, dto.QueryFiltersLegacy{ + Genres: slice.Map(genres, func(g model.Genre) string { return g.Name }), + Tags: []string{}, + OfficialRatings: []string{}, + Years: years, + }) +} diff --git a/server/jellyfin/browsing_test.go b/server/jellyfin/browsing_test.go index 660e5d293..e346b56cb 100644 --- a/server/jellyfin/browsing_test.go +++ b/server/jellyfin/browsing_test.go @@ -175,4 +175,52 @@ var _ = Describe("Browsing", func() { Expect(w.Code).To(Equal(http.StatusOK)) }) }) + + Describe("getStudios", func() { + It("scopes results to the user's accessible libraries", func() { + tagRepo := ds.Tag(context.Background()).(*tests.MockTagRepo) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Studios", nil).WithContext(ctxUser(model.Libraries{{ID: 1}, {ID: 2}})) + invoke(api.getStudios, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, args, err := tagRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).To(ContainSubstring("library_tag.library_id")) + Expect(args).To(ContainElements(1, 2)) + }) + + // An empty scope (admin, or a non-admin with no explicit library grants) must be treated + // as unrestricted, matching accessibleLibraryIDs' documented contract, not as "match nothing". + It("does not restrict results for an admin user", func() { + tagRepo := ds.Tag(context.Background()).(*tests.MockTagRepo) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Studios", nil).WithContext(ctxAdmin()) + invoke(api.getStudios, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(tagRepo.Options.Filters).To(BeNil()) + }) + }) + + Describe("getQueryFiltersLegacy", func() { + It("scopes genres to the user's accessible libraries", func() { + genreRepo := ds.Genre(context.Background()).(*tests.MockedGenreRepo) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/Filters", nil).WithContext(ctxUser(model.Libraries{{ID: 1}, {ID: 2}})) + invoke(api.getQueryFiltersLegacy, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, args, err := genreRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).To(ContainSubstring("library_tag.library_id")) + Expect(args).To(ContainElements(1, 2)) + }) + + It("does not restrict genres for an admin user", func() { + genreRepo := ds.Genre(context.Background()).(*tests.MockedGenreRepo) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/Filters", nil).WithContext(ctxAdmin()) + invoke(api.getQueryFiltersLegacy, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(genreRepo.Options.Filters).To(BeNil()) + }) + }) }) diff --git a/server/jellyfin/dto/dto.go b/server/jellyfin/dto/dto.go index c8f7129cb..9b2c35c69 100644 --- a/server/jellyfin/dto/dto.go +++ b/server/jellyfin/dto/dto.go @@ -71,6 +71,7 @@ type BaseItemDto struct { ArtistItems []NameGuidPair `json:"ArtistItems,omitempty"` Genres []string `json:"Genres,omitempty"` GenreItems []NameGuidPair `json:"GenreItems,omitempty"` + Studios []NameGuidPair `json:"Studios,omitempty"` NormalizationGain *float64 `json:"NormalizationGain,omitempty"` AlbumNormalizationGain *float64 `json:"AlbumNormalizationGain,omitempty"` ChildCount *int `json:"ChildCount,omitempty"` @@ -288,3 +289,12 @@ type LyricLineCue struct { Start int64 `json:"Start"` End *int64 `json:"End,omitempty"` } + +// QueryFiltersLegacy is the response for GET /Items/Filters. All four lists are always present; +// clients (jellyfin-web) render each unconditionally. +type QueryFiltersLegacy struct { + Genres []string `json:"Genres"` + Tags []string `json:"Tags"` + OfficialRatings []string `json:"OfficialRatings"` + Years []int `json:"Years"` +} diff --git a/server/jellyfin/dto/filters_test.go b/server/jellyfin/dto/filters_test.go new file mode 100644 index 000000000..ad3367264 --- /dev/null +++ b/server/jellyfin/dto/filters_test.go @@ -0,0 +1,22 @@ +package dto + +import ( + "encoding/json" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("QueryFiltersLegacy", func() { + It("marshals all four keys, empty ones as [] not null", func() { + b, err := json.Marshal(QueryFiltersLegacy{ + Genres: []string{"Rock"}, Tags: []string{}, OfficialRatings: []string{}, Years: []int{1999}, + }) + Expect(err).ToNot(HaveOccurred()) + j := string(b) + Expect(j).To(ContainSubstring(`"Genres":["Rock"]`)) + Expect(j).To(ContainSubstring(`"Tags":[]`)) + Expect(j).To(ContainSubstring(`"OfficialRatings":[]`)) + Expect(j).To(ContainSubstring(`"Years":[1999]`)) + }) +}) diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go index 036dfbd91..444a25cac 100644 --- a/server/jellyfin/dto/mappers.go +++ b/server/jellyfin/dto/mappers.go @@ -200,7 +200,7 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto { return item } -func AlbumToBaseItem(al model.Album) BaseItemDto { +func AlbumToBaseItem(al model.Album, fields Fields) BaseItemDto { item := BaseItemDto{ Name: al.Name, Id: EncodeID(al.ID), @@ -232,6 +232,14 @@ func AlbumToBaseItem(al model.Album) BaseItemDto { item.GenreItems = append(item.GenreItems, NameGuidPair{Id: EncodeID(g.ID), Name: g.Name}) } } + // Jellyfin leaves Studios empty for music; we expose record labels here to match our /Studios + // list and StudioIds= filter, so a client can display and click through to filter by label. + if fields.Has("Studios") { + for _, label := range al.Tags.Values(model.TagRecordLabel) { + id := EncodeID(model.NewTag(model.TagRecordLabel, label).ID) + item.Studios = append(item.Studios, NameGuidPair{Name: label, Id: id}) + } + } // The album's own ReplayGain gain (dB at the RG2 -18 LUFS reference) — same // convention as tracks; clients read it off the album item as NormalizationGain. item.NormalizationGain = al.RGAlbumGain @@ -264,6 +272,15 @@ func GenreToBaseItem(g model.Genre) BaseItemDto { } } +func StudioToBaseItem(t model.Tag) BaseItemDto { + return BaseItemDto{ + Name: t.TagValue, + Id: EncodeID(t.ID), + Type: "Studio", + 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. diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go index c986e7f81..c11f38eaf 100644 --- a/server/jellyfin/dto/mappers_test.go +++ b/server/jellyfin/dto/mappers_test.go @@ -244,7 +244,7 @@ var _ = Describe("mappers", func() { 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, Genres: []model.Genre{{ID: "1", Name: "genre 1"}, {ID: "2", Name: "genre 2"}}} - item := AlbumToBaseItem(al) + item := AlbumToBaseItem(al, nil) Expect(item.Type).To(Equal("MusicAlbum")) Expect(item.IsFolder).To(BeTrue()) Expect(item.Id).To(Equal(EncodeID("alb-1"))) @@ -260,9 +260,22 @@ var _ = Describe("mappers", func() { Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID("1"), Name: "genre 1"}, {Id: EncodeID("2"), Name: "genre 2"}})) }) + It("populates album Studios from record-label tags only when Fields=Studios", func() { + al := model.Album{ID: "alb-2", Name: "Alb2"} + al.Tags = model.Tags{model.TagRecordLabel: []string{"Columbia", "Legacy"}} + + Expect(AlbumToBaseItem(al, nil).Studios).To(BeEmpty()) + + item := AlbumToBaseItem(al, ParseFields("Studios")) + Expect(item.Studios).To(Equal([]NameGuidPair{ + {Name: "Columbia", Id: EncodeID(model.NewTag(model.TagRecordLabel, "Columbia").ID)}, + {Name: "Legacy", Id: EncodeID(model.NewTag(model.TagRecordLabel, "Legacy").ID)}, + })) + }) + It("sets NormalizationGain on the album from its ReplayGain", func() { al := model.Album{ID: "al1", Name: "Album", RGAlbumGain: new(-6.0)} - b, err := json.Marshal(AlbumToBaseItem(al)) + b, err := json.Marshal(AlbumToBaseItem(al, nil)) Expect(err).ToNot(HaveOccurred()) Expect(string(b)).To(ContainSubstring(`"NormalizationGain":-6`)) // Real Jellyfin never sets AlbumNormalizationGain on an album item. @@ -270,7 +283,7 @@ var _ = Describe("mappers", func() { }) It("omits NormalizationGain when the album has no ReplayGain", func() { - b, err := json.Marshal(AlbumToBaseItem(model.Album{ID: "al1", Name: "Album"})) + b, err := json.Marshal(AlbumToBaseItem(model.Album{ID: "al1", Name: "Album"}, nil)) Expect(err).ToNot(HaveOccurred()) Expect(string(b)).ToNot(ContainSubstring("NormalizationGain")) }) @@ -293,6 +306,13 @@ var _ = Describe("mappers", func() { Expect(item.Name).To(Equal("Rock")) }) + It("maps a tag to a Studio BaseItemDto", func() { + item := StudioToBaseItem(model.Tag{ID: "t1", TagValue: "Blue Note"}) + Expect(item.Type).To(Equal("Studio")) + Expect(item.Name).To(Equal("Blue Note")) + Expect(item.Id).To(Equal(EncodeID("t1"))) + }) + Describe("premiereDate", func() { // Finamp re-sorts "Latest Releases" client-side by PremiereDate; absent values sort arbitrarily. It("serializes a full date", func() { @@ -321,9 +341,9 @@ var _ = Describe("mappers", func() { }) 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()) + Expect(*AlbumToBaseItem(model.Album{ID: "a1", Date: "2013-09-06"}, nil).PremiereDate).To(Equal("2013-09-06T00:00:00Z")) + Expect(*AlbumToBaseItem(model.Album{ID: "a2", MaxYear: 2013}, nil).PremiereDate).To(Equal("2013-01-01T00:00:00Z")) + Expect(AlbumToBaseItem(model.Album{ID: "a3"}, nil).PremiereDate).To(BeNil()) }) }) diff --git a/server/jellyfin/e2e/browsing_test.go b/server/jellyfin/e2e/browsing_test.go index a0632994e..be6d6bb5d 100644 --- a/server/jellyfin/e2e/browsing_test.go +++ b/server/jellyfin/e2e/browsing_test.go @@ -2,6 +2,7 @@ package e2e import ( "net/http" + "sort" "time" "github.com/navidrome/navidrome/server/jellyfin/dto" @@ -210,6 +211,44 @@ var _ = Describe("Browsing", func() { }) }) + Describe("year filtering (Years=)", func() { + It("filters items by Years=", func() { + albums := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&Years=1959")) + Expect(names(albums.Items)).To(ConsistOf("Kind of Blue")) + + songs := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&Years=1959")) + for _, it := range songs.Items { + Expect(it.ProductionYear).ToNot(BeNil()) + Expect(*it.ProductionYear).To(Equal(1959)) + } + Expect(songs.Items).ToNot(BeEmpty()) + }) + }) + + Describe("studio filtering (StudioIds=)", func() { + It("filters items by StudioIds=", func() { + studios := queryResult(get("/Studios")) + var columbiaID string + for _, it := range studios.Items { + if it.Name == "Columbia" { + columbiaID = it.Id + } + } + Expect(columbiaID).ToNot(BeEmpty()) + + albums := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&StudioIds=" + columbiaID)) + Expect(names(albums.Items)).To(ConsistOf("Kind of Blue")) + }) + + It("returns filter lists scoped to a ParentId library", func() { + var filters dto.QueryFiltersLegacy + parseInto(get("/Items/Filters?ParentId="+enc("1")+"&IncludeItemTypes=Audio&Recursive=true"), &filters) + Expect(filters.Years).To(ContainElements(1959, 1965)) + studios := queryResult(get("/Studios?ParentId=" + enc("1"))) + Expect(names(studios.Items)).To(ContainElement("Columbia")) + }) + }) + // Finamp's genre screen sends ParentId= (scoping) plus GenreIds=. Describe("genre filtering (GenreIds)", func() { lib1 := enc("1") @@ -482,5 +521,31 @@ var _ = Describe("Browsing", func() { Expect(q.Items).To(HaveLen(1)) Expect(q.TotalRecordCount).To(Equal(3)) }) + + It("returns record labels as Studio items", func() { + q := queryResult(get("/Studios")) + names := make([]string, 0, len(q.Items)) + for _, it := range q.Items { + Expect(it.Type).To(Equal("Studio")) + names = append(names, it.Name) + } + Expect(names).To(ContainElement("Columbia")) + }) + }) + + Describe("GET /Items/Filters", func() { + It("returns legacy query filters with genres, years, and empty tags/ratings", func() { + var filters dto.QueryFiltersLegacy + parseInto(get("/Items/Filters?IncludeItemTypes=Audio&Recursive=true"), &filters) + Expect(filters.Genres).To(ContainElements("Rock", "Jazz")) + Expect(filters.Years).To(ContainElements(1959, 1965, 1969, 1971)) + // Verify ascending sort by checking it equals itself sorted. + sorted := make([]int, len(filters.Years)) + copy(sorted, filters.Years) + sort.Ints(sorted) + Expect(filters.Years).To(Equal(sorted)) + Expect(filters.Tags).To(BeEmpty()) + Expect(filters.OfficialRatings).To(BeEmpty()) + }) }) }) diff --git a/server/jellyfin/e2e/e2e_suite_test.go b/server/jellyfin/e2e/e2e_suite_test.go index 5a4c8c031..31d98d1d9 100644 --- a/server/jellyfin/e2e/e2e_suite_test.go +++ b/server/jellyfin/e2e/e2e_suite_test.go @@ -111,7 +111,7 @@ 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"}) + kindOfBlue := template(_t{"albumartist": "Miles Davis", "artist": "Miles Davis", "album": "Kind of Blue", "year": 1959, "genre": "Jazz", "label": "Columbia"}) singles := template(_t{"albumartist": "Solo Artist", "artist": "Solo Artist", "album": "Singles", "year": 2020, "genre": "Pop"}) return harness.CreateFS(fstest.MapFS{ diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go index c3c217cfc..7cf2a17ba 100644 --- a/server/jellyfin/items.go +++ b/server/jellyfin/items.go @@ -222,6 +222,8 @@ type itemsQuery struct { contributingOnly bool genreIds []string albumIds []string + years []int + studioIds []string } // parseItemsQuery also resolves the entity types (inferring them from the parent when @@ -245,7 +247,9 @@ func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) itemsQu // Finamp's genre screen sends ParentId= for scoping plus GenreIds for the genre. genreIds: decodedQueryIDs(r, "genreids"), // Feishin fetches an album's tracks with AlbumIds instead of ParentId. - albumIds: decodedQueryIDs(r, "albumids"), + albumIds: decodedQueryIDs(r, "albumids"), + years: parseYears(r), + studioIds: decodedQueryIDs(r, "studioids"), } // An artist's page filters by artist, not ParentId: Finamp sends ParentId= for scoping // plus AlbumArtistIds/ArtistIds/contributingArtistIds for the artist. @@ -414,6 +418,17 @@ func decodedQueryIDs(r *http.Request, key string) []string { return slice.Map(queryIDs(r, key), dto.DecodeID) } +// parseYears reads Years= as a discrete list, accepting comma-separated and repeated params. +func parseYears(r *http.Request) []int { + var years []int + for _, v := range queryIDs(r, "years") { + if y, err := strconv.Atoi(v); err == nil && y > 0 { + years = append(years, y) + } + } + return years +} + // 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 { @@ -481,6 +496,7 @@ func searchPage[S ~[]E, E any](opts model.QueryOptions, search func(model.QueryO } func (api *Router) listAlbums(ctx context.Context, opts model.QueryOptions, q itemsQuery) (itemsResult, error) { + toItem := func(al model.Album) dto.BaseItemDto { return dto.AlbumToBaseItem(al, q.fields) } repo := api.ds.Album(ctx) filters := squirrel.And{} // For albums, ParentId (browse an artist) and AlbumArtistIds/ArtistIds both mean "this artist's @@ -496,6 +512,12 @@ func (api *Router) listAlbums(ctx context.Context, opts model.QueryOptions, q it if len(q.genreIds) > 0 { filters = append(filters, filter.ByGenreID(q.genreIds)) } + if len(q.years) > 0 { + filters = append(filters, filter.AlbumsByYears(q.years)) + } + if len(q.studioIds) > 0 { + filters = append(filters, filter.ByStudioID(q.studioIds)) + } if q.favOnly { filters = append(filters, filter.ByStarred().Filters) } @@ -509,12 +531,12 @@ func (api *Router) listAlbums(ctx context.Context, opts model.QueryOptions, q it if err != nil { return itemsResult{}, err } - return materialized(result(slice.Map(albums, dto.AlbumToBaseItem), total, opts.Offset)), nil + return materialized(result(slice.Map(albums, toItem), total, opts.Offset)), nil } total, _ := repo.CountAll(model.QueryOptions{Filters: opts.Filters}) open := streamCursor(func() (func(func(model.Album, error) bool), error) { return repo.GetCursor(opts) - }, dto.AlbumToBaseItem) + }, toItem) return streamed(open, int(total), opts.Offset), nil } @@ -537,6 +559,12 @@ func (api *Router) listSongs(ctx context.Context, opts model.QueryOptions, q ite if len(q.genreIds) > 0 { filters = append(filters, filter.ByGenreID(q.genreIds)) } + if len(q.years) > 0 { + filters = append(filters, filter.SongsByYears(q.years)) + } + if len(q.studioIds) > 0 { + filters = append(filters, filter.ByStudioID(q.studioIds)) + } if q.favOnly { filters = append(filters, filter.ByStarred().Filters) } @@ -665,7 +693,7 @@ func (api *Router) resolveItemByID(ctx context.Context, id string, fields dto.Fi if !u.HasLibraryAccess(al.LibraryID) { return dto.BaseItemDto{}, false } - return dto.AlbumToBaseItem(*al), true + return dto.AlbumToBaseItem(*al, fields), 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 @@ -754,13 +782,15 @@ func (api *Router) deleteItem(w http.ResponseWriter, r *http.Request) { // /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() + p := req.Params(r) + fields := dto.ParseFields(p.Strings("fields")...) opts := filter.AlbumsByNewest() - opts.Max = req.Params(r).IntOr("limit", 20) + opts.Max = p.IntOr("limit", 20) opts = filter.ApplyLibraryFilter(opts, accessibleLibraryIDs(ctx)) repo := api.ds.Album(ctx) open := streamCursor(func() (func(func(model.Album, error) bool), error) { return repo.GetCursor(opts) - }, dto.AlbumToBaseItem) + }, func(al model.Album) dto.BaseItemDto { return dto.AlbumToBaseItem(al, fields) }) api.writeItemsArray(w, r, streamed(open, 0, 0)) } diff --git a/server/jellyfin/library.go b/server/jellyfin/library.go index 2e486c36f..2830f1105 100644 --- a/server/jellyfin/library.go +++ b/server/jellyfin/library.go @@ -2,11 +2,14 @@ package jellyfin import ( "context" + "net/http" "strconv" + "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/jellyfin/dto" + "github.com/navidrome/navidrome/utils/req" ) // accessibleLibraryIDs returns the ids of the libraries the current user can access. An empty @@ -30,6 +33,20 @@ func resolveLibraryScope(ctx context.Context, parentId string) (scopeIDs []int, return accessibleLibraryIDs(ctx), false } +// parentIDScope resolves the request's ParentId param to a library scope (see resolveLibraryScope). +func parentIDScope(ctx context.Context, r *http.Request) (scopeIDs []int, isLibraryParent bool) { + return resolveLibraryScope(ctx, dto.DecodeID(req.Params(r).StringOr("parentid", ""))) +} + +// libraryScopeFilter restricts a tag query to the given library scope. Empty scope means +// unrestricted (see accessibleLibraryIDs), so it returns nil rather than an impossible IN (). +func libraryScopeFilter(scope []int) squirrel.Sqlizer { + if len(scope) == 0 { + return nil + } + return squirrel.Eq{"library_tag.library_id": scope} +} + // 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 { diff --git a/server/jellyfin/similar.go b/server/jellyfin/similar.go index 085080850..661fb95cc 100644 --- a/server/jellyfin/similar.go +++ b/server/jellyfin/similar.go @@ -177,7 +177,7 @@ func (api *Router) similarAlbums(ctx context.Context, id string, limit int) dto. } 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)) + items = append(items, dto.AlbumToBaseItem(*al, nil)) if len(items) >= limit { break } diff --git a/tests/mock_album_repo.go b/tests/mock_album_repo.go index 03dfed879..cc7d66b4d 100644 --- a/tests/mock_album_repo.go +++ b/tests/mock_album_repo.go @@ -209,4 +209,11 @@ func (m *MockAlbumRepo) SetStar(starred bool, itemIDs ...string) error { return nil } +func (m *MockAlbumRepo) GetYears(libraryIDs ...int) ([]int, error) { + if m.Err { + return nil, errors.New("error") + } + return []int{}, nil +} + var _ model.AlbumRepository = (*MockAlbumRepo)(nil) diff --git a/tests/mock_data_store.go b/tests/mock_data_store.go index 754f0c084..e016a28de 100644 --- a/tests/mock_data_store.go +++ b/tests/mock_data_store.go @@ -65,7 +65,7 @@ func (db *MockDataStore) Tag(ctx context.Context) model.TagRepository { if db.RealDS != nil { return db.RealDS.Tag(ctx) } - db.MockedTag = struct{ model.TagRepository }{} + db.MockedTag = &MockTagRepo{} return db.MockedTag } diff --git a/tests/mock_genre_repo.go b/tests/mock_genre_repo.go index 122ccc278..50796efc0 100644 --- a/tests/mock_genre_repo.go +++ b/tests/mock_genre_repo.go @@ -5,8 +5,9 @@ import ( ) type MockedGenreRepo struct { - Error error - Data map[string]model.Genre + Error error + Data map[string]model.Genre + Options model.QueryOptions } func (r *MockedGenreRepo) init() { @@ -15,7 +16,10 @@ func (r *MockedGenreRepo) init() { } } -func (r *MockedGenreRepo) GetAll(...model.QueryOptions) (model.Genres, error) { +func (r *MockedGenreRepo) GetAll(options ...model.QueryOptions) (model.Genres, error) { + if len(options) > 0 { + r.Options = options[0] + } if r.Error != nil { return nil, r.Error } diff --git a/tests/mock_tag_repo.go b/tests/mock_tag_repo.go new file mode 100644 index 000000000..a59035ea6 --- /dev/null +++ b/tests/mock_tag_repo.go @@ -0,0 +1,24 @@ +package tests + +import ( + "github.com/navidrome/navidrome/model" +) + +// MockTagRepo records the QueryOptions passed to GetAll, mirroring MockArtistRepo, so tests can +// assert on which filters a caller attached (e.g. a library scope). +type MockTagRepo struct { + model.TagRepository + Data model.TagList + Options model.QueryOptions + Err error +} + +func (r *MockTagRepo) GetAll(_ model.TagName, options ...model.QueryOptions) (model.TagList, error) { + if len(options) > 0 { + r.Options = options[0] + } + if r.Err != nil { + return nil, r.Err + } + return r.Data, nil +} From e6597398c2f9834f3376ee041b135d26bfab27e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 19 Jul 2026 13:36:05 -0400 Subject: [PATCH 30/71] feat(scrobbler): exponential backoff for scrobble retries during outages (#5818) * feat(scrobbler): add exponential backoff delay helper * feat(scrobbler): back off retries up to 4m during outages * test(scrobbler): verify backoff schedule with synctest; clarify backoffDelay doc Adds a testing/synctest-based test that drives the real run loop against a failing service and asserts the exact 5s/10s/20s/40s retry schedule and the drain-on-recovery reset, addressing the review note that the run loop's behavior was untested. Also clarifies the backoffDelay doc comment: the argument is a zero-based retry index. --- core/scrobbler/buffered_scrobbler.go | 42 +++++++++-- core/scrobbler/buffered_scrobbler_test.go | 91 +++++++++++++++++++++++ 2 files changed, 128 insertions(+), 5 deletions(-) diff --git a/core/scrobbler/buffered_scrobbler.go b/core/scrobbler/buffered_scrobbler.go index 408ab410d..38ea83228 100644 --- a/core/scrobbler/buffered_scrobbler.go +++ b/core/scrobbler/buffered_scrobbler.go @@ -10,6 +10,30 @@ import ( "github.com/navidrome/navidrome/model/request" ) +const ( + minRetryDelay = 5 * time.Second + maxRetryDelay = 4 * time.Minute + // maxRetryShift caps the exponent so the shift never overflows int64. + // minRetryDelay<<6 = 320s already exceeds maxRetryDelay, so 6 reaches the ceiling. + maxRetryShift = 6 +) + +// backoffDelay returns the delay for a zero-based retry index (0 = first retry): +// minRetryDelay doubled per prior failure, clamped to maxRetryDelay. +func backoffDelay(failures int) time.Duration { + if failures < 0 { + failures = 0 + } + if failures >= maxRetryShift { + return maxRetryDelay + } + d := minRetryDelay << failures + if d > maxRetryDelay { + return maxRetryDelay + } + return d +} + // Loader is a function that loads a scrobbler by name. // It returns the scrobbler and true if found, or nil and false if not available. // This allows the buffered scrobbler to always get the current plugin instance. @@ -98,15 +122,23 @@ func (b *bufferedScrobbler) sendWakeSignal() { } func (b *bufferedScrobbler) run(ctx context.Context) { + timer := time.NewTimer(time.Hour) + timer.Stop() + defer timer.Stop() + failures := 0 for { - if !b.processQueue(ctx) { - time.AfterFunc(5*time.Second, func() { - b.sendWakeSignal() - }) + if b.processQueue(ctx) { + failures = 0 + timer.Stop() + } else { + timer.Reset(backoffDelay(failures)) + if failures < maxRetryShift { + failures++ + } } select { case <-b.wakeSignal: - continue + case <-timer.C: case <-ctx.Done(): return } diff --git a/core/scrobbler/buffered_scrobbler_test.go b/core/scrobbler/buffered_scrobbler_test.go index d11f0b003..c250085ef 100644 --- a/core/scrobbler/buffered_scrobbler_test.go +++ b/core/scrobbler/buffered_scrobbler_test.go @@ -2,6 +2,9 @@ package scrobbler import ( "context" + "sync/atomic" + "testing" + "testing/synctest" "time" "github.com/navidrome/navidrome/model" @@ -100,3 +103,91 @@ var _ = Describe("BufferedScrobbler", func() { } }) }) + +var _ = Describe("backoffDelay", func() { + DescribeTable("computes the exponential backoff curve clamped to the ceiling", + func(failures int, expected time.Duration) { + Expect(backoffDelay(failures)).To(Equal(expected)) + }, + Entry("first failure", 0, 5*time.Second), + Entry("second failure", 1, 10*time.Second), + Entry("third failure", 2, 20*time.Second), + Entry("fourth failure", 3, 40*time.Second), + Entry("fifth failure", 4, 80*time.Second), + Entry("sixth failure", 5, 160*time.Second), + Entry("reaches the ceiling", 6, 4*time.Minute), + Entry("stays clamped past the ceiling", 7, 4*time.Minute), + Entry("stays clamped for large values", 1000, 4*time.Minute), + Entry("negative is treated as zero", -1, 5*time.Second), + ) +}) + +// Drives the real run loop and asserts the exact retry schedule + recovery. Plain +// test: testing/synctest's fake clock needs a *testing.T, which Ginkgo doesn't give. +func TestBufferedScrobblerBackoffSchedule(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + g := NewWithT(t) + buffer := tests.CreateMockedScrobbleBufferRepo() + userRepo := tests.CreateMockUserRepo() + g.Expect(userRepo.Put(&model.User{ID: "user1", UserName: "alice"})).To(Succeed()) + ds := &tests.MockDataStore{MockedScrobbleBuffer: buffer, MockedUser: userRepo} + + flaky := &recoveringScrobbler{} + flaky.fail(ErrRetryLater) + bs := newBufferedScrobbler(ds, flaky, "flaky") + defer func() { bs.Stop(); synctest.Wait() }() + + // Let the loop settle on the empty buffer, then enqueue a scrobble. + synctest.Wait() + track := model.MediaFile{ID: "123", Title: "Test Track", Artist: "Test Artist"} + g.Expect(bs.Scrobble(context.Background(), "user1", Scrobble{MediaFile: track, TimeStamp: time.Now()})).To(Succeed()) + + // First attempt fires immediately on the enqueue wake and is left buffered. + synctest.Wait() + g.Expect(flaky.count.Load()).To(Equal(int32(1))) + g.Expect(buffer.Length()).To(Equal(int64(1))) + + // Each subsequent retry waits exactly double the previous: 5s, 10s, 20s, 40s. + for i, gap := range []time.Duration{5 * time.Second, 10 * time.Second, 20 * time.Second, 40 * time.Second} { + want := int32(i + 2) + time.Sleep(gap - time.Nanosecond) + synctest.Wait() + g.Expect(flaky.count.Load()).To(Equal(want-1), "retry fired before the %s backoff", gap) + time.Sleep(time.Nanosecond) + synctest.Wait() + g.Expect(flaky.count.Load()).To(Equal(want), "retry did not fire after the %s backoff", gap) + } + + // Once the service recovers, waking the loop drains the buffered entry. + flaky.succeed() + bs.sendWakeSignal() + synctest.Wait() + g.Expect(buffer.Length()).To(Equal(int64(0))) + }) +} + +// recoveringScrobbler is a race-safe Scrobbler whose error can be toggled while +// the buffered scrobbler's goroutine is draining, to exercise retry then recovery. +type recoveringScrobbler struct { + err atomic.Pointer[error] + count atomic.Int32 +} + +func (f *recoveringScrobbler) fail(err error) { f.err.Store(&err) } +func (f *recoveringScrobbler) succeed() { f.err.Store(nil) } + +func (f *recoveringScrobbler) IsAuthorized(context.Context, string) bool { return true } + +func (f *recoveringScrobbler) NowPlaying(context.Context, string, *model.MediaFile, int) error { + return nil +} + +func (f *recoveringScrobbler) Scrobble(_ context.Context, _ string, _ Scrobble) error { + f.count.Add(1) + if e := f.err.Load(); e != nil { + return *e + } + return nil +} + +func (f *recoveringScrobbler) PlaybackReport(context.Context, PlaybackSession) error { return nil } From fed966506012113ba7e4272ba422116c43d28b06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 19 Jul 2026 18:51:32 -0400 Subject: [PATCH 31/71] fix(streaming): surface why a transcode decision failed (#5820) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(subsonic): surface the reason a transcode decision failed getTranscodeDecision returned a bare "failed to make transcode decision" with no clue why, and the probe command ran ffprobe with -v quiet, so even the server log bottomed out at "exit status 1". A user whose files had been moved by an external tool only saw the opaque error. ProbeAudioStream now returns a typed ProbeError that separates the file path from the reason: ffprobe runs with -v error so its stderr diagnostic is captured, and a missing or unreadable file is reported as "file not found" rather than ffprobe's misleading "Invalid data found". The handler logs the full detail (including the path) and returns the reason to the client with the server path stripped out. Reported-by: Tolriq (Symfonium) * refactor(ffmpeg): use errors.AsType for ExitError match probeErrorReason used the older var+errors.As form while the rest of the codebase (and its sibling transcodeFailureReason) uses the generic errors.AsType. Switch to it for consistency; behavior is unchanged. * fix(subsonic): return error 70 when the source file is missing A getTranscodeDecision probe failure was always reported as generic error 0. When the source file is gone (moved or deleted out from under the DB), that is a not-found condition, so return the standard Subsonic error 70 ("data not found") instead — matching what the endpoint already returns for an unknown mediaId. Files that exist but are corrupt or unreadable stay error 0. ProbeError now wraps the underlying cause and implements Unwrap, so the handler detects the case with errors.Is(err, fs.ErrNotExist). * fix(ffmpeg): keep probe error paths out of client-facing reasons Addresses review feedback on the ProbeError type: the Reason field doubled as both the log detail and the client message, so an ffprobe launch failure (a *os.PathError from fork/exec) could leak the ffprobe binary path to clients, and an unexpected stat error was reduced to "file not accessible" in the log. Split the two concerns: Reason now holds only a path-free, client-safe string (built at construction), while Error() logs the full underlying cause. Launch failures return a generic "could not read file" instead of the raw exec error. SafeReason no longer does substring path-stripping (removing the empty-Path edge case); the stripping happens once, against ffprobe's stderr. * fix(subsonic): don't report a broken ffprobe as a missing media file Two issues from review of the previous commit: Code 70 was selected with errors.Is(err, fs.ErrNotExist), but a launch failure of a deleted ffprobe binary is an *os.PathError that also wraps fs.ErrNotExist. A server-side ffprobe problem was therefore reported to clients as a missing media file. ProbeError now carries an explicit NotFound flag, set only on the file-access branch, and the handler keys the code off that instead of the chain. ffprobe can also exit 0 while yielding no audio stream (an audio-suffixed container holding only video). That parse failure was returned unwrapped, so clients got "internal error"; it is now wrapped in a ProbeError too. --- core/ffmpeg/ffmpeg.go | 73 +++++++++++++++++++++++++++++-- core/ffmpeg/ffmpeg_test.go | 70 +++++++++++++++++++++++++++++ server/subsonic/transcode.go | 17 ++++++- server/subsonic/transcode_test.go | 49 +++++++++++++++++++++ 4 files changed, 204 insertions(+), 5 deletions(-) diff --git a/core/ffmpeg/ffmpeg.go b/core/ffmpeg/ffmpeg.go index 3d4cd0e72..af2dab647 100644 --- a/core/ffmpeg/ffmpeg.go +++ b/core/ffmpeg/ffmpeg.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + "io/fs" "os" "os/exec" "path/filepath" @@ -67,7 +68,7 @@ var ErrAnimatedWebPUnsupported = errors.New("ffmpeg lacks libwebp_anim encoder const ( extractImageCmd = "ffmpeg -i %s -map 0:v -map -0:V -vcodec copy -f image2pipe -" probeCmd = "ffmpeg %s -f ffmetadata" - probeAudioStreamCmd = "ffprobe -v quiet -select_streams a:0 -print_format json -show_streams -show_format %s" + probeAudioStreamCmd = "ffprobe -v error -select_streams a:0 -print_format json -show_streams -show_format %s" ) type ffmpeg struct{} @@ -159,16 +160,80 @@ func (e *ffmpeg) ProbeAudioStream(ctx context.Context, filePath string) (*AudioP return nil, err } if err := fileExists(filePath); err != nil { - return nil, err + return nil, &ProbeError{Path: filePath, Reason: fileAccessReason(err), + NotFound: errors.Is(err, fs.ErrNotExist), err: err} } args := createFFmpegCommand(probeAudioStreamCmd, filePath, 0, 0) log.Trace(ctx, "Executing ffprobe command", "args", args) cmd := exec.CommandContext(ctx, args[0], args[1:]...) // #nosec output, err := cmd.Output() if err != nil { - return nil, fmt.Errorf("running ffprobe on %q: %w", filePath, err) + return nil, &ProbeError{Path: filePath, Reason: probeClientReason(err, filePath), err: err} } - return parseProbeOutput(output) + result, err := parseProbeOutput(output) + if err != nil { + return nil, &ProbeError{Path: filePath, Reason: err.Error(), err: err} + } + return result, nil +} + +// ProbeError reports an ffprobe failure. Reason is a path-free message safe to +// expose to clients; the wrapped cause carries the full detail for logging. +// NotFound marks the media file itself as missing — a launch failure of a +// deleted ffprobe binary also wraps fs.ErrNotExist, so callers must not infer +// it from the error chain. +type ProbeError struct { + Path string + Reason string + NotFound bool + err error +} + +func (e *ProbeError) Error() string { + if e.err == nil { + return fmt.Sprintf("probe failed on %q: %s", e.Path, e.Reason) + } + return fmt.Sprintf("probe failed on %q: %s", e.Path, probeDetail(e.err)) +} + +// Unwrap exposes the underlying cause so callers can test it with errors.Is +// (e.g. fs.ErrNotExist to detect a missing file). +func (e *ProbeError) Unwrap() error { return e.err } + +// SafeReason returns the path-free reason, safe to send to clients. +func (e *ProbeError) SafeReason() string { return e.Reason } + +// fileAccessReason maps a stat failure to a clear, path-free reason, so a moved +// or unreadable file reads as "file not found" rather than a raw ffprobe message. +func fileAccessReason(err error) string { + switch { + case errors.Is(err, fs.ErrNotExist): + return "file not found" + case errors.Is(err, fs.ErrPermission): + return "permission denied" + default: + return "file not accessible" + } +} + +// probeDetail returns the full diagnostic for logging (may contain paths): +// ffprobe's stderr when present, otherwise the raw error text. +func probeDetail(err error) string { + if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && len(exitErr.Stderr) > 0 { + return strings.TrimSpace(string(exitErr.Stderr)) + } + return err.Error() +} + +// probeClientReason returns a path-free reason for an ffprobe execution failure: +// ffprobe's stderr with the file path stripped, or a generic reason when ffprobe +// couldn't run at all (its launch error may embed the binary path). +func probeClientReason(err error, path string) string { + exitErr, ok := errors.AsType[*exec.ExitError](err) + if !ok || len(exitErr.Stderr) == 0 { + return "could not read file" + } + return strings.TrimSpace(strings.ReplaceAll(string(exitErr.Stderr), path, "the file")) } type probeOutput struct { diff --git a/core/ffmpeg/ffmpeg_test.go b/core/ffmpeg/ffmpeg_test.go index 9c20e6c05..0fa3de111 100644 --- a/core/ffmpeg/ffmpeg_test.go +++ b/core/ffmpeg/ffmpeg_test.go @@ -2,6 +2,7 @@ package ffmpeg import ( "context" + "errors" "os" "os/exec" "path/filepath" @@ -553,6 +554,65 @@ var _ = Describe("ffmpeg", func() { }) }) + Describe("ProbeError", func() { + It("uses the underlying cause in Error() so logs keep the full detail", func() { + e := &ProbeError{Path: "/music/foo.flac", + err: errors.New("/music/foo.flac: Invalid data found when processing input")} + Expect(e.Error()).To(ContainSubstring("/music/foo.flac")) + Expect(e.Error()).To(ContainSubstring("Invalid data found when processing input")) + }) + + It("returns the path-free reason from SafeReason()", func() { + e := &ProbeError{Path: "/music/foo.flac", Reason: "the file: Invalid data found when processing input"} + Expect(e.SafeReason()).To(Equal("the file: Invalid data found when processing input")) + Expect(e.SafeReason()).ToNot(ContainSubstring("/music/foo.flac")) + }) + + It("unwraps to the underlying cause so errors.Is detects a missing file", func() { + e := &ProbeError{Path: "/music/foo.flac", Reason: "file not found", err: os.ErrNotExist} + Expect(errors.Is(e, os.ErrNotExist)).To(BeTrue()) + }) + }) + + Describe("probeClientReason", func() { + It("strips the file path from ffprobe stderr", func() { + if runtime.GOOS == "windows" { + Skip("uses /bin/sh") + } + _, err := exec.Command("/bin/sh", "-c", "echo '/music/foo.flac: Invalid data found' >&2; exit 1").Output() + Expect(err).To(HaveOccurred()) + Expect(probeClientReason(err, "/music/foo.flac")).To(Equal("the file: Invalid data found")) + }) + + It("returns a generic reason for launch failures, without leaking the binary path", func() { + err := errors.New("fork/exec /opt/navidrome/bin/ffprobe: no such file or directory") + Expect(probeClientReason(err, "/music/foo.flac")).To(Equal("could not read file")) + }) + }) + + Describe("probeDetail", func() { + It("surfaces ffprobe stderr for logging", func() { + if runtime.GOOS == "windows" { + Skip("uses /bin/sh") + } + _, err := exec.Command("/bin/sh", "-c", "echo 'boom detail' >&2; exit 1").Output() + Expect(err).To(HaveOccurred()) + Expect(probeDetail(err)).To(Equal("boom detail")) + }) + }) + + Describe("fileAccessReason", func() { + It("reports a missing file as 'file not found', not a raw stat message", func() { + _, err := os.Stat("/no/such/dir/really-missing.flac") + Expect(err).To(HaveOccurred()) + Expect(fileAccessReason(err)).To(Equal("file not found")) + }) + + It("falls back to a generic reason for other access errors", func() { + Expect(fileAccessReason(errors.New("boom"))).To(Equal("file not accessible")) + }) + }) + Describe("FFmpeg", func() { Context("when FFmpeg is available", func() { var ff FFmpeg @@ -566,6 +626,16 @@ var _ = Describe("ffmpeg", func() { } }) + It("ProbeAudioStream returns a not-found ProbeError for a missing file", func() { + _, err := ff.ProbeAudioStream(GinkgoT().Context(), "/no/such/dir/really-missing.flac") + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, os.ErrNotExist)).To(BeTrue()) + var pe *ProbeError + Expect(errors.As(err, &pe)).To(BeTrue()) + Expect(pe.SafeReason()).To(Equal("file not found")) + Expect(pe.NotFound).To(BeTrue()) + }) + It("should interrupt transcoding when context is cancelled", func() { ctx, cancel := context.WithTimeout(GinkgoT().Context(), 5*time.Second) defer cancel() diff --git a/server/subsonic/transcode.go b/server/subsonic/transcode.go index 511db2b85..9eb2af160 100644 --- a/server/subsonic/transcode.go +++ b/server/subsonic/transcode.go @@ -8,6 +8,7 @@ import ( "slices" "strconv" + "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -315,7 +316,8 @@ func (api *Router) GetTranscodeDecision(w http.ResponseWriter, r *http.Request) decision, err := api.transcodeDecision.MakeDecision(ctx, mf, clientInfo, stream.TranscodeOptions{}) if err != nil { log.Error(ctx, "Failed to make transcode decision", "mediaID", mediaID, err) - return nil, newError(responses.ErrorGeneric, "failed to make transcode decision") + code, reason := transcodeFailure(err) + return nil, newError(code, "failed to make transcode decision: %s", reason) } // Only create a token when there is a valid playback path @@ -346,6 +348,19 @@ func (api *Router) GetTranscodeDecision(w http.ResponseWriter, r *http.Request) return response, nil } +// transcodeFailure maps a decision error to a Subsonic error code and a reason +// safe to send to clients, omitting server file paths. +func transcodeFailure(err error) (int32, string) { + pe, ok := errors.AsType[*ffmpeg.ProbeError](err) + if !ok { + return responses.ErrorGeneric, "internal error" + } + if pe.NotFound { + return responses.ErrorDataNotFound, pe.SafeReason() + } + return responses.ErrorGeneric, pe.SafeReason() +} + // GetTranscodeStream handles the OpenSubsonic getTranscodeStream endpoint. // It streams media using the decision encoded in the transcodeParams JWT token. // All errors are returned as proper HTTP status codes (not Subsonic error responses). diff --git a/server/subsonic/transcode_test.go b/server/subsonic/transcode_test.go index 7e36ab243..8d5cbb974 100644 --- a/server/subsonic/transcode_test.go +++ b/server/subsonic/transcode_test.go @@ -4,12 +4,16 @@ import ( "bytes" "context" "errors" + "fmt" + "io/fs" "net/http" "net/http/httptest" + "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -77,6 +81,47 @@ var _ = Describe("Transcode endpoints", func() { Expect(err.Error()).To(ContainSubstring("error retrieving media file")) }) + It("enriches the decision error with the reason, without leaking the file path", func() { + mockMFRepo.SetData(model.MediaFiles{{ID: "song-1", Suffix: "flac"}}) + mockTD.decisionErr = fmt.Errorf("probing media file song-1: %w", + &ffmpeg.ProbeError{Path: "/music/secret/foo.flac", Reason: "the file: Invalid data found when processing input"}) + r := newJSONPostRequest("mediaId=song-1&mediaType=song", "{}") + _, err := router.GetTranscodeDecision(w, r) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to make transcode decision")) + Expect(err.Error()).To(ContainSubstring("Invalid data found when processing input")) + Expect(err.Error()).ToNot(ContainSubstring("/music/secret")) + var subErr subError + Expect(errors.As(err, &subErr)).To(BeTrue()) + Expect(subErr.code).To(Equal(responses.ErrorGeneric)) + }) + + It("returns ErrorDataNotFound when the source file is missing on disk", func() { + mockMFRepo.SetData(model.MediaFiles{{ID: "song-1", Suffix: "flac"}}) + mockTD.decisionErr = fmt.Errorf("probing media file song-1: %w", + &ffmpeg.ProbeError{Path: "/music/gone.flac", Reason: "file not found", NotFound: true}) + r := newJSONPostRequest("mediaId=song-1&mediaType=song", "{}") + _, err := router.GetTranscodeDecision(w, r) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("file not found")) + var subErr subError + Expect(errors.As(err, &subErr)).To(BeTrue()) + Expect(subErr.code).To(Equal(responses.ErrorDataNotFound)) + }) + + It("keeps ErrorGeneric when ffprobe is missing, even though the cause wraps fs.ErrNotExist", func() { + mockMFRepo.SetData(model.MediaFiles{{ID: "song-1", Suffix: "flac"}}) + pe := &ffmpeg.ProbeError{Path: "/music/song.flac", Reason: "could not read file"} + mockTD.decisionErr = fmt.Errorf("probing media file song-1: %w (%w)", pe, fs.ErrNotExist) + Expect(errors.Is(mockTD.decisionErr, fs.ErrNotExist)).To(BeTrue()) + r := newJSONPostRequest("mediaId=song-1&mediaType=song", "{}") + _, err := router.GetTranscodeDecision(w, r) + Expect(err).To(HaveOccurred()) + var subErr subError + Expect(errors.As(err, &subErr)).To(BeTrue()) + Expect(subErr.code).To(Equal(responses.ErrorGeneric)) + }) + It("returns error when body is empty", func() { r := newJSONPostRequest("mediaId=song-1&mediaType=song", "") _, err := router.GetTranscodeDecision(w, r) @@ -516,6 +561,7 @@ func newJSONPostRequest(queryParams string, jsonBody string) *http.Request { // mockTranscodeDecision is a test double for stream.TranscodeDecider type mockTranscodeDecision struct { decision *stream.TranscodeDecision + decisionErr error token string tokenErr error resolvedReq stream.Request @@ -525,6 +571,9 @@ type mockTranscodeDecision struct { func (m *mockTranscodeDecision) MakeDecision(_ context.Context, _ *model.MediaFile, ci *stream.ClientInfo, _ stream.TranscodeOptions) (*stream.TranscodeDecision, error) { m.capturedClient = ci + if m.decisionErr != nil { + return nil, m.decisionErr + } if m.decision != nil { return m.decision, nil } From bf79d2f3a2a8c569d4d533a596f4575d39d5e1dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 19 Jul 2026 18:52:30 -0400 Subject: [PATCH 32/71] refactor(plugins): remove non-functional experimental manifest option (#5821) The `experimental.threads` manifest option never actually worked, so drop it from the schema, the generated types, the loader and the docs. --- plugins/README.md | 20 +---------- plugins/manager_loader.go | 8 ----- plugins/manifest-schema.json | 24 ------------- plugins/manifest.go | 5 --- plugins/manifest_gen.go | 15 -------- plugins/manifest_test.go | 70 ------------------------------------ 6 files changed, 1 insertion(+), 141 deletions(-) diff --git a/plugins/README.md b/plugins/README.md index b9118d36f..08ab967f3 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -136,7 +136,7 @@ Every plugin must include a `manifest.json` file. Example: **Required fields:** `name`, `author`, `version` -**Optional fields:** `description`, `website`, `config`, `permissions`, `experimental` +**Optional fields:** `description`, `website`, `config`, `permissions` #### Config Definition @@ -160,24 +160,6 @@ The `config` field defines the plugin's configuration schema using [JSON Schema } ``` -#### Experimental Features - -Plugins can opt-in to experimental WebAssembly features that may change or be removed in future versions. Currently supported: - -- **`threads`** – Enables WebAssembly threads support (for plugins compiled with multi-threading) - -```json -{ - "experimental": { - "threads": { - "reason": "Required for concurrent audio processing" - } - } -} -``` - -> **Note:** Experimental features may have compatibility or performance implications. Use only when necessary. - --- ## Capabilities diff --git a/plugins/manager_loader.go b/plugins/manager_loader.go index 675c85e26..0ba5fcf76 100644 --- a/plugins/manager_loader.go +++ b/plugins/manager_loader.go @@ -13,8 +13,6 @@ import ( "github.com/navidrome/navidrome/plugins/host" "github.com/navidrome/navidrome/scheduler" "github.com/tetratelabs/wazero" - "github.com/tetratelabs/wazero/api" - "github.com/tetratelabs/wazero/experimental" "golang.org/x/sync/errgroup" ) @@ -377,12 +375,6 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { WithCompilationCache(m.cache). WithCloseOnContextDone(true) - // Enable experimental threads if requested in manifest - if pkg.Manifest.HasExperimentalThreads() { - runtimeConfig = runtimeConfig.WithCoreFeatures(api.CoreFeaturesV2 | experimental.CoreFeaturesThreads) - log.Debug(ctx, "Enabling experimental threads support") - } - extismConfig := extism.PluginConfig{ EnableWasi: true, RuntimeConfig: runtimeConfig, diff --git a/plugins/manifest-schema.json b/plugins/manifest-schema.json index 29e5d1fc7..28adeed79 100644 --- a/plugins/manifest-schema.json +++ b/plugins/manifest-schema.json @@ -34,9 +34,6 @@ "permissions": { "$ref": "#/$defs/Permissions" }, - "experimental": { - "$ref": "#/$defs/Experimental" - }, "config": { "$ref": "#/$defs/ConfigDefinition" } @@ -58,27 +55,6 @@ } } }, - "Experimental": { - "type": "object", - "description": "Experimental features that may change or be removed in future versions", - "additionalProperties": false, - "properties": { - "threads": { - "$ref": "#/$defs/ThreadsFeature" - } - } - }, - "ThreadsFeature": { - "type": "object", - "description": "Enable experimental WebAssembly threads support", - "additionalProperties": false, - "properties": { - "reason": { - "type": "string", - "description": "Explanation for why threads support is needed" - } - } - }, "Permissions": { "type": "object", "description": "Permissions required by the plugin", diff --git a/plugins/manifest.go b/plugins/manifest.go index 6bd0e8049..5e144b5c8 100644 --- a/plugins/manifest.go +++ b/plugins/manifest.go @@ -117,11 +117,6 @@ func ValidateWithCapabilities(m *Manifest, capabilities []Capability) error { return nil } -// HasExperimentalThreads returns true if the manifest requests experimental threads support. -func (m *Manifest) HasExperimentalThreads() bool { - return m.Experimental != nil && m.Experimental.Threads != nil -} - // HasLibraryFilesystemPermission checks if the manifest grants filesystem permission for libraries. func (m *Manifest) HasLibraryFilesystemPermission() bool { return m.Permissions != nil && diff --git a/plugins/manifest_gen.go b/plugins/manifest_gen.go index 3599eafc4..c2aa5e298 100644 --- a/plugins/manifest_gen.go +++ b/plugins/manifest_gen.go @@ -45,12 +45,6 @@ func (j *ConfigDefinition) UnmarshalJSON(value []byte) error { return nil } -// Experimental features that may change or be removed in future versions -type Experimental struct { - // Threads corresponds to the JSON schema field "threads". - Threads *ThreadsFeature `json:"threads,omitempty" yaml:"threads,omitempty" mapstructure:"threads,omitempty"` -} - // HTTP access permissions for a plugin type HTTPPermission struct { // Explanation for why HTTP access is needed @@ -109,9 +103,6 @@ type Manifest struct { // A brief description of what the plugin does Description *string `json:"description,omitempty" yaml:"description,omitempty" mapstructure:"description,omitempty"` - // Experimental corresponds to the JSON schema field "experimental". - Experimental *Experimental `json:"experimental,omitempty" yaml:"experimental,omitempty" mapstructure:"experimental,omitempty"` - // The display name of the plugin Name string `json:"name" yaml:"name" mapstructure:"name"` @@ -242,12 +233,6 @@ func (j *TaskQueuePermission) UnmarshalJSON(value []byte) error { return nil } -// Enable experimental WebAssembly threads support -type ThreadsFeature struct { - // Explanation for why threads support is needed - Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"` -} - // Users service permissions for accessing user information type UsersPermission struct { // Explanation for why users access is needed diff --git a/plugins/manifest_test.go b/plugins/manifest_test.go index 2a8b0dcfa..32bcbba08 100644 --- a/plugins/manifest_test.go +++ b/plugins/manifest_test.go @@ -117,76 +117,6 @@ var _ = Describe("Manifest", func() { }) }) - Describe("HasExperimentalThreads", func() { - It("returns false when no experimental section", func() { - m := &Manifest{} - Expect(m.HasExperimentalThreads()).To(BeFalse()) - }) - - It("returns false when experimental section has no threads", func() { - m := &Manifest{ - Experimental: &Experimental{}, - } - Expect(m.HasExperimentalThreads()).To(BeFalse()) - }) - - It("returns true when threads feature is present", func() { - m := &Manifest{ - Experimental: &Experimental{ - Threads: &ThreadsFeature{}, - }, - } - Expect(m.HasExperimentalThreads()).To(BeTrue()) - }) - - It("returns true when threads feature has a reason", func() { - m := &Manifest{ - Experimental: &Experimental{ - Threads: &ThreadsFeature{ - Reason: new("Required for concurrent processing"), - }, - }, - } - Expect(m.HasExperimentalThreads()).To(BeTrue()) - }) - - It("parses experimental.threads from JSON", func() { - data := []byte(`{ - "name": "Threaded Plugin", - "author": "Test Author", - "version": "1.0.0", - "experimental": { - "threads": { - "reason": "To use multi-threaded WASM module" - } - } - }`) - - var m Manifest - err := json.Unmarshal(data, &m) - Expect(err).ToNot(HaveOccurred()) - Expect(m.HasExperimentalThreads()).To(BeTrue()) - Expect(m.Experimental.Threads.Reason).ToNot(BeNil()) - Expect(*m.Experimental.Threads.Reason).To(Equal("To use multi-threaded WASM module")) - }) - - It("parses experimental.threads without reason from JSON", func() { - data := []byte(`{ - "name": "Threaded Plugin", - "author": "Test Author", - "version": "1.0.0", - "experimental": { - "threads": {} - } - }`) - - var m Manifest - err := json.Unmarshal(data, &m) - Expect(err).ToNot(HaveOccurred()) - Expect(m.HasExperimentalThreads()).To(BeTrue()) - }) - }) - Describe("ParseManifest", func() { It("parses a valid manifest with users permission", func() { data := []byte(`{ From 6c2644d20860fa052fed1e150b2390727e37fa34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 19 Jul 2026 18:59:37 -0400 Subject: [PATCH 33/71] feat(ui): remember 'items per page' selection across sessions (#5819) * feat(ui): add perPageStore helper for items-per-page persistence * feat(ui): persist items-per-page selection in localStorage * feat(ui): enable per-page persistence on album grid, missing files and playlist tracks * feat(ui): restore saved album grid page size on fresh load * feat(ui): restore saved items-per-page on list load * fix(ui): move defaultRowsPerPageOptions to perPageStore to satisfy react-refresh lint * fix(ui): correct defaultRowsPerPageOptions import in List and add render test * refactor(ui): default getStoredPerPage fallback to the first option The fallback equalled options[0] at three of four call sites; default it so those sites stop restating it. SongList and useAlbumsPerPage still pass an explicit fallback where it legitimately differs from the first option. * fix(ui): persist only user-selected page sizes, validate album size against width Persisting on every pagination context value let a URL-injected perPage (e.g. the perPage=15 album link in NowPlayingPanel) or a forced single-option mobile grid overwrite the saved preference. Persist only a value that is an actual option in a multi-option selector. Also validate the album grid's redux session value against the current width so a size chosen at a wider breakpoint can't leave an out-of-range selector. * fix(ui): restore saved items-per-page on the radio list RadioList passed a hard-coded perPage that overrode List's stored seed via the props spread, so a radio-list page size was persisted but never restored. Seed it from storage like the other list views. * fix(ui): persist page size only on an actual selector change Watching the pagination context value meant any valid value persisted itself: loading a list at a breakpoint where the saved size is invalid stored the responsive fallback, and opening a URL with a valid ?perPage= stored that too, either way discarding the user's real preference. Inject a wrapped setPerPage instead, so only the rows-per-page selector writes. This also drops the option-validation heuristics, which the new trigger makes unnecessary. --- ui/src/album/AlbumList.jsx | 2 +- ui/src/artist/ArtistShow.jsx | 1 + ui/src/common/List.jsx | 3 +- ui/src/common/List.test.jsx | 27 +++++++++++ ui/src/common/Pagination.jsx | 33 +++++++++++-- ui/src/common/Pagination.test.jsx | 62 +++++++++++++++++++++++++ ui/src/common/index.js | 1 + ui/src/common/perPageStore.js | 11 +++++ ui/src/common/perPageStore.test.js | 40 ++++++++++++++++ ui/src/common/useAlbumsPerPage.jsx | 16 +++++-- ui/src/common/useAlbumsPerPage.test.jsx | 61 ++++++++++++++++++++++++ ui/src/missing/MissingFilesList.jsx | 15 ++++-- ui/src/playlist/PlaylistShow.jsx | 20 ++++++-- ui/src/radio/RadioList.jsx | 8 +++- ui/src/song/SongList.jsx | 8 +++- 15 files changed, 286 insertions(+), 22 deletions(-) create mode 100644 ui/src/common/List.test.jsx create mode 100644 ui/src/common/Pagination.test.jsx create mode 100644 ui/src/common/perPageStore.js create mode 100644 ui/src/common/perPageStore.test.js create mode 100644 ui/src/common/useAlbumsPerPage.test.jsx diff --git a/ui/src/album/AlbumList.jsx b/ui/src/album/AlbumList.jsx index a860c85bb..5108bfaa1 100644 --- a/ui/src/album/AlbumList.jsx +++ b/ui/src/album/AlbumList.jsx @@ -6,7 +6,6 @@ import { Filter, NullableBooleanInput, NumberInput, - Pagination, ReferenceArrayInput, ReferenceInput, SearchInput, @@ -20,6 +19,7 @@ import FavoriteIcon from '@material-ui/icons/Favorite' import { withWidth } from '@material-ui/core' import { List, + Pagination, Title, useAlbumsPerPage, useResourceRefresh, diff --git a/ui/src/artist/ArtistShow.jsx b/ui/src/artist/ArtistShow.jsx index 935b0bab7..955a565d6 100644 --- a/ui/src/artist/ArtistShow.jsx +++ b/ui/src/artist/ArtistShow.jsx @@ -100,6 +100,7 @@ const ArtistShowLayout = (props) => { const rowsPerPageOptions = [1, 2, 3].map((option) => Math.trunc(option * (perPage / 3)), ) + // react-admin's Pagination on purpose: the common one would persist 30/60/90 under the album grid's key pagination = } diff --git a/ui/src/common/List.jsx b/ui/src/common/List.jsx index 72c2d9482..0ae089460 100644 --- a/ui/src/common/List.jsx +++ b/ui/src/common/List.jsx @@ -2,6 +2,7 @@ import React from 'react' import { List as RAList } from 'react-admin' import config from '../config' import { Pagination } from './Pagination' +import { defaultRowsPerPageOptions, getStoredPerPage } from './perPageStore' import { Title } from './index' export const List = (props) => { @@ -15,7 +16,7 @@ export const List = (props) => { /> } debounce={config.uiSearchDebounceMs} - perPage={15} + perPage={getStoredPerPage(resource, defaultRowsPerPageOptions)} pagination={} {...props} /> diff --git a/ui/src/common/List.test.jsx b/ui/src/common/List.test.jsx new file mode 100644 index 000000000..5bc4b910f --- /dev/null +++ b/ui/src/common/List.test.jsx @@ -0,0 +1,27 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' +import { List } from './List' + +// Only stub the heavy react-admin List controller (data fetching, router sync); +// everything else, including our own Pagination/perPageStore wiring, stays real +// so a bad import (the bug this test guards against) throws on render. +vi.mock('react-admin', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + List: ({ children }) =>
{children}
, + } +}) + +describe('List', () => { + it('renders without throwing and shows its children', () => { + render( + +
list content
+
, + ) + expect(screen.getByTestId('ra-list')).toBeInTheDocument() + expect(screen.getByText('list content')).toBeInTheDocument() + }) +}) diff --git a/ui/src/common/Pagination.jsx b/ui/src/common/Pagination.jsx index e17d9e63e..dd18961ce 100644 --- a/ui/src/common/Pagination.jsx +++ b/ui/src/common/Pagination.jsx @@ -1,6 +1,29 @@ -import React from 'react' -import { Pagination as RAPagination } from 'react-admin' +import React, { useCallback } from 'react' +import { + Pagination as RAPagination, + useListPaginationContext, +} from 'react-admin' +import { setStoredPerPage, defaultRowsPerPageOptions } from './perPageStore' -export const Pagination = (props) => ( - -) +export const Pagination = ({ + rowsPerPageOptions = defaultRowsPerPageOptions, + ...props +}) => { + const { resource, setPerPage } = useListPaginationContext() + // Persist only a selector-driven change: mount, URL params and responsive + // fallbacks never call setPerPage, so they can't overwrite the preference. + const handleSetPerPage = useCallback( + (value) => { + if (resource) setStoredPerPage(resource, value) + setPerPage(value) + }, + [resource, setPerPage], + ) + return ( + + ) +} diff --git a/ui/src/common/Pagination.test.jsx b/ui/src/common/Pagination.test.jsx new file mode 100644 index 000000000..a488aba91 --- /dev/null +++ b/ui/src/common/Pagination.test.jsx @@ -0,0 +1,62 @@ +import React from 'react' +import { render, screen, fireEvent } from '@testing-library/react' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { Pagination } from './Pagination' + +// stub RA's Pagination so a test can invoke the injected setPerPage, i.e. +// simulate an actual rows-per-page selection +vi.mock('react-admin', async () => { + const React = await vi.importActual('react') + return { + Pagination: ({ setPerPage }) => + React.createElement( + 'button', + { onClick: () => setPerPage(50) }, + 'select 50', + ), + useListPaginationContext: vi.fn(), + } +}) + +describe('Pagination', () => { + let mockContext + let setPerPage + + beforeEach(async () => { + vi.clearAllMocks() + localStorage.clear() + setPerPage = vi.fn() + const { useListPaginationContext } = await import('react-admin') + mockContext = vi.mocked(useListPaginationContext) + }) + + const selectPerPage = () => fireEvent.click(screen.getByText('select 50')) + + it('persists the page size chosen in the selector', () => { + mockContext.mockReturnValue({ resource: 'song', perPage: 15, setPerPage }) + render() + selectPerPage() + expect(localStorage.getItem('perPage.song')).toEqual('50') + }) + + it('still applies the change to the list', () => { + mockContext.mockReturnValue({ resource: 'song', perPage: 15, setPerPage }) + render() + selectPerPage() + expect(setPerPage).toHaveBeenCalledWith(50) + }) + + it('does not persist a page size the user did not select', () => { + mockContext.mockReturnValue({ resource: 'song', perPage: 15, setPerPage }) + render() + expect(localStorage.getItem('perPage.song')).toBeNull() + }) + + it('does not persist without a resource in context', () => { + mockContext.mockReturnValue({ perPage: 15, setPerPage }) + render() + selectPerPage() + expect(localStorage.getItem('perPage.undefined')).toBeNull() + expect(setPerPage).toHaveBeenCalledWith(50) + }) +}) diff --git a/ui/src/common/index.js b/ui/src/common/index.js index 362a0ced3..ac8d7f62c 100644 --- a/ui/src/common/index.js +++ b/ui/src/common/index.js @@ -10,6 +10,7 @@ export * from './DurationField' export * from './List' export * from './MultiLineTextField' export * from './Pagination' +export * from './perPageStore' export * from './PlayButton' export * from './QuickFilter' export * from './RangeField' diff --git a/ui/src/common/perPageStore.js b/ui/src/common/perPageStore.js new file mode 100644 index 000000000..52a8a8f29 --- /dev/null +++ b/ui/src/common/perPageStore.js @@ -0,0 +1,11 @@ +export const defaultRowsPerPageOptions = [15, 25, 50] + +const key = (resource) => `perPage.${resource}` + +export const getStoredPerPage = (resource, options, fallback = options[0]) => { + const stored = parseInt(localStorage.getItem(key(resource)), 10) + return options.includes(stored) ? stored : fallback +} + +export const setStoredPerPage = (resource, perPage) => + localStorage.setItem(key(resource), String(perPage)) diff --git a/ui/src/common/perPageStore.test.js b/ui/src/common/perPageStore.test.js new file mode 100644 index 000000000..3aeba3ad8 --- /dev/null +++ b/ui/src/common/perPageStore.test.js @@ -0,0 +1,40 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { getStoredPerPage, setStoredPerPage } from './perPageStore' + +const options = [15, 25, 50] + +describe('perPageStore', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('round-trips a stored value', () => { + setStoredPerPage('song', 25) + expect(getStoredPerPage('song', options, 15)).toEqual(25) + }) + + it('keys values per resource', () => { + setStoredPerPage('song', 25) + setStoredPerPage('playlist', 50) + expect(getStoredPerPage('song', options, 15)).toEqual(25) + expect(getStoredPerPage('playlist', options, 15)).toEqual(50) + }) + + it('returns the fallback when nothing is stored', () => { + expect(getStoredPerPage('song', options, 15)).toEqual(15) + }) + + it('returns the fallback for garbage values', () => { + localStorage.setItem('perPage.song', 'bogus') + expect(getStoredPerPage('song', options, 15)).toEqual(15) + }) + + it('returns the fallback when the stored value is not a valid option', () => { + setStoredPerPage('album', 90) + expect(getStoredPerPage('album', [18, 36, 72], 18)).toEqual(18) + }) + + it('defaults the fallback to the first option', () => { + expect(getStoredPerPage('song', options)).toEqual(15) + }) +}) diff --git a/ui/src/common/useAlbumsPerPage.jsx b/ui/src/common/useAlbumsPerPage.jsx index 6a02bdeb7..0fb5616c3 100644 --- a/ui/src/common/useAlbumsPerPage.jsx +++ b/ui/src/common/useAlbumsPerPage.jsx @@ -1,4 +1,5 @@ import { useSelector } from 'react-redux' +import { getStoredPerPage } from './perPageStore' const getPerPage = (width) => { if (width === 'xs') return 12 @@ -17,10 +18,15 @@ const getPerPageOptions = (width) => { } export const useAlbumsPerPage = (width) => { - const perPage = - useSelector( - (state) => state?.admin.resources?.album?.list?.params?.perPage, - ) || getPerPage(width) + const options = getPerPageOptions(width) + const sessionPerPage = useSelector( + (state) => state?.admin.resources?.album?.list?.params?.perPage, + ) + // Use the session value only when it's valid for the current width, so a + // size picked at a wider breakpoint can't leave an out-of-range selector. + const perPage = options.includes(sessionPerPage) + ? sessionPerPage + : getStoredPerPage('album', options, getPerPage(width)) - return [perPage, getPerPageOptions(width)] + return [perPage, options] } diff --git a/ui/src/common/useAlbumsPerPage.test.jsx b/ui/src/common/useAlbumsPerPage.test.jsx new file mode 100644 index 000000000..b194a6ef2 --- /dev/null +++ b/ui/src/common/useAlbumsPerPage.test.jsx @@ -0,0 +1,61 @@ +import { renderHook } from '@testing-library/react-hooks' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { useAlbumsPerPage } from './useAlbumsPerPage' +import { setStoredPerPage } from './perPageStore' + +vi.mock('react-redux', () => ({ + useSelector: vi.fn(), +})) + +describe('useAlbumsPerPage', () => { + let mockUseSelector + + beforeEach(async () => { + vi.clearAllMocks() + localStorage.clear() + const { useSelector } = await import('react-redux') + mockUseSelector = vi.mocked(useSelector) + }) + + const setReduxPerPage = (value) => + mockUseSelector.mockImplementation((selector) => + selector({ + admin: { + resources: { album: { list: { params: { perPage: value } } } }, + }, + }), + ) + + it('prefers the redux session value over the stored one', () => { + setReduxPerPage(36) + setStoredPerPage('album', 72) + const { result } = renderHook(() => useAlbumsPerPage('lg')) + expect(result.current[0]).toEqual(36) + }) + + it('falls back to the stored value on fresh load', () => { + setReduxPerPage(undefined) + setStoredPerPage('album', 72) + const { result } = renderHook(() => useAlbumsPerPage('lg')) + expect(result.current[0]).toEqual(72) + }) + + it('ignores stored values invalid for the current width', () => { + setReduxPerPage(undefined) + setStoredPerPage('album', 72) // valid for lg, not for md + const { result } = renderHook(() => useAlbumsPerPage('md')) + expect(result.current[0]).toEqual(12) + }) + + it('returns the responsive default when nothing is stored', () => { + setReduxPerPage(undefined) + const { result } = renderHook(() => useAlbumsPerPage('xl')) + expect(result.current).toEqual([36, [18, 36, 72]]) + }) + + it('ignores a redux value invalid for the current width', () => { + setReduxPerPage(72) // valid for lg, not for md + const { result } = renderHook(() => useAlbumsPerPage('md')) + expect(result.current[0]).toEqual(12) + }) +}) diff --git a/ui/src/missing/MissingFilesList.jsx b/ui/src/missing/MissingFilesList.jsx index 87d9f629f..a09f552d9 100644 --- a/ui/src/missing/MissingFilesList.jsx +++ b/ui/src/missing/MissingFilesList.jsx @@ -1,10 +1,15 @@ -import { List, SizeField, useResourceRefresh } from '../common/index' +import { + List, + Pagination, + SizeField, + getStoredPerPage, + useResourceRefresh, +} from '../common/index' import { Datagrid, DateField, TextField, downloadCSV, - Pagination, Filter, ReferenceInput, useTranslate, @@ -49,8 +54,10 @@ const BulkActionButtons = (props) => ( ) +const missingPerPageOptions = [50, 100, 200] + const MissingPagination = (props) => ( - + ) const MissingFilesList = (props) => { @@ -63,7 +70,7 @@ const MissingFilesList = (props) => { actions={} filters={} bulkActionButtons={} - perPage={50} + perPage={getStoredPerPage('missing', missingPerPageOptions)} pagination={} > diff --git a/ui/src/playlist/PlaylistShow.jsx b/ui/src/playlist/PlaylistShow.jsx index f0cb472b1..4e269be18 100644 --- a/ui/src/playlist/PlaylistShow.jsx +++ b/ui/src/playlist/PlaylistShow.jsx @@ -4,14 +4,21 @@ import { ShowContextProvider, useShowContext, useShowController, - Pagination, Title as RaTitle, } from 'react-admin' import { makeStyles } from '@material-ui/core/styles' import PlaylistDetails from './PlaylistDetails' import PlaylistSongs from './PlaylistSongs' import PlaylistActions from './PlaylistActions' -import { Title, canChangeTracks, useResourceRefresh } from '../common' +import { + Pagination, + Title, + canChangeTracks, + getStoredPerPage, + useResourceRefresh, +} from '../common' + +const playlistTrackPerPageOptions = [100, 250, 500] const useStyles = makeStyles( (theme) => ({ @@ -41,7 +48,10 @@ const PlaylistShowLayout = (props) => { reference="playlistTrack" target="playlist_id" sort={{ field: 'id', order: 'ASC' }} - perPage={100} + perPage={getStoredPerPage( + 'playlistTrack', + playlistTrackPerPageOptions, + )} filter={{ playlist_id: props.id }} > { } resource={'playlistTrack'} exporter={false} - pagination={} + pagination={ + + } /> )} diff --git a/ui/src/radio/RadioList.jsx b/ui/src/radio/RadioList.jsx index 945bac519..ccdb9f1ef 100644 --- a/ui/src/radio/RadioList.jsx +++ b/ui/src/radio/RadioList.jsx @@ -16,6 +16,8 @@ import { } from 'react-admin' import { List, + defaultRowsPerPageOptions, + getStoredPerPage, useImageUrl, ToggleFieldsMenu, useSelectedFields, @@ -135,7 +137,11 @@ const RadioList = ({ permissions, ...props }) => { hasCreate={isAdmin} actions={} filters={} - perPage={isXsmall ? 25 : 10} + perPage={getStoredPerPage( + 'radio', + defaultRowsPerPageOptions, + isXsmall ? 25 : 10, + )} > {isXsmall ? ( { bulkActionButtons={} actions={} filters={} - perPage={isXsmall ? 50 : 15} + perPage={getStoredPerPage( + 'song', + defaultRowsPerPageOptions, + isXsmall ? 50 : 15, + )} > {isXsmall ? ( From c35b14dd74d35660a4781d3bd72f7f9c8efa8bd9 Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 20 Jul 2026 09:49:48 -0400 Subject: [PATCH 34/71] chore(deps): update go-taglib to v2.3.1 Signed-off-by: Deluan --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 014a43a56..5488b41e4 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/navidrome/navidrome go 1.26 // Fork to implement raw tags support -replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260619222856-1975cb12f59d +replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3 require ( github.com/Masterminds/squirrel v1.5.4 diff --git a/go.sum b/go.sum index 064974edb..29983a27d 100644 --- a/go.sum +++ b/go.sum @@ -31,8 +31,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= -github.com/deluan/go-taglib v0.0.0-20260619222856-1975cb12f59d h1:/MmnVPIlGzX5kYF6sNtMaOHMkjmu0Us7WtDyJZTglMs= -github.com/deluan/go-taglib v0.0.0-20260619222856-1975cb12f59d/go.mod h1:QGxQ4Z1IWyY9w56xNEFjYAaWE8uSxA/gneQ7RPcFJrY= +github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3 h1:j7eSXqgtjhlNfwnMEzRdXnJGZTEw4I7J9TeQAll83bU= +github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3/go.mod h1:QGxQ4Z1IWyY9w56xNEFjYAaWE8uSxA/gneQ7RPcFJrY= github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf h1:tb246l2Zmpt/GpF9EcHCKTtwzrd0HGfEmoODFA/qnk4= github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8= github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 h1:wSCnggTs2f2ji6nFwQmfwgINcmSMj0xF0oHnoyRSPe4= From 62257527d7f24fdf90b717f12827e708f6223033 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Mon, 20 Jul 2026 21:49:14 -0400 Subject: [PATCH 35/71] fix(subsonic): avoid double brackets when appending subtitle or version (#5832) When AppendSubtitle or AppendAlbumVersion is enabled, the subtitle/version tag was always wrapped in parentheses and appended to the title/album name. If the tag value already came wrapped in brackets (e.g. "(non-explicit version)"), the result was doubled: "Title ((non-explicit version))". Append the tag as-is when it is already wrapped in a matching bracket pair - (), [], {} or <> - and trim surrounding whitespace first. The shared appendSuffix helper is used by MediaFile.FullTitle, MediaFile.FullAlbumName and Album.FullName so all consumers behave consistently. --- model/album.go | 3 +-- model/album_test.go | 2 ++ model/mediafile.go | 14 ++++++++++++-- model/mediafile_test.go | 9 +++++++++ 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/model/album.go b/model/album.go index 0afeb2760..55df8c63c 100644 --- a/model/album.go +++ b/model/album.go @@ -1,7 +1,6 @@ package model import ( - "fmt" "iter" "math" "sync" @@ -77,7 +76,7 @@ func (a Album) CoverArtID() ArtworkID { func (a Album) FullName() string { if conf.Server.Subsonic.AppendAlbumVersion && len(a.Tags[TagAlbumVersion]) > 0 { - return fmt.Sprintf("%s (%s)", a.Name, a.Tags[TagAlbumVersion][0]) + return appendSuffix(a.Name, a.Tags[TagAlbumVersion][0]) } return a.Name } diff --git a/model/album_test.go b/model/album_test.go index 0f4c912cd..ad2ca1cb6 100644 --- a/model/album_test.go +++ b/model/album_test.go @@ -24,6 +24,8 @@ var _ = Describe("Album", func() { Entry("returns just name when disabled", false, Tags{TagAlbumVersion: []string{"Remastered"}}, "Album"), Entry("returns just name when tag is absent", true, Tags{}, "Album"), Entry("returns just name when tag is an empty slice", true, Tags{TagAlbumVersion: []string{}}, "Album"), + Entry("does not double parentheses when version is already parenthesized", true, Tags{TagAlbumVersion: []string{"(Remastered)"}}, "Album (Remastered)"), + Entry("does not add parentheses when version is wrapped in square brackets", true, Tags{TagAlbumVersion: []string{"[Remastered]"}}, "Album [Remastered]"), ) }) diff --git a/model/mediafile.go b/model/mediafile.go index 910cd998d..22ab7fbbe 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -100,18 +100,28 @@ type MediaFile struct { func (mf MediaFile) FullTitle() string { if conf.Server.Subsonic.AppendSubtitle && len(mf.Tags[TagSubtitle]) > 0 { - return fmt.Sprintf("%s (%s)", mf.Title, mf.Tags[TagSubtitle][0]) + return appendSuffix(mf.Title, mf.Tags[TagSubtitle][0]) } return mf.Title } func (mf MediaFile) FullAlbumName() string { if conf.Server.Subsonic.AppendAlbumVersion && len(mf.Tags[TagAlbumVersion]) > 0 { - return fmt.Sprintf("%s (%s)", mf.Album, mf.Tags[TagAlbumVersion][0]) + return appendSuffix(mf.Album, mf.Tags[TagAlbumVersion][0]) } return mf.Album } +var bracketPairs = map[byte]byte{'(': ')', '[': ']', '{': '}', '<': '>'} + +func appendSuffix(base, suffix string) string { + suffix = strings.TrimSpace(suffix) + if len(suffix) >= 2 && bracketPairs[suffix[0]] == suffix[len(suffix)-1] { + return base + " " + suffix + } + return base + " (" + suffix + ")" +} + func (mf MediaFile) ContentType() string { return mime.TypeByExtension("." + mf.Suffix) } diff --git a/model/mediafile_test.go b/model/mediafile_test.go index 6085ccf25..3f306f1a7 100644 --- a/model/mediafile_test.go +++ b/model/mediafile_test.go @@ -533,6 +533,13 @@ var _ = Describe("MediaFile", func() { Entry("returns just title when disabled", false, Tags{TagSubtitle: []string{"Live"}}, "Song"), Entry("returns just title when tag is absent", true, Tags{}, "Song"), Entry("returns just title when tag is an empty slice", true, Tags{TagSubtitle: []string{}}, "Song"), + Entry("does not double parentheses when subtitle is already parenthesized", true, Tags{TagSubtitle: []string{"(non-explicit version)"}}, "Song (non-explicit version)"), + Entry("does not add parentheses when subtitle is wrapped in square brackets", true, Tags{TagSubtitle: []string{"[Live]"}}, "Song [Live]"), + Entry("does not add parentheses when subtitle is wrapped in curly braces", true, Tags{TagSubtitle: []string{"{Remix}"}}, "Song {Remix}"), + Entry("does not add parentheses when subtitle is wrapped in angle brackets", true, Tags{TagSubtitle: []string{""}}, "Song "), + Entry("adds parentheses when brackets do not match", true, Tags{TagSubtitle: []string{"[Live)"}}, "Song ([Live))"), + Entry("trims surrounding whitespace before wrapping", true, Tags{TagSubtitle: []string{" Live "}}, "Song (Live)"), + Entry("trims whitespace around an already-bracketed subtitle", true, Tags{TagSubtitle: []string{" (Live) "}}, "Song (Live)"), ) DescribeTable("FullAlbumName", func(enabled bool, tags Tags, expected string) { @@ -544,6 +551,8 @@ var _ = Describe("MediaFile", func() { Entry("returns just album name when disabled", false, Tags{TagAlbumVersion: []string{"Deluxe Edition"}}, "Album"), Entry("returns just album name when tag is absent", true, Tags{}, "Album"), Entry("returns just album name when tag is an empty slice", true, Tags{TagAlbumVersion: []string{}}, "Album"), + Entry("does not double parentheses when version is already parenthesized", true, Tags{TagAlbumVersion: []string{"(Deluxe Edition)"}}, "Album (Deluxe Edition)"), + Entry("does not add parentheses when version is wrapped in square brackets", true, Tags{TagAlbumVersion: []string{"[Deluxe Edition]"}}, "Album [Deluxe Edition]"), ) Describe("CoverArtId", func() { It("returns its own id if it HasCoverArt", func() { From 93f6afb684b44b29ebdbd35eb18d3f063d14caa6 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 22 Jul 2026 12:19:50 -0400 Subject: [PATCH 36/71] chore: add OpenCollective funding option Signed-off-by: Deluan --- .github/FUNDING.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 534cfbd11..e5ed36e70 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,10 +1,10 @@ # These are supported funding model platforms -github: deluan -patreon: # Replace with a single Patreon username -open_collective: # Replace with a single Open Collective username ko_fi: deluan +github: deluan +open_collective: navidrome liberapay: deluan +patreon: # Replace with a single Patreon username tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry issuehunt: # Replace with a single IssueHunt username From ecf606e52320b87d099991f4f3da87913523c303 Mon Sep 17 00:00:00 2001 From: Marco Ciotola Date: Sat, 25 Jul 2026 19:23:32 +0200 Subject: [PATCH 37/71] fix(ui): update Italian translation (#5848) --- resources/i18n/it.json | 108 +++++++++++++++++++++-------------------- 1 file changed, 55 insertions(+), 53 deletions(-) diff --git a/resources/i18n/it.json b/resources/i18n/it.json index b91c04064..656043589 100644 --- a/resources/i18n/it.json +++ b/resources/i18n/it.json @@ -17,7 +17,7 @@ "genre": "Genere", "compilation": "Compilation", "year": "Anno", - "size": "Dimensioni", + "size": "Dimensione file", "updatedAt": "Ultimo aggiornamento", "bitRate": "Bitrate", "bitDepth": "Profondità di bit", @@ -98,9 +98,9 @@ "lists": { "all": "Tutti", "random": "Casuali", - "recentlyAdded": "Aggiunti di Recente", - "recentlyPlayed": "Riprodotti di Recente", - "mostPlayed": "I Più Riprodotti", + "recentlyAdded": "Aggiunti di recente", + "recentlyPlayed": "Riprodotti di recente", + "mostPlayed": "I più riprodotti", "starred": "Preferiti", "topRated": "Più votati" } @@ -121,17 +121,17 @@ "roles": { "albumartist": "Artista Album |||| Artisti Album", "artist": "Artista |||| Artisti", - "composer": "Compositore |||| Compositori", - "conductor": "Direttore d'orchestra |||| Direttori d'orchestra", - "lyricist": "Paroliere |||| Parolieri", - "arranger": "Arrangiatore |||| Arrangiatori", - "producer": "Produttore |||| Produttori", - "director": "Direttore |||| Direttori", - "engineer": "Ingegnere del suono |||| Ingegneri del suono", + "composer": "Composizione |||| Composizione", + "conductor": "Direzione d'orchestra |||| Direzione d'orchestra", + "lyricist": "Testi |||| Testi", + "arranger": "Arrangiamento |||| Arrangiamento", + "producer": "Produzione |||| Produzione", + "director": "Direzione |||| Direzione", + "engineer": "Ingegneria del suono |||| Ingegneria del suono", "mixer": "Mixer |||| Mixer", "remixer": "Remixer |||| Remixer", "djmixer": "DJ Mixer |||| DJ Mixer", - "performer": "Esecutore |||| Esecutori", + "performer": "Esecuzione |||| Esecuzione", "maincredit": "Artista Album o Artista |||| Artisti Album o Artisti" }, "actions": { @@ -144,7 +144,7 @@ "name": "Utente |||| Utenti", "fields": { "userName": "Nome utente", - "isAdmin": "Amministratore", + "isAdmin": "Admin", "lastLoginAt": "Ultimo login", "lastAccessAt": "Ultimo accesso", "updatedAt": "Ultimo aggiornamento", @@ -152,13 +152,13 @@ "password": "Password", "createdAt": "Creato il", "changePassword": "Cambiare la password?", - "currentPassword": "Password Attuale", - "newPassword": "Nuova Password", + "currentPassword": "Password attuale", + "newPassword": "Nuova password", "token": "Token", "libraries": "Librerie" }, "helperTexts": { - "name": "Le modifiche effettuate al tuo nome verranno mostrate al prossimo accesso", + "name": "Le modifiche al tuo nome verranno mostrate solo al prossimo accesso", "libraries": "Seleziona librerie specifiche per questo utente, o lascia vuoto per usare le librerie predefinite" }, "notifications": { @@ -167,13 +167,13 @@ "deleted": "Utente eliminato" }, "validation": { - "librariesRequired": "Almeno una libreria deve essere selezionata per gli utenti non amministratori" + "librariesRequired": "Almeno una libreria deve essere selezionata per gli utenti non admin" }, "message": { - "listenBrainzToken": "Inserisci il tuo token utente ListenBrainz", + "listenBrainzToken": "Inserisci il tuo token utente ListenBrainz.", "clickHereForToken": "Clicca qui per ottenere il tuo token", "selectAllLibraries": "Seleziona tutte le librerie", - "adminAutoLibraries": "Gli utenti amministratori hanno automaticamente accesso a tutte le librerie" + "adminAutoLibraries": "Gli utenti admin hanno automaticamente accesso a tutte le librerie" } }, "player": { @@ -203,28 +203,29 @@ "fields": { "name": "Nome", "duration": "Durata", - "ownerName": "Creatore", + "ownerName": "Di", "public": "Pubblica", "updatedAt": "Ultimo aggiornamento", "createdAt": "Data creazione", "songCount": "Tracce", "comment": "Commento", "sync": "Importazione automatica", - "path": "Importa da" + "path": "Importa da", + "starred": "Preferita" }, "actions": { "selectPlaylist": "Seleziona una playlist:", "addNewPlaylist": "Crea \"%{name}\"", "export": "Esporta", "saveQueue": "Salva la coda nella playlist", - "makePublic": "Rendi Pubblica", - "makePrivate": "Rendi Privata", + "makePublic": "Rendi pubblica", + "makePrivate": "Rendi privata", "searchOrCreate": "Cerca playlist o digita per crearne una nuova...", "pressEnterToCreate": "Premi Invio per creare una nuova playlist", "removeFromSelection": "Rimuovi dalla selezione" }, "message": { - "duplicate_song": "Aggiungere i duplicati", + "duplicate_song": "Aggiungi tracce duplicate", "song_exist": "Si stanno aggiungendo dei duplicati nella playlist. Vuoi aggiungerli o saltarli?", "noPlaylistsFound": "Nessuna playlist trovata", "noPlaylists": "Nessuna playlist disponibile" @@ -331,7 +332,7 @@ "pathInvalid": "Percorso della libreria non valido" }, "messages": { - "deleteConfirm": "Sei sicuro di voler eliminare questa libreria? Verranno rimossi tutti i dati associati e gli accessi degli utenti.", + "deleteConfirm": "Vuoi eliminare questa libreria? Verranno rimossi tutti i dati associati e gli accessi degli utenti.", "scanInProgress": "Scansione in corso...", "noLibrariesAssigned": "Nessuna libreria assegnata a questo utente" } @@ -367,7 +368,7 @@ "configuration": "Configurazione", "manifest": "Manifest", "usersPermission": "Permessi utenti", - "libraryPermission": "Permesso libreria" + "libraryPermission": "Permessi librerie" }, "status": { "enabled": "Abilitato", @@ -400,10 +401,10 @@ "allUsersHelp": "Se abilitato, il plugin avrà accesso a tutti gli utenti, inclusi quelli creati in futuro.", "noUsers": "Nessun utente selezionato", "permissionReason": "Motivo", - "usersRequired": "Questo plugin richiede accesso alle informazioni degli utenti. Seleziona quali utenti il plugin può accedere, oppure abilita 'Consenti tutti gli utenti'.", + "usersRequired": "Questo plugin richiede accesso alle informazioni degli utenti. Seleziona a quali utenti il plugin può accedere, oppure abilita 'Consenti tutti gli utenti'.", "allLibrariesHelp": "Se abilitato, il plugin avrà accesso a tutte le librerie, incluse quelle create in futuro.", "noLibraries": "Nessuna libreria selezionata", - "librariesRequired": "Questo plugin richiede accesso alle informazioni delle librerie. Seleziona quali librerie il plugin può accedere, oppure abilita 'Consenti tutte le librerie'.", + "librariesRequired": "Questo plugin richiede accesso alle informazioni delle librerie. Seleziona a quali librerie il plugin può accedere, oppure abilita 'Consenti tutte le librerie'.", "allowWriteAccessHelp": "Se abilitato, il plugin può modificare i file nelle directory della libreria. Per impostazione predefinita, i plugin hanno accesso in sola lettura.", "requiredHosts": "Host richiesti" }, @@ -416,9 +417,9 @@ "ra": { "auth": { "welcome1": "Grazie per aver installato Navidrome!", - "welcome2": "Per iniziare, crea un amministratore", + "welcome2": "Per iniziare, crea un account amministratore", "confirmPassword": "Conferma la password", - "buttonCreateAdmin": "Crea amministratore", + "buttonCreateAdmin": "Crea admin", "auth_check_error": "Per favore accedi per continuare", "user_menu": "Profilo", "username": "Nome utente", @@ -488,8 +489,8 @@ "loading": "Caricamento in corso", "not_found": "Non trovato", "show": "%{name} #%{id}", - "empty": "Nessun %{name} per adesso.", - "invite": "Vuoi aggiungerne uno?" + "empty": "Ancora niente %{name}.", + "invite": "Vuoi aggiungerne?" }, "input": { "file": { @@ -512,10 +513,10 @@ }, "message": { "about": "Informazioni", - "are_you_sure": "Sei sicuro?", - "bulk_delete_content": "Sei sicuro di voler rimuovere questo %{name}? |||| Sei sicuro di voler rimuovere questi %{smart_count} elementi?", + "are_you_sure": "Vuoi procedere?", + "bulk_delete_content": "Vuoi rimuovere questo %{name}? |||| Vuoi rimuovere questi %{smart_count} elementi?", "bulk_delete_title": "Rimuovi %{name} |||| Rimuovi %{smart_count} %{name}", - "delete_content": "Sei sicuro di voler eliminare questo elemento?", + "delete_content": "Vuoi eliminare questo elemento?", "delete_title": "Rimuovi %{name} #%{id}", "details": "Dettagli", "error": "Un errore dal lato client ha impedito il completamento della tua richiesta.", @@ -524,7 +525,7 @@ "no": "No", "not_found": "Hai inserito un URL inesistente, oppure hai cliccato un link errato.", "yes": "Sì", - "unsaved_changes": "Alcune modifiche non sono state salvate. Sei sicuro di volerle ignorare?" + "unsaved_changes": "Alcune modifiche non sono state salvate. Vuoi ignorarle?" }, "navigation": { "no_results": "Nessun risultato trovato", @@ -574,11 +575,11 @@ "noTopSongsFound": "Nessun brano più ascoltato trovato", "noPlaylistsAvailable": "Nessuna disponibile", "delete_user_title": "Rimuovi utente '%{name}'", - "delete_user_content": "Sei sicuro di voler rimuovere questo utente e tutti i suoi dati (incluse playlist e impostazioni)?", + "delete_user_content": "Vuoi rimuovere questo utente e tutti i suoi dati (incluse playlist e impostazioni)?", "remove_missing_title": "Rimuovi i file mancanti", - "remove_missing_content": "Sei sicuro di voler rimuovere i file mancanti selezionati dal database? Verranno eliminati permanentemente tutti i riferimenti ad essi, inclusi i conteggi delle riproduzioni e le valutazioni.", + "remove_missing_content": "Vuoi rimuovere i file mancanti selezionati dal database? Verranno eliminati permanentemente tutti i riferimenti ad essi, inclusi i conteggi delle riproduzioni e le valutazioni.", "remove_all_missing_title": "Rimuovi tutti i file mancanti", - "remove_all_missing_content": "Sei sicuro di voler rimuovere tutti i file mancanti dal database? Verranno eliminati permanentemente tutti i riferimenti ad essi, inclusi i conteggi delle riproduzioni e le valutazioni.", + "remove_all_missing_content": "Vuoi rimuovere tutti i file mancanti dal database? Verranno eliminati permanentemente tutti i riferimenti ad essi, inclusi i conteggi delle riproduzioni e le valutazioni.", "notifications_blocked": "Hai bloccato le notifiche per questo sito nelle tue impostazioni del browser", "notifications_not_available": "Questo browser non supporta le notifiche desktop o non stai accedendo a Navidrome tramite HTTPS", "lastfmLinkSuccess": "Collegamento a Last.fm riuscito e scrobbling abilitato", @@ -619,7 +620,7 @@ "options": { "theme": "Tema", "language": "Lingua", - "defaultView": "Vista Predefinita", + "defaultView": "Vista predefinita", "desktop_notifications": "Notifiche desktop", "lastfmNotConfigured": "La chiave API di Last.fm non è configurata", "lastfmScrobbling": "Esegui lo scrobbling tramite Last.fm", @@ -635,21 +636,22 @@ }, "albumList": "Album", "playlists": "Playlist", - "sharedPlaylists": "Playlist Condivise", - "about": "Info" + "sharedPlaylists": "Playlist condivise", + "about": "Info", + "onlyFavourites": "Mostra solo i preferiti" }, "player": { "playListsText": "Coda", "openText": "Apri", "closeText": "Chiudi", - "notContentText": "Nessuna traccia", + "notContentText": "Niente musica", "clickToPlayText": "Clicca per riprodurre", "clickToPauseText": "Clicca per mettere in pausa", "nextTrackText": "Traccia successiva", "previousTrackText": "Traccia precedente", "reloadText": "Ricarica", "volumeText": "Volume", - "toggleLyricText": "Mostra testo", + "toggleLyricText": "Mostra/nascondi testo", "toggleMiniModeText": "Minimizza", "destroyText": "Distruggi", "downloadText": "Scarica", @@ -659,7 +661,7 @@ "playModeText": { "order": "In ordine", "orderLoop": "Ripeti", - "singleLoop": "Ripeti una volta", + "singleLoop": "Ripeti traccia", "shufflePlay": "Casuale" } }, @@ -667,7 +669,7 @@ "links": { "homepage": "Sito web", "source": "Codice sorgente", - "featureRequests": "Richieste", + "featureRequests": "Proponi idee", "lastInsightsCollection": "Ultima raccolta dati", "insights": { "disabled": "Disabilitato", @@ -693,7 +695,7 @@ }, "activity": { "title": "Attività", - "totalScanned": "Cartelle scansionate totali", + "totalScanned": "Totale cartelle scansionate", "quickScan": "Rapida", "fullScan": "Completa", "selectiveScan": "Selettiva", @@ -709,17 +711,17 @@ "minutesAgo": "%{smart_count} minuto fa |||| %{smart_count} minuti fa" }, "help": { - "title": "Scorciatoie da Tastiera di Navidrome", + "title": "Scorciatoie da tastiera di Navidrome", "hotkeys": { "show_help": "Mostra questa schermata", "toggle_menu": "Mostra/Nascondi la barra laterale", "toggle_play": "Riproduzione/Pausa", - "prev_song": "Traccia Precedente", - "next_song": "Traccia Successiva", + "prev_song": "Traccia precedente", + "next_song": "Traccia successiva", "current_song": "Vai alla traccia corrente", - "vol_up": "Alza il Volume", - "vol_down": "Abbassa il Volume", + "vol_up": "Alza il volume", + "vol_down": "Abbassa il volume", "toggle_love": "Aggiungi questa traccia ai preferiti" } } -} \ No newline at end of file +} From 23548f40a09f5f8bf9cf1e1bc2d4c2b236777560 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Tue, 28 Jul 2026 16:13:44 -0400 Subject: [PATCH 38/71] fix(share): give visual feedback when downloading from a share (#5865) * fix(share): give visual feedback when downloading a share The share page handed the download URL to navidrome-music-player, which fell through to downloadjs and buffered the whole ZIP into memory via XHR before saving it. Nothing was handed to the browser until the last byte arrived, so a large share produced a long silent window with no player feedback and no browser download UI, inviting repeat clicks that each spawn another server-side zip+transcode. Use the player's customDownloader prop to trigger a synthetic anchor instead, so the browser performs the download and reports its own progress. An anchor rather than assigning window.location.href: the share page's service worker registers a NavigationRoute over all navigations, which intercepts the streamed archive and fails it into the offline fallback (observed as HTTP 503 in Chrome). handleDownloads now loads the share before streaming so it can set Content-Disposition and Content-Type. This also fixes error reporting: ZipShare previously wrote to the ResponseWriter before checkShareError ran, locking the status at 200, so expired, missing and non-downloadable shares all returned 200. They now correctly return 410, 404 and 403. * feat(share): acknowledge the download click in the player The browser's download UI is the real progress indicator, but nothing in the page itself reacted to the click, so the moment before the browser catches up still read as unresponsive. Dim the download button and make it unclickable for two seconds after a download starts, reusing the JSS function-value pattern the existing single-track styling already uses. A repeat download restarts the window instead of extending the original, and the timer is cleared on unmount. This also blunts repeat clicking, where every extra click costs another server-side zip and transcode. Add SharePlayer tests covering the download mechanism and this state machine. The dimming itself is verified in a browser rather than jsdom: JSS function values are not evaluated there, so the rule is never emitted and a CSS assertion would pass or fail for the wrong reason. Signed-off-by: Deluan * test(share): assert render counts in SharePlayer feedback tests The two acknowledgement tests compared the props object across renders, which React may reuse, so they passed without proving anything and then failed once the surrounding assertions changed. Count renders instead, and let the pending timer run out rather than advancing exactly to its deadline, which does not cross it. The repeat-download test now also asserts that no render happens at the original deadline, proving the timer was replaced rather than merely that one eventually fired. * fix(share): count one visit per share download The preflight share load added in this branch made every download record two visits: handleDownloads called Share.Load, and ZipShare then loaded the share again internally. Share.Load increments and persists VisitCount, so the counter advanced twice per download and the repository work was duplicated. Pass the already-loaded share into ZipShare instead of its id. handleDownloads is its only production caller, and it now has the share in hand for the Content-Disposition header anyway. The archiver test asserts Load is not called, so the double-load cannot come back unnoticed. Verified against a running server: the counter now advances by one per download. * test(share): derive feedback-window timings from the constant The acknowledgement tests hardcoded clock advances tuned to a 2000ms window. Raising DOWNLOAD_FEEDBACK_MS to 5000 left them advancing 1500ms and 1001ms, which no longer reach the deadline they are meant to cross, so the repeat- download test passed without proving the timer had been replaced. Export the constant and derive the advances from it, and let the pending timer run out in the unmount test rather than advancing a fixed amount. Changing the duration can no longer silently strand a test short of its deadline. --------- Signed-off-by: Deluan --- core/archiver.go | 12 +-- core/archiver_test.go | 7 +- server/public/handle_downloads.go | 27 ++++- server/public/handle_downloads_test.go | 134 ++++++++++++++++++++++++ server/subsonic/e2e/e2e_suite_test.go | 2 +- ui/src/share/SharePlayer.jsx | 42 ++++++-- ui/src/share/SharePlayer.test.jsx | 139 +++++++++++++++++++++++++ 7 files changed, 344 insertions(+), 19 deletions(-) create mode 100644 server/public/handle_downloads_test.go create mode 100644 ui/src/share/SharePlayer.test.jsx diff --git a/core/archiver.go b/core/archiver.go index 5d1c090cd..8c42f8f49 100644 --- a/core/archiver.go +++ b/core/archiver.go @@ -21,7 +21,7 @@ import ( type Archiver interface { ZipAlbum(ctx context.Context, id string, format string, bitrate int, w io.Writer) error ZipArtist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error - ZipShare(ctx context.Context, id string, w io.Writer) error + ZipShare(ctx context.Context, s *model.Share, w io.Writer) error ZipPlaylist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error } @@ -100,16 +100,14 @@ func (a *archiver) albumFilename(mf model.MediaFile, format string, isMultiDisc return fmt.Sprintf("%s/%s", str.SanitizeFilename(mf.Album), file) } -func (a *archiver) ZipShare(ctx context.Context, id string, out io.Writer) error { - s, err := a.shares.Load(ctx, id) - if err != nil { - return err - } +// ZipShare takes an already-loaded share: Share.Load records a visit, so +// loading it again here would count every download twice. +func (a *archiver) ZipShare(ctx context.Context, s *model.Share, out io.Writer) error { if !s.Downloadable { return model.ErrNotAuthorized } log.Debug(ctx, "Zipping share", "name", s.ID, "format", s.Format, "bitrate", s.MaxBitRate, "numTracks", len(s.Tracks)) - return a.zipMediaFiles(ctx, id, s.ID, s.Format, s.MaxBitRate, out, s.Tracks, false) + return a.zipMediaFiles(ctx, s.ID, s.ID, s.Format, s.MaxBitRate, out, s.Tracks, false) } func (a *archiver) ZipPlaylist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error { diff --git a/core/archiver_test.go b/core/archiver_test.go index f432139d8..2ba8f1fc0 100644 --- a/core/archiver_test.go +++ b/core/archiver_test.go @@ -130,13 +130,16 @@ var _ = Describe("Archiver", func() { Tracks: mfs, } - sh.On("Load", mock.Anything, "1").Return(share, nil) ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2) out := new(bytes.Buffer) - err := arch.ZipShare(context.Background(), "1", out) + err := arch.ZipShare(context.Background(), share, out) Expect(err).To(BeNil()) + // Share.Load records a visit; re-loading here would double-count + // every download. + sh.AssertNotCalled(GinkgoT(), "Load", mock.Anything, mock.Anything) + zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len())) Expect(err).To(BeNil()) diff --git a/server/public/handle_downloads.go b/server/public/handle_downloads.go index 6aa35c341..0012c4b35 100644 --- a/server/public/handle_downloads.go +++ b/server/public/handle_downloads.go @@ -1,18 +1,41 @@ package public import ( + "cmp" + "fmt" "net/http" + "strings" + "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/req" + "github.com/navidrome/navidrome/utils/str" ) func (pub *Router) handleDownloads(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() id, err := req.Params(r).String(":id") if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } - err = pub.archiver.ZipShare(r.Context(), id, w) - checkShareError(r.Context(), w, err, id) + // Load the share before streaming: once ZipShare writes its first byte the + // status is locked at 200, so errors could no longer be reported. + s, err := pub.share.Load(ctx, id) + if err != nil { + checkShareError(ctx, w, err, id) + return + } + if !s.Downloadable { + checkShareError(ctx, w, model.ErrNotAuthorized, id) + return + } + + name := str.SanitizeFilename(cmp.Or(s.Description, s.ID)) + name = strings.ReplaceAll(name, ",", "_") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", name+".zip")) + w.Header().Set("Content-Type", "application/zip") + + err = pub.archiver.ZipShare(ctx, s, w) + checkShareError(ctx, w, err, id) } diff --git a/server/public/handle_downloads_test.go b/server/public/handle_downloads_test.go new file mode 100644 index 000000000..1a97f4379 --- /dev/null +++ b/server/public/handle_downloads_test.go @@ -0,0 +1,134 @@ +package public + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "time" + + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type mockArchiver struct { + called bool + err error +} + +func (m *mockArchiver) ZipAlbum(context.Context, string, string, int, io.Writer) error { + return nil +} + +func (m *mockArchiver) ZipArtist(context.Context, string, string, int, io.Writer) error { + return nil +} + +func (m *mockArchiver) ZipPlaylist(context.Context, string, string, int, io.Writer) error { + return nil +} + +func (m *mockArchiver) ZipShare(_ context.Context, _ *model.Share, w io.Writer) error { + m.called = true + if m.err != nil { + return m.err + } + _, _ = w.Write([]byte("zip-contents")) + return nil +} + +var _ = Describe("handleDownloads", func() { + var ds *tests.MockDataStore + var shareRepo *tests.MockShareRepo + var archiver *mockArchiver + var pub *Router + + BeforeEach(func() { + ds = &tests.MockDataStore{} + shareRepo = &tests.MockShareRepo{} + ds.MockedShare = shareRepo + archiver = &mockArchiver{} + pub = &Router{ds: ds, archiver: archiver, share: core.NewShare(ds)} + }) + + shareIs := func(s *model.Share) { + shareRepo.ID = s.ID + shareRepo.Entity = s + } + + makeRequest := func(id string) *httptest.ResponseRecorder { + r := httptest.NewRequest("GET", "/public/d/"+id+"?%3Aid="+id, nil) + w := httptest.NewRecorder() + pub.handleDownloads(w, r) + return w + } + + It("sets a Content-Disposition filename from the share description", func() { + shareIs(&model.Share{ID: "abc123", Description: "My Mixtape", Downloadable: true}) + + w := makeRequest("abc123") + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Disposition")).To(Equal(`attachment; filename="My Mixtape.zip"`)) + Expect(w.Header().Get("Content-Type")).To(Equal("application/zip")) + Expect(archiver.called).To(BeTrue()) + Expect(w.Body.String()).To(Equal("zip-contents")) + }) + + It("falls back to the share ID when there is no description", func() { + shareIs(&model.Share{ID: "abc123", Downloadable: true}) + + w := makeRequest("abc123") + + Expect(w.Header().Get("Content-Disposition")).To(Equal(`attachment; filename="abc123.zip"`)) + }) + + It("sanitizes characters that are unsafe in a filename", func() { + shareIs(&model.Share{ID: "abc123", Description: `AC/DC: Live, 1979`, Downloadable: true}) + + w := makeRequest("abc123") + + Expect(w.Header().Get("Content-Disposition")).To(Equal(`attachment; filename="AC_DC_ Live_ 1979.zip"`)) + }) + + It("returns 403 without invoking the archiver when the share is not downloadable", func() { + shareIs(&model.Share{ID: "abc123", Description: "No Download", Downloadable: false}) + + w := makeRequest("abc123") + + Expect(w.Code).To(Equal(http.StatusForbidden)) + Expect(archiver.called).To(BeFalse()) + Expect(w.Header().Get("Content-Disposition")).To(BeEmpty()) + }) + + It("returns 404 when the share does not exist", func() { + shareIs(&model.Share{ID: "other", Downloadable: true}) + + w := makeRequest("missing") + + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(archiver.called).To(BeFalse()) + }) + + It("returns 410 when the share has expired", func() { + shareIs(&model.Share{ID: "abc123", Downloadable: true, ExpiresAt: new(time.Now().Add(-time.Hour))}) + + w := makeRequest("abc123") + + Expect(w.Code).To(Equal(http.StatusGone)) + Expect(archiver.called).To(BeFalse()) + }) + + It("returns 500 when the share lookup fails", func() { + shareRepo.Error = errors.New("db error") + + w := makeRequest("abc123") + + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + Expect(archiver.called).To(BeFalse()) + }) +}) diff --git a/server/subsonic/e2e/e2e_suite_test.go b/server/subsonic/e2e/e2e_suite_test.go index 6875b6370..4e9039d68 100644 --- a/server/subsonic/e2e/e2e_suite_test.go +++ b/server/subsonic/e2e/e2e_suite_test.go @@ -330,7 +330,7 @@ func (n noopArchiver) ZipArtist(context.Context, string, string, int, io.Writer) return model.ErrNotFound } -func (n noopArchiver) ZipShare(context.Context, string, io.Writer) error { +func (n noopArchiver) ZipShare(context.Context, *model.Share, io.Writer) error { return model.ErrNotFound } diff --git a/ui/src/share/SharePlayer.jsx b/ui/src/share/SharePlayer.jsx index a3a15e50a..2384866ea 100644 --- a/ui/src/share/SharePlayer.jsx +++ b/ui/src/share/SharePlayer.jsx @@ -1,15 +1,24 @@ import ReactJkMusicPlayer from 'navidrome-music-player' +import { useCallback, useEffect, useRef, useState } from 'react' import config, { shareInfo } from '../config' import { shareCoverUrl, shareDownloadUrl, shareStreamUrl } from '../utils' import { makeStyles } from '@material-ui/core/styles' +// How long the download button stays inert after a click. The browser needs a +// moment to show its own download UI; until then the page looks unresponsive. +export const DOWNLOAD_FEEDBACK_MS = 5000 + const useStyle = makeStyles({ player: { '& .group .next-audio': { pointerEvents: (props) => props.single && 'none', opacity: (props) => props.single && 0.65, }, + '& .group.audio-download': { + pointerEvents: (props) => props.downloading && 'none', + opacity: (props) => props.downloading && 0.65, + }, '@media (min-width: 768px)': { '& .react-jinke-music-player-mobile > div': { width: 768, @@ -23,7 +32,14 @@ const useStyle = makeStyles({ }) const SharePlayer = () => { - const classes = useStyle({ single: shareInfo?.tracks.length === 1 }) + const [downloading, setDownloading] = useState(false) + const timer = useRef(null) + const classes = useStyle({ + single: shareInfo?.tracks.length === 1, + downloading, + }) + + useEffect(() => () => clearTimeout(timer.current), []) const list = shareInfo?.tracks.map((s) => { return { @@ -34,11 +50,23 @@ const SharePlayer = () => { duration: s.duration, } }) - const onBeforeAudioDownload = () => { - return Promise.resolve({ - src: shareDownloadUrl(shareInfo?.id), - }) - } + // An anchor, not a navigation: the service worker's NavigationRoute would + // intercept the streamed archive and fail it. + const customDownloader = useCallback(() => { + const link = document.createElement('a') + link.href = shareDownloadUrl(shareInfo?.id) + link.download = '' + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + + setDownloading(true) + clearTimeout(timer.current) + timer.current = setTimeout( + () => setDownloading(false), + DOWNLOAD_FEEDBACK_MS, + ) + }, []) const options = { audioLists: list, mode: 'full', @@ -59,7 +87,7 @@ const SharePlayer = () => { ) } diff --git a/ui/src/share/SharePlayer.test.jsx b/ui/src/share/SharePlayer.test.jsx new file mode 100644 index 000000000..68d1eff59 --- /dev/null +++ b/ui/src/share/SharePlayer.test.jsx @@ -0,0 +1,139 @@ +import { render, act } from '@testing-library/react' +import SharePlayer, { DOWNLOAD_FEEDBACK_MS } from './SharePlayer' + +let playerProps +let renderCount + +vi.mock('navidrome-music-player', () => ({ + default: (props) => { + playerProps = props + renderCount++ + return
+ }, +})) + +vi.mock('../config', () => ({ + default: { enableDownloads: true }, + shareInfo: { + id: 'share-1', + downloadable: true, + tracks: [{ id: 't1', title: 'One', artist: 'A', duration: 100 }], + }, +})) + +vi.mock('../utils', () => ({ + shareDownloadUrl: (id) => `/share/d/${id}`, + shareStreamUrl: (id) => `/share/s/${id}`, + shareCoverUrl: (id) => `/share/img/${id}`, +})) + +describe('SharePlayer', () => { + let clickSpy + + beforeEach(() => { + vi.useFakeTimers() + playerProps = null + renderCount = 0 + // Downloading for real would navigate the jsdom window. + clickSpy = vi + .spyOn(HTMLAnchorElement.prototype, 'click') + .mockImplementation(() => {}) + }) + + afterEach(() => { + vi.runOnlyPendingTimers() + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('downloads via an anchor so the service worker does not intercept it', () => { + render() + + let anchor + clickSpy.mockImplementation(function () { + anchor = { href: this.href, download: this.download } + }) + + act(() => { + playerProps.customDownloader() + }) + + expect(clickSpy).toHaveBeenCalledTimes(1) + expect(anchor.href).toContain('/share/d/share-1') + // Empty, so the server's Content-Disposition filename wins. + expect(anchor.download).toBe('') + }) + + it('removes the anchor from the document after clicking', () => { + render() + + act(() => { + playerProps.customDownloader() + }) + + expect(document.querySelectorAll('a[download]')).toHaveLength(0) + }) + + // The inert styling itself is driven by JSS function values, which jsdom does + // not evaluate; it is verified in a browser. What is checked here is the + // state machine feeding it -- that the component re-renders on download and + // again when the window closes. + it('re-renders when the feedback window opens and closes', () => { + render() + + const beforeDownload = renderCount + act(() => { + playerProps.customDownloader() + }) + expect(renderCount).toBeGreaterThan(beforeDownload) + + const beforeExpiry = renderCount + act(() => { + vi.runAllTimers() + }) + expect(renderCount).toBeGreaterThan(beforeExpiry) + }) + + it('restarts the feedback window on a repeat download', () => { + render() + + act(() => { + playerProps.customDownloader() + }) + const elapsedBeforeRepeat = Math.floor(DOWNLOAD_FEEDBACK_MS / 2) + act(() => { + vi.advanceTimersByTime(elapsedBeforeRepeat) + }) + act(() => { + playerProps.customDownloader() + }) + + // Past the first timer's deadline, which it would have fired at had the + // repeat download not replaced it. + const beforeOriginalDeadline = renderCount + act(() => { + vi.advanceTimersByTime(DOWNLOAD_FEEDBACK_MS - elapsedBeforeRepeat + 1) + }) + expect(renderCount).toBe(beforeOriginalDeadline) + + act(() => { + vi.runAllTimers() + }) + expect(renderCount).toBeGreaterThan(beforeOriginalDeadline) + }) + + it('does not update state after unmount', () => { + const { unmount } = render() + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + act(() => { + playerProps.customDownloader() + }) + unmount() + act(() => { + vi.runAllTimers() + }) + + expect(errorSpy).not.toHaveBeenCalled() + }) +}) From add0a6dc9b8360db480f76793fa928499bf51741 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Wed, 29 Jul 2026 13:44:42 -0400 Subject: [PATCH 39/71] feat(config): warn about unrecognized options in the config file (#5870) * feat(config): warn about unrecognized options in the config file Options that don't match any known name were silently discarded, so a typo or an option written outside its section looked like it was applied. In #5869 the user set `ArtistSplitExceptions` at the root level instead of under `Scanner`, and got no feedback that the option was being ignored. The known names are derived by reflection over configOptions, so the check stays in sync with the struct. Free-form maps (Tags, DevLogLevels) accept any subkey. When an unknown key matches the last segment of a known one, the warning suggests it. Keys are reported as spelled in the config file, recovered by scanning it, since viper lowercases every key it loads. Also fixes two gaps this surfaced: - remapEnvVarKeysFromConfig accepted any ND_-prefixed key and advised a canonical name built by string substitution, so `ND_SCANNER_WATCHERENABLED` (not an option) suggested `Scanner.Watcherenabled` (not an option either). It now only advises names that exist, with their documented spelling, and leaves the rest to the unrecognized-option warning. - The deprecated option list drove only the warnings, while the value migration kept a second hardcoded list. They had drifted: SearchFullString warned about `Search.FullString` but never migrated to it. Both now come from deprecatedOptions. * fix(config): address Codex review on the unrecognized-option warning - Values computed during Load (ConfigFile, LastFM.Languages, Deezer.Languages) were accepted as valid keys, so setting them in the config file stayed silent even though Load overwrites them. They are now marked `conf:"-"` at the declaration, so the exclusion can't drift from the struct. - Removed options are in the known-key set only so they get their own warning, but suggestOptions drew from the same set, so an unknown `ID` advised `Spotify.ID`, a key Navidrome explicitly ignores. They are now filtered out of suggestions. - mapDeprecatedOption uses viper.Set, which outranks the config file, so a deprecated value overrode an explicitly configured replacement. It now skips the migration when the replacement was provided. viper.IsSet counts defaults as set, so the check is InConfig plus the env var. envVarName also returns "" for an empty option, so a deprecated option with no replacement no longer advises "Please use the new 'ND_'". * fix(config): cover the ND_ spelling of a replacement, and the warning output - explicitlySet missed the case where the replacement is given in the config file under its ND_ spelling: remapEnvVarKeysFromConfig moves it to the override layer, out of InConfig's reach, so the deprecated value still won. It now also checks the ND_-prefixed config key. - The tests asserted only the helpers' return values, so removing the logUnknownOptions call from Load left them green. Added a spec that captures the logger and checks the emitted warning and suggestion text; verified it fails when the call is removed. --- conf/configuration.go | 241 +++++++++++++++++++---- conf/configuration_test.go | 119 +++++++++++ conf/export_test.go | 4 + conf/testdata/cfg_deprecated_search.toml | 2 + conf/testdata/cfg_nd_bogus.toml | 3 + conf/testdata/cfg_runtime_fields.toml | 10 + conf/testdata/cfg_unknown_casing.ini | 3 + conf/testdata/cfg_unknown_casing.json | 4 + conf/testdata/cfg_unknown_casing.toml | 2 + conf/testdata/cfg_unknown_casing.yaml | 2 + conf/testdata/cfg_unknown_keys.toml | 18 ++ conf/testdata/cfg_warning_output.toml | 7 + 12 files changed, 380 insertions(+), 35 deletions(-) create mode 100644 conf/testdata/cfg_deprecated_search.toml create mode 100644 conf/testdata/cfg_nd_bogus.toml create mode 100644 conf/testdata/cfg_runtime_fields.toml create mode 100644 conf/testdata/cfg_unknown_casing.ini create mode 100644 conf/testdata/cfg_unknown_casing.json create mode 100644 conf/testdata/cfg_unknown_casing.toml create mode 100644 conf/testdata/cfg_unknown_casing.yaml create mode 100644 conf/testdata/cfg_unknown_keys.toml create mode 100644 conf/testdata/cfg_warning_output.toml diff --git a/conf/configuration.go b/conf/configuration.go index 83793bd43..9b49fb264 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -2,14 +2,18 @@ package conf import ( "cmp" + "encoding" "encoding/json" "fmt" "net/url" "os" "path/filepath" + "reflect" + "regexp" "runtime" "slices" "strings" + "sync" "time" "github.com/bmatcuk/doublestar/v4" @@ -21,11 +25,12 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/scheduler" "github.com/navidrome/navidrome/utils/run" + "github.com/navidrome/navidrome/utils/slice" "github.com/spf13/viper" ) type configOptions struct { - ConfigFile string + ConfigFile string `conf:"-"` Address string Port int UnixSocketPerm string @@ -201,7 +206,7 @@ type lastfmOptions struct { ScrobbleFirstArtistOnly bool // Computed values - Languages []string // Computed from Language, split by comma + Languages []string `conf:"-"` // Computed from Language, split by comma } type deezerOptions struct { @@ -209,7 +214,7 @@ type deezerOptions struct { Language string // Computed values - Languages []string // Computed from Language, split by comma + Languages []string `conf:"-"` // Computed from Language, split by comma } type listenBrainzOptions struct { @@ -340,12 +345,11 @@ func Load(noConfigDump bool) { remapEnvVarKeysFromConfig() // Map deprecated options to their new names for backwards compatibility - mapDeprecatedOption("ReverseProxyWhitelist", "ExtAuth.TrustedSources") - mapDeprecatedOption("ReverseProxyUserHeader", "ExtAuth.UserHeader") - mapDeprecatedOption("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions") - mapDeprecatedOption("CoverJpegQuality", "CoverArtQuality") - mapDeprecatedOption("SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold") - mapDeprecatedOption("EnableTranscodingCancellation", "Transcoding.EnableCancellation") + for _, o := range deprecatedOptions { + if o.replacement != "" { + mapDeprecatedOption(o.name, o.replacement) + } + } err := viper.Unmarshal(&Server, viper.DecodeHook( mapstructure.ComposeDecodeHookFunc( @@ -403,6 +407,13 @@ func Load(noConfigDump bool) { log.SetLogSourceLine(Server.DevLogSourceLine) log.SetRedacting(Server.EnableLogRedacting) + // Log deprecated, removed and unknown options + for _, o := range deprecatedOptions { + logDeprecatedOptions(o.name, o.replacement) + } + logRemovedOptions(removedOptions...) + logUnknownOptions() + err = run.Sequentially( validateScanSchedule, validateBackupSchedule, @@ -461,21 +472,6 @@ func Load(noConfigDump bool) { // Parse Deezer.Language into Languages slice (comma-separated, with fallback to DefaultInfoLanguage) Server.Deezer.Languages = parseLanguages(Server.Deezer.Language) - // Deprecated options - logDeprecatedOptions("Scanner.GenreSeparators", "") - logDeprecatedOptions("Scanner.GroupAlbumReleases", "") - logDeprecatedOptions("DevEnableBufferedScrobble", "") // Deprecated: Buffered scrobbling is now always enabled and this option is ignored - logDeprecatedOptions("SearchFullString", "Search.FullString") - logDeprecatedOptions("ReverseProxyWhitelist", "ExtAuth.TrustedSources") - logDeprecatedOptions("ReverseProxyUserHeader", "ExtAuth.UserHeader") - logDeprecatedOptions("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions") - logDeprecatedOptions("CoverJpegQuality", "CoverArtQuality") - logDeprecatedOptions("SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold") - logDeprecatedOptions("EnableTranscodingCancellation", "Transcoding.EnableCancellation") - - // Removed options - logRemovedOptions("Spotify.ID", "Spotify.Secret") - // Validate other options if Server.UICoverArtSize < 200 || Server.UICoverArtSize > 1200 { newValue := max(200, min(1200, Server.UICoverArtSize)) @@ -489,9 +485,26 @@ func Load(noConfigDump bool) { } } +// deprecatedOptions still work, but will be removed in a future release. An empty +// replacement means the option is now ignored. +var deprecatedOptions = []struct{ name, replacement string }{ + {"Scanner.GenreSeparators", ""}, + {"Scanner.GroupAlbumReleases", ""}, + {"DevEnableBufferedScrobble", ""}, + {"SearchFullString", "Search.FullString"}, + {"ReverseProxyWhitelist", "ExtAuth.TrustedSources"}, + {"ReverseProxyUserHeader", "ExtAuth.UserHeader"}, + {"HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions"}, + {"CoverJpegQuality", "CoverArtQuality"}, + {"SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold"}, + {"EnableTranscodingCancellation", "Transcoding.EnableCancellation"}, +} + +var removedOptions = []string{"Spotify.ID", "Spotify.Secret"} + func logDeprecatedOptions(oldName, newName string) { - envVar := "ND_" + strings.ToUpper(strings.ReplaceAll(oldName, ".", "_")) - newEnvVar := "ND_" + strings.ToUpper(strings.ReplaceAll(newName, ".", "_")) + envVar := envVarName(oldName) + newEnvVar := envVarName(newName) logWarning := func(oldName, newName string) { if newName != "" { log.Warn(fmt.Sprintf("Option '%s' is deprecated and will be ignored in a future release. Please use the new '%s'", oldName, newName)) @@ -511,7 +524,7 @@ func logDeprecatedOptions(oldName, newName string) { // not available anymore func logRemovedOptions(options ...string) { for _, option := range options { - envVar := "ND_" + strings.ToUpper(strings.ReplaceAll(option, ".", "_")) + envVar := envVarName(option) logWarning := func(option string) { log.Warn(fmt.Sprintf("Option '%s' is not available anymore and will be ignored. Please remove it from your config", option)) } @@ -532,35 +545,193 @@ func remapEnvVarKeysFromConfig() { continue } stripped := strings.TrimPrefix(key, "nd_") - canonicalKey := strings.ReplaceAll(stripped, "_", ".") + canonicalKey := ndKeyToCanonical(key) displayNDKey := "ND_" + strings.ToUpper(stripped) - displayCanonical := toPascalCase(canonicalKey) + canonicalName := canonicalOptionName(canonicalKey) if viper.InConfig(canonicalKey) { logFatal(fmt.Sprintf( "Config file contains both '%s' and '%s'. Remove the ND_-prefixed version. "+ "The 'ND_' prefix is only needed for environment variables, not config file keys.", - displayNDKey, displayCanonical, + displayNDKey, cmp.Or(canonicalName, toPascalCase(canonicalKey)), )) return } viper.Set(canonicalKey, viper.Get(key)) - _, _ = fmt.Fprintf(os.Stderr, "WARNING: Config key '%s' uses environment variable naming. Use '%s' instead. "+ - "The 'ND_' prefix is only needed for environment variables.\n", - displayNDKey, displayCanonical, - ) + // Unknown keys get no advice here, logUnknownOptions reports them instead + if canonicalName != "" { + _, _ = fmt.Fprintf(os.Stderr, "WARNING: Config key '%s' uses environment variable naming. Use '%s' instead. "+ + "The 'ND_' prefix is only needed for environment variables.\n", + displayNDKey, canonicalName, + ) + } } } // mapDeprecatedOption is used to provide backwards compatibility for deprecated options. It should be called after // the config has been read by viper, but before unmarshalling it into the Config struct. func mapDeprecatedOption(legacyName, newName string) { - if viper.IsSet(legacyName) { + // viper.Set outranks the config file, so an explicit replacement must win over the legacy value + if viper.IsSet(legacyName) && !explicitlySet(newName) { viper.Set(newName, viper.Get(legacyName)) } } +// explicitlySet reports whether the user provided the option, ignoring defaults, +// which viper.IsSet counts as set. The ND_ spelling is also accepted in the config +// file, and remapEnvVarKeysFromConfig has already moved it out of InConfig's reach. +func explicitlySet(name string) bool { + envVar := envVarName(name) + return viper.InConfig(name) || os.Getenv(envVar) != "" || viper.InConfig(strings.ToLower(envVar)) +} + +func envVarName(option string) string { + if option == "" { + return "" + } + return "ND_" + strings.ToUpper(strings.ReplaceAll(option, ".", "_")) +} + +func logUnknownOptions() { + for _, key := range unknownConfigKeys() { + msg := fmt.Sprintf("Option '%s' is not recognized and will be ignored", key) + if matches := suggestOptions(key); len(matches) > 0 { + msg += fmt.Sprintf(". Did you mean '%s'?", strings.Join(matches, "' or '")) + } + log.Warn(msg) + } +} + +// suggestOptions returns the known options sharing the last segment with key, +// catching options written outside their section. +func suggestOptions(key string) []string { + key = strings.ToLower(key) + leaf := leafKey(key) + canonical, _ := configKeys() + var matches []string + for known, name := range canonical { + // Removed options are known only so they get their own warning, never suggest them + if known != key && leafKey(known) == leaf && !slices.Contains(removedOptions, name) { + matches = append(matches, name) + } + } + slices.Sort(matches) + return matches +} + +func leafKey(key string) string { + return key[strings.LastIndex(key, ".")+1:] +} + +// unknownConfigKeys returns config file keys that don't match any known option, so +// typos and options written outside their section don't fail silently. +func unknownConfigKeys() []string { + // INI files keep the original [default] section alongside the merged one + skipDefault := strings.EqualFold(filepath.Ext(viper.ConfigFileUsed()), ".ini") + + var unknown []string + for _, key := range viper.AllKeys() { + if !viper.InConfig(key) || canonicalOptionName(key) != "" { + continue + } + if skipDefault && strings.HasPrefix(key, "default.") { + continue + } + // Only ND_-prefixed keys that remapEnvVarKeysFromConfig could resolve are valid + if strings.HasPrefix(key, "nd_") && canonicalOptionName(ndKeyToCanonical(key)) != "" { + continue + } + unknown = append(unknown, key) + } + slices.Sort(unknown) + return asWrittenInConfigFile(unknown) +} + +func ndKeyToCanonical(key string) string { + return strings.ReplaceAll(strings.TrimPrefix(key, "nd_"), "_", ".") +} + +// canonicalOptionName returns the documented spelling of a known option key, or "" +// if it matches no option. Subkeys of free-form maps have no fixed spelling. +func canonicalOptionName(key string) string { + keys, prefixes := configKeys() + if name, ok := keys[key]; ok { + return name + } + if slices.ContainsFunc(prefixes, func(p string) bool { return strings.HasPrefix(key, p) }) { + return toPascalCase(key) + } + return "" +} + +// asWrittenInConfigFile restores the casing the keys have in the config file, as +// viper lowercases every key it loads. +func asWrittenInConfigFile(keys []string) []string { + if len(keys) == 0 { + return nil + } + data, err := os.ReadFile(viper.ConfigFileUsed()) + if err != nil { + return keys + } + casing := map[string]string{} + for _, match := range configFileKeyRx.FindAllStringSubmatch(string(data), -1) { + for segment := range strings.SplitSeq(match[1], ".") { + lower := strings.ToLower(segment) + casing[lower] = cmp.Or(casing[lower], segment) + } + } + return slice.Map(keys, func(key string) string { + segments := strings.Split(key, ".") + for i, s := range segments { + segments[i] = cmp.Or(casing[s], s) + } + return strings.Join(segments, ".") + }) +} + +// Matches keys and section headers in all supported config formats. +var configFileKeyRx = regexp.MustCompile(`(?m)^\s*\[?\s*"?([\w.]+)"?\s*[]=:]`) + +// configKeys maps every accepted option name, lowercased, to its canonical spelling, +// plus the prefixes of free-form map options (Tags, DevLogLevels). +var configKeys = sync.OnceValues(func() (map[string]string, []string) { + keys := map[string]string{} + var prefixes []string + + var collect func(t reflect.Type, prefix string) + collect = func(t reflect.Type, prefix string) { + for field := range t.Fields() { + // `conf:"-"` marks values computed during Load, not settable in the config + if !field.IsExported() || field.Tag.Get("conf") == "-" { + continue + } + name := prefix + field.Name + if field.Type.Kind() == reflect.Struct && !reflect.PointerTo(field.Type).Implements(textUnmarshalerType) { + collect(field.Type, name+".") + continue + } + lower := strings.ToLower(name) + keys[lower] = name + if field.Type.Kind() == reflect.Map { + prefixes = append(prefixes, lower+".") + } + } + } + collect(reflect.TypeFor[configOptions](), "") + + for _, o := range deprecatedOptions { + keys[strings.ToLower(o.name)] = o.name + } + for _, o := range removedOptions { + keys[strings.ToLower(o)] = o + } + return keys, prefixes +}) + +var textUnmarshalerType = reflect.TypeFor[encoding.TextUnmarshaler]() + // parseIniFileConfiguration is used to parse the config file when it is in INI format. For INI files, it // would require a nested structure, so instead we unmarshal it to a map and then merge the nested [default] // section into the root level. diff --git a/conf/configuration_test.go b/conf/configuration_test.go index e43c91a4b..4eaa8e3d8 100644 --- a/conf/configuration_test.go +++ b/conf/configuration_test.go @@ -1,12 +1,14 @@ package conf_test import ( + "bytes" "fmt" "os" "path/filepath" "testing" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/spf13/viper" @@ -178,6 +180,123 @@ var _ = Describe("Configuration", func() { }) }) + Describe("unknownConfigKeys", func() { + BeforeEach(func() { + viper.Reset() + conf.SetViperDefaults() + viper.SetDefault("datafolder", GinkgoT().TempDir()) + viper.SetDefault("loglevel", "error") + conf.ResetConf() + }) + + It("reports misplaced and misspelled options, as spelled in the config file", func() { + conf.InitConfig(filepath.Join("testdata", "cfg_unknown_keys.toml"), false) + conf.Load(true) + + Expect(conf.UnknownConfigKeys()).To(ConsistOf( + "ArtistSplitExceptions", "EnableDownlods", "Whatever.Foo", + )) + }) + + DescribeTable("recovers the original casing in all supported formats", + func(file string) { + conf.InitConfig(filepath.Join("testdata", file), false) + conf.Load(true) + + Expect(conf.UnknownConfigKeys()).To(ConsistOf("NotAnOption")) + }, + Entry("TOML", "cfg_unknown_casing.toml"), + Entry("YAML", "cfg_unknown_casing.yaml"), + Entry("JSON", "cfg_unknown_casing.json"), + Entry("INI", "cfg_unknown_casing.ini"), + ) + + It("does not report valid, deprecated or free-form keys", func() { + conf.InitConfig(filepath.Join("testdata", "cfg.toml"), false) + conf.Load(true) + + Expect(conf.UnknownConfigKeys()).To(BeEmpty()) + }) + + It("does not report the [default] section of INI files", func() { + conf.InitConfig(filepath.Join("testdata", "cfg.ini"), false) + conf.Load(true) + + Expect(conf.UnknownConfigKeys()).To(BeEmpty()) + }) + + DescribeTable("SuggestOptions", + func(key string, expected []string) { + Expect(conf.SuggestOptions(key)).To(Equal(expected)) + }, + Entry("suggests the section of a misplaced option", "artistsplitexceptions", + []string{"Scanner.ArtistSplitExceptions"}), + Entry("suggests the section of a misplaced nested option", "backup.fuzzythreshold", + []string{"Matcher.FuzzyThreshold"}), + Entry("suggests every section defining the option", "schedule", + []string{"Backup.Schedule", "Scanner.Schedule"}), + Entry("suggests nothing for a typo", "enabledownlods", nil), + ) + + It("does not report ND_-prefixed keys, as they are remapped", func() { + conf.InitConfig(filepath.Join("testdata", "cfg_nd_keys.toml"), false) + conf.Load(true) + + Expect(conf.UnknownConfigKeys()).To(BeEmpty()) + }) + + It("reports ND_-prefixed keys that remap to no known option", func() { + conf.InitConfig(filepath.Join("testdata", "cfg_nd_bogus.toml"), false) + conf.Load(true) + + Expect(conf.UnknownConfigKeys()).To(ConsistOf("ND_TOTALLY_BOGUS_OPTION")) + Expect(conf.Server.Scanner.Schedule).To(Equal("@every 1h")) + }) + + It("migrates every deprecated option that has a replacement", func() { + conf.InitConfig(filepath.Join("testdata", "cfg_deprecated_search.toml"), false) + conf.Load(true) + + Expect(conf.Server.Search.FullString).To(BeTrue()) + Expect(conf.UnknownConfigKeys()).To(BeEmpty()) + }) + + It("warns about each unrecognized option at startup", func() { + var logBuf bytes.Buffer + log.SetOutput(&logBuf) + DeferCleanup(func() { log.SetOutput(GinkgoWriter) }) + + conf.InitConfig(filepath.Join("testdata", "cfg_warning_output.toml"), false) + conf.Load(true) + + Expect(logBuf.String()).To(ContainSubstring( + "Option 'ArtistSplitExceptions' is not recognized and will be ignored. " + + "Did you mean 'Scanner.ArtistSplitExceptions'?")) + Expect(logBuf.String()).To(ContainSubstring( + "Option 'EnableDownlods' is not recognized and will be ignored")) + Expect(logBuf.String()).ToNot(ContainSubstring("ArtistJoiner")) + }) + + Context("with runtime-computed and removed options in the config", func() { + BeforeEach(func() { + conf.InitConfig(filepath.Join("testdata", "cfg_runtime_fields.toml"), false) + conf.Load(true) + }) + + It("reports values computed during Load, which the config cannot set", func() { + Expect(conf.UnknownConfigKeys()).To(ContainElements("ConfigFile", "LastFM.Languages")) + }) + + It("never suggests a removed option", func() { + Expect(conf.SuggestOptions("id")).To(BeEmpty()) + }) + + It("keeps an explicit replacement over the deprecated value", func() { + Expect(conf.Server.Search.FullString).To(BeFalse()) + }) + }) + }) + Describe("logFatal", func() { var invalidPath string BeforeEach(func() { diff --git a/conf/export_test.go b/conf/export_test.go index acebca551..cbb64b3d0 100644 --- a/conf/export_test.go +++ b/conf/export_test.go @@ -32,3 +32,7 @@ func SetLogFatal(f func(...any)) func() { logFatal = f return func() { logFatal = old } } + +var UnknownConfigKeys = unknownConfigKeys + +var SuggestOptions = suggestOptions diff --git a/conf/testdata/cfg_deprecated_search.toml b/conf/testdata/cfg_deprecated_search.toml new file mode 100644 index 000000000..cc6541e09 --- /dev/null +++ b/conf/testdata/cfg_deprecated_search.toml @@ -0,0 +1,2 @@ +MusicFolder = "/toml/music" +SearchFullString = true diff --git a/conf/testdata/cfg_nd_bogus.toml b/conf/testdata/cfg_nd_bogus.toml new file mode 100644 index 000000000..841b40998 --- /dev/null +++ b/conf/testdata/cfg_nd_bogus.toml @@ -0,0 +1,3 @@ +MusicFolder = "/toml/music" +ND_TOTALLY_BOGUS_OPTION = true +ND_SCANNER_SCHEDULE = "@every 1h" diff --git a/conf/testdata/cfg_runtime_fields.toml b/conf/testdata/cfg_runtime_fields.toml new file mode 100644 index 000000000..a9c09f619 --- /dev/null +++ b/conf/testdata/cfg_runtime_fields.toml @@ -0,0 +1,10 @@ +MusicFolder = "/toml/music" +SearchFullString = true +ConfigFile = "/somewhere/else" +ID = "oops" + +[Search] +FullString = false + +[LastFM] +Languages = ["pt"] diff --git a/conf/testdata/cfg_unknown_casing.ini b/conf/testdata/cfg_unknown_casing.ini new file mode 100644 index 000000000..c88db9b1f --- /dev/null +++ b/conf/testdata/cfg_unknown_casing.ini @@ -0,0 +1,3 @@ +[default] +MusicFolder = /ini/music +NotAnOption = true diff --git a/conf/testdata/cfg_unknown_casing.json b/conf/testdata/cfg_unknown_casing.json new file mode 100644 index 000000000..cd3a226cd --- /dev/null +++ b/conf/testdata/cfg_unknown_casing.json @@ -0,0 +1,4 @@ +{ + "MusicFolder": "/json/music", + "NotAnOption": true +} diff --git a/conf/testdata/cfg_unknown_casing.toml b/conf/testdata/cfg_unknown_casing.toml new file mode 100644 index 000000000..f015c9cb2 --- /dev/null +++ b/conf/testdata/cfg_unknown_casing.toml @@ -0,0 +1,2 @@ +MusicFolder = "/toml/music" +NotAnOption = true diff --git a/conf/testdata/cfg_unknown_casing.yaml b/conf/testdata/cfg_unknown_casing.yaml new file mode 100644 index 000000000..8987f6351 --- /dev/null +++ b/conf/testdata/cfg_unknown_casing.yaml @@ -0,0 +1,2 @@ +MusicFolder: /yaml/music +NotAnOption: true diff --git a/conf/testdata/cfg_unknown_keys.toml b/conf/testdata/cfg_unknown_keys.toml new file mode 100644 index 000000000..a7ed55cb0 --- /dev/null +++ b/conf/testdata/cfg_unknown_keys.toml @@ -0,0 +1,18 @@ +MusicFolder = "/toml/music" + +# Valid option, but written at the root level instead of under Scanner +ArtistSplitExceptions = ["AC/DC", "Tyler, the creator"] + +# Misspelled option +EnableDownlods = true + +# Unknown section +[Whatever] +Foo = "bar" + +# Valid options, must not be reported +[Scanner] +ArtistJoiner = " • " + +[Tags.custom] +aliases = ["toml", "test"] diff --git a/conf/testdata/cfg_warning_output.toml b/conf/testdata/cfg_warning_output.toml new file mode 100644 index 000000000..a52c5ea54 --- /dev/null +++ b/conf/testdata/cfg_warning_output.toml @@ -0,0 +1,7 @@ +MusicFolder = "/toml/music" +LogLevel = "warn" +ArtistSplitExceptions = ["AC/DC"] +EnableDownlods = true + +[Scanner] +ArtistJoiner = " • " From 4f29465d27d35444683bd7fae70097583ab4b01f Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:48:42 +0000 Subject: [PATCH 40/71] fix(artwork): do not promote artist folder to album folder (#5856) * fix(artwork): do not promote artist folder to album folder * use filesystem join for path instead * fix(artwork): resolve artist folder for albums with disc subfolders Dropping the promoted album root from the artist reader's paths fixed the flat-album case but broke albums whose tracks live in disc subfolders: the promoted root was what kept the byte-wise longest common prefix on a directory boundary. Without it, CD1/CD2 siblings share the fragment "Album/CD", so the artist folder resolved one level too deep and album/artist.* won over artist.*. loadAlbumFoldersPaths now collapses each album to its own root before the paths are compared across albums, so a disc-split album contributes its album folder rather than each disc folder. Folders no album claims (the promoted parent) are still returned unchanged, keeping the album, disc and mediafile readers unaffected. The prefix math moves into commonDir, which appends a trailing separator so the comparison lands on segment boundaries - this also fixes sibling folders sharing a name prefix (Album/Album2). Adds e2e coverage for the case the branch fixes (artist.* with no album/ fallback, which failed before this branch), the disc-subfolder regression, and a multi-album guard that pins the scope boundary. * test(artwork): cover artist whose albums are all disc-split The existing specs cover a single disc-split album and a disc-split album alongside a flat one, but not an artist where every album is split into disc subfolders. That layout resolves correctly because the albums diverge one level above the disc folders, which re-anchors the common prefix - a property worth pinning so a future change to the path math can't silently break it. * fix(artwork): resolve artist folder when albums share a folder loadArtistFolder decided whether to climb above the common directory by comparing len(paths) against 2. That count stands in for "how many distinct album roots are there", and the two diverge when an artist has two albums in the same folder: the slice holds two identical entries, the climb is skipped, and the artist folder resolves to the album folder. Deduplicate the roots so the branch tests the property it means to test. Replace the includeParent flag with two functions split by audience. The flag was a caller-identity switch - constant true at the album, disc and mediafile readers, all of which discard the returned paths, and constant false at the only caller that reads them. The artist path now has loadArtistAlbumRoots, which collapses each album to its own root and never consults albumRootParent; loadAlbumFoldersPaths goes back to taking a single album and always promoting the parent. Both share the folder load and image aggregation. This also makes albumRootParent's contract structural. Its guard only rejects an artist folder when it can find audio outside the album being resolved, so passing every album of an artist at once made the check meaningless; taking a single album means it can no longer be called that way. Drops the per-album grouping and unclaimed-folder reconciliation from the album path, where the result was discarded, and removes 15 mechanical true arguments from the tests. --------- Co-authored-by: Deluan --- core/artwork/artwork_internal_test.go | 1 + core/artwork/e2e/artist_test.go | 95 +++++++++++++++++++++++++++ core/artwork/reader_album.go | 32 ++++----- core/artwork/reader_album_test.go | 27 ++++++++ core/artwork/reader_artist.go | 58 ++++++++++++++-- core/artwork/reader_artist_test.go | 13 ++++ 6 files changed, 206 insertions(+), 20 deletions(-) diff --git a/core/artwork/artwork_internal_test.go b/core/artwork/artwork_internal_test.go index c95371959..5bcd82f23 100644 --- a/core/artwork/artwork_internal_test.go +++ b/core/artwork/artwork_internal_test.go @@ -202,6 +202,7 @@ var _ = Describe("Artwork", func() { repoRoot, err := os.Getwd() Expect(err).ToNot(HaveOccurred()) folderRepo.result = []model.Folder{{ + ID: "f1", LibraryPath: testFileLibPath(repoRoot), Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"artist.png"}, diff --git a/core/artwork/e2e/artist_test.go b/core/artwork/e2e/artist_test.go index d959b1d60..2079d37ce 100644 --- a/core/artwork/e2e/artist_test.go +++ b/core/artwork/e2e/artist_test.go @@ -80,6 +80,101 @@ var _ = Describe("Artist artwork resolution", func() { }) }) + When("ArtistArtPriority has no album/ fallback", func() { + // Artist/ + // ├── artist.jpg ← must resolve via the artist folder itself + // └── Album/ + // └── 01 - Track.mp3 + It("still resolves the artist folder and returns artist.*", func() { + conf.Server.ArtistArtPriority = "artist.*" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), + "Artist/artist.jpg": imageFile("artist-folder"), + }) + scan() + + ar := soleArtist() + artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) + Expect(readArtwork(artID)).To(Equal(imageBytes("artist-folder"))) + }) + }) + + When("the artist's only album has its tracks in disc subfolders", func() { + // Artist/ + // ├── artist.jpg ← wins (artist.* before album/artist.*) + // └── Album/ + // ├── artist.jpg + // ├── CD1/01 - Track.mp3 + // └── CD2/02 - Track.mp3 + It("prefers the artist-folder image over the album-folder one", func() { + conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external" + setLayout(fstest.MapFS{ + "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track 1", map[string]any{"albumartist": "Artist", "album": "Album"}), + "Artist/Album/CD2/02 - Track.mp3": trackFile(2, "Track 2", map[string]any{"albumartist": "Artist", "album": "Album"}), + "Artist/artist.jpg": imageFile("artist-folder"), + "Artist/Album/artist.jpg": imageFile("album-artist"), + }) + scan() + + ar := soleArtist() + artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) + Expect(readArtwork(artID)).To(Equal(imageBytes("artist-folder"))) + }) + }) + + When("one album has disc subfolders and another sits at artist level", func() { + // Artist/ + // ├── artist.jpg ← wins + // ├── Album1/ + // │ ├── artist.jpg + // │ ├── CD1/01 - Track.mp3 + // │ └── CD2/02 - Track.mp3 + // └── Album2/03 - Track.mp3 + It("prefers the artist-folder image over the album-folder one", func() { + conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external" + setLayout(fstest.MapFS{ + "Artist/Album1/CD1/01 - Track.mp3": trackFile(1, "Track 1", map[string]any{"albumartist": "Artist", "album": "Album1"}), + "Artist/Album1/CD2/02 - Track.mp3": trackFile(2, "Track 2", map[string]any{"albumartist": "Artist", "album": "Album1"}), + "Artist/Album2/03 - Track.mp3": trackFile(3, "Track 3", map[string]any{"albumartist": "Artist", "album": "Album2"}), + "Artist/artist.jpg": imageFile("artist-folder"), + "Artist/Album1/artist.jpg": imageFile("album-artist"), + }) + scan() + + ar := soleArtist() + artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) + Expect(readArtwork(artID)).To(Equal(imageBytes("artist-folder"))) + }) + }) + + When("every album of the artist has its tracks in disc subfolders", func() { + // Artist/ + // ├── artist.jpg ← wins + // ├── Album1/ + // │ ├── artist.jpg + // │ ├── CD1/01 - Track.mp3 + // │ └── CD2/02 - Track.mp3 + // └── Album2/ + // ├── CD1/03 - Track.mp3 + // └── CD2/04 - Track.mp3 + It("prefers the artist-folder image over the album-folder one", func() { + conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external" + setLayout(fstest.MapFS{ + "Artist/Album1/CD1/01 - Track.mp3": trackFile(1, "Track 1", map[string]any{"albumartist": "Artist", "album": "Album1"}), + "Artist/Album1/CD2/02 - Track.mp3": trackFile(2, "Track 2", map[string]any{"albumartist": "Artist", "album": "Album1"}), + "Artist/Album2/CD1/03 - Track.mp3": trackFile(3, "Track 3", map[string]any{"albumartist": "Artist", "album": "Album2"}), + "Artist/Album2/CD2/04 - Track.mp3": trackFile(4, "Track 4", map[string]any{"albumartist": "Artist", "album": "Album2"}), + "Artist/artist.jpg": imageFile("artist-folder"), + "Artist/Album1/artist.jpg": imageFile("album-artist"), + }) + scan() + + ar := soleArtist() + artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) + Expect(readArtwork(artID)).To(Equal(imageBytes("artist-folder"))) + }) + }) + When("an artist has an uploaded image and a matching artist.* file", func() { // / // └── artwork/ diff --git a/core/artwork/reader_album.go b/core/artwork/reader_album.go index 8ad07773b..58a47c8ae 100644 --- a/core/artwork/reader_album.go +++ b/core/artwork/reader_album.go @@ -20,6 +20,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils" "github.com/navidrome/navidrome/utils/natural" + "github.com/navidrome/navidrome/utils/slice" ) type albumArtworkReader struct { @@ -103,17 +104,13 @@ func (a *albumArtworkReader) fromCoverArtPriority(ctx context.Context, ffmpeg ff return ff } -func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...model.Album) ([]string, []string, *time.Time, error) { - var folderIDs []string - for _, album := range albums { - folderIDs = append(folderIDs, album.FolderIDs...) - } - folders, err := ds.Folder(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"folder.id": folderIDs, "missing": false}}) +func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, album model.Album) ([]string, []string, *time.Time, error) { + folders, err := loadFolders(ctx, ds, album.FolderIDs) if err != nil { return nil, nil, nil, err } - parent, err := albumRootParent(ctx, ds, folders, folderIDs) + parent, err := albumRootParent(ctx, ds, folders, album.FolderIDs) if err != nil { return nil, nil, nil, err } @@ -121,11 +118,21 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...mo folders = append(folders, *parent) } - var paths []string + paths := slice.Map(folders, func(f model.Folder) string { return f.AbsolutePath() }) + imgFiles, updatedAt := folderImages(folders) + return paths, imgFiles, &updatedAt, nil +} + +func loadFolders(ctx context.Context, ds model.DataStore, folderIDs []string) ([]model.Folder, error) { + return ds.Folder(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"folder.id": folderIDs, "missing": false}}) +} + +// folderImages collects the folders' image files, sorted so files without +// numeric suffixes win (e.g. cover.jpg over cover.1.jpg). +func folderImages(folders []model.Folder) ([]string, time.Time) { var imgFiles []string var updatedAt time.Time for _, f := range folders { - paths = append(paths, f.AbsolutePath()) if f.ImagesUpdatedAt.After(updatedAt) { updatedAt = f.ImagesUpdatedAt } @@ -134,13 +141,8 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...mo imgFiles = append(imgFiles, path.Join(rel, img)) } } - - // Sort image files to ensure consistent selection of cover art - // This prioritizes files without numeric suffixes (e.g., cover.jpg over cover.1.jpg) - // by comparing base filenames without extensions slices.SortFunc(imgFiles, compareImageFiles) - - return paths, imgFiles, &updatedAt, nil + return imgFiles, updatedAt } // albumRootParent returns the common parent of the album's folders when it diff --git a/core/artwork/reader_album_test.go b/core/artwork/reader_album_test.go index fe4a1a545..c95ddedaa 100644 --- a/core/artwork/reader_album_test.go +++ b/core/artwork/reader_album_test.go @@ -3,6 +3,7 @@ package artwork import ( "context" "errors" + "path/filepath" "time" "github.com/navidrome/navidrome/model" @@ -339,6 +340,32 @@ var _ = Describe("Album Artwork Reader", func() { Expect(repo.getCallCount).To(Equal(1)) }) + It("promotes the album root parent into the returned paths", func() { + repo.result = []model.Folder{ + { + ID: "folder1", + Path: "Artist", + Name: "Album", + ParentID: "artistFolder", + ImagesUpdatedAt: now, + ImageFiles: []string{}, + }, + } + repo.parentResult = &model.Folder{ + ID: "artistFolder", + Path: ".", + Name: "Artist", + ParentID: "libraryRoot", + ImagesUpdatedAt: expectedAt, + ImageFiles: []string{"folder.jpg"}, + } + + paths, _, _, err := loadAlbumFoldersPaths(ctx, ds, album) + + Expect(err).ToNot(HaveOccurred()) + Expect(paths).To(Equal([]string{filepath.Join("Artist", "Album"), "Artist"})) + }) + It("does not include parent images when other albums' audio lives under the parent", func() { // Simulates: Artist/folder.jpg with Artist/Album (no images) and // another album's tracks elsewhere under the artist folder diff --git a/core/artwork/reader_artist.go b/core/artwork/reader_artist.go index 37b7b6dee..fe5bcb196 100644 --- a/core/artwork/reader_artist.go +++ b/core/artwork/reader_artist.go @@ -19,6 +19,7 @@ import ( "github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/slice" "github.com/navidrome/navidrome/utils/str" ) @@ -54,7 +55,7 @@ func newArtistArtworkReader(ctx context.Context, artwork *artwork, artID model.A if err != nil { return nil, err } - albumPaths, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, artwork.ds, als...) + albumPaths, imgFiles, imagesUpdatedAt, err := loadArtistAlbumRoots(ctx, artwork.ds, als) if err != nil { return nil, err } @@ -232,17 +233,64 @@ func escapeGlobLiteral(s string) string { return b.String() } +// loadArtistAlbumRoots returns one path per album — the deepest folder holding +// all of that album's tracks — so an album split into disc subfolders can't +// pull the artist folder's common prefix below the artist level. +func loadArtistAlbumRoots(ctx context.Context, ds model.DataStore, albums model.Albums) ([]string, []string, *time.Time, error) { + var folderIDs []string + for _, album := range albums { + folderIDs = append(folderIDs, album.FolderIDs...) + } + folders, err := loadFolders(ctx, ds, folderIDs) + if err != nil { + return nil, nil, nil, err + } + + pathByID := slice.ToMap(folders, func(f model.Folder) (string, string) { + return f.ID, f.AbsolutePath() + }) + var roots []string + for _, album := range albums { + var albumPaths []string + for _, fid := range album.FolderIDs { + if p, ok := pathByID[fid]; ok { + albumPaths = append(albumPaths, p) + } + } + if len(albumPaths) > 0 { + roots = append(roots, commonDir(albumPaths)) + } + } + + imgFiles, updatedAt := folderImages(folders) + return roots, imgFiles, &updatedAt, nil +} + +// commonDir returns the deepest directory containing all paths. Trailing +// separators keep the comparison on segment boundaries, so a shared name +// fragment (".../Album" and ".../Album2") is never read as a shared directory. +func commonDir(paths []string) string { + sep := string(filepath.Separator) + common := str.LongestCommonPrefix(slice.Map(paths, func(p string) string { return p + sep })) + if !strings.HasSuffix(common, sep) { + common, _ = filepath.Split(common) + } + return filepath.Clean(common) +} + func loadArtistFolder(ctx context.Context, ds model.DataStore, albums model.Albums, paths []string) (string, time.Time, error) { if len(albums) == 0 { return "", time.Time{}, nil } libID := albums[0].LibraryID // Just need one of the albums, as they should all be in the same Library - for now! TODO: Support multiple libraries - folderPath := str.LongestCommonPrefix(paths) - if !strings.HasSuffix(folderPath, string(filepath.Separator)) { - folderPath, _ = filepath.Split(folderPath) + // paths holds one root per album: two or more distinct roots already meet at + // the artist folder, while a single root is an album folder needing a climb. + roots := slices.Compact(slices.Sorted(slices.Values(paths))) + folderPath := commonDir(roots) + if len(roots) < 2 { + folderPath = filepath.Dir(folderPath) } - folderPath = filepath.Dir(folderPath) // Manipulate the path to get the folder ID // TODO: This is a bit hacky, but it's the easiest way to get the folder ID, ATM diff --git a/core/artwork/reader_artist_test.go b/core/artwork/reader_artist_test.go index 6d6d58fc5..c75f8447e 100644 --- a/core/artwork/reader_artist_test.go +++ b/core/artwork/reader_artist_test.go @@ -85,6 +85,19 @@ var _ = Describe("artistArtworkReader", func() { }) }) + When("two albums share the same folder", func() { + It("climbs above the shared album folder", func() { + paths = []string{ + filepath.FromSlash("/music/artist/split"), + filepath.FromSlash("/music/artist/split"), + } + folder, upd, err := loadArtistFolder(ctx, fds, albums, paths) + Expect(err).ToNot(HaveOccurred()) + Expect(folder).To(Equal(filepath.FromSlash("/music/artist"))) + Expect(upd).To(Equal(expectedUpdTime)) + }) + }) + When("the album paths contain same prefix", func() { It("returns the common prefix", func() { paths = []string{ From 600ea5482c36d3705fbca1d9ab749d9bdafd6f80 Mon Sep 17 00:00:00 2001 From: fxj368 <62541194+fxj368@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:36:37 +0800 Subject: [PATCH 41/71] fix(ui): update Chinese (Simplified) translation (#5873) Signed-off-by: Deluan --- resources/i18n/zh-Hans.json | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/resources/i18n/zh-Hans.json b/resources/i18n/zh-Hans.json index 21778506a..24f55357a 100644 --- a/resources/i18n/zh-Hans.json +++ b/resources/i18n/zh-Hans.json @@ -32,7 +32,7 @@ "rating": "评分", "quality": "品质", "bpm": "BPM", - "playDate": "最后一次播放", + "playDate": "上次播放", "createdAt": "加入日期", "grouping": "分组", "mood": "情绪", @@ -184,7 +184,7 @@ "maxBitRate": "最大比特率", "client": "客户端", "userName": "用户名", - "lastSeen": "上次浏览", + "lastSeen": "上次使用", "reportRealPath": "报告真实路径", "scrobbleEnabled": "发送个性化记录到外部服务" } @@ -210,7 +210,8 @@ "songCount": "歌曲数", "comment": "注释", "sync": "自动导入", - "path": "导入路径" + "path": "导入路径", + "starred": "收藏" }, "actions": { "selectPlaylist": "选择歌单", @@ -530,7 +531,7 @@ "no_results": "未找到结果", "no_more_results": "页码 %{page} 超出范围,尝试返回上一页", "page_out_of_boundaries": "页码 %{page} 超出范围", - "page_out_from_end": "已经最后一页", + "page_out_from_end": "已经是最后一页", "page_out_from_begin": "已经是第一页", "page_range_info": "%{offsetBegin}-%{offsetEnd} / %{total}", "page_rows_per_page": "每页项目数:", @@ -636,6 +637,7 @@ "albumList": "专辑", "playlists": "歌单", "sharedPlaylists": "共享的歌单", + "onlyFavourites": "仅显示收藏项", "about": "关于" }, "player": { @@ -668,10 +670,10 @@ "homepage": "主页", "source": "源代码", "featureRequests": "功能需求", - "lastInsightsCollection": "最近的分析收集", + "lastInsightsCollection": "上次的匿名数据收集", "insights": { - "disabled": "禁用", - "waiting": "等待" + "disabled": "已禁用", + "waiting": "等待中" } }, "tabs": { From d23b68a4385d42b647cb2c349ba5e1ac36fc4c1e Mon Sep 17 00:00:00 2001 From: Mateusz Piotrowski Date: Fri, 31 Jul 2026 14:54:29 +0200 Subject: [PATCH 42/71] fix: typo in description of --prometheus.enabled (#5878) Remove a stray back tick. --- cmd/root.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/root.go b/cmd/root.go index 9e2b38cd8..d9e766c32 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -394,7 +394,7 @@ func init() { rootCmd.Flags().String("albumplaycountmode", viper.GetString("albumplaycountmode"), "how to compute playcount for albums. absolute (default) or normalized") rootCmd.Flags().Bool("autoimportplaylists", viper.GetBool("autoimportplaylists"), "enable/disable .m3u playlist auto-import`") - rootCmd.Flags().Bool("prometheus.enabled", viper.GetBool("prometheus.enabled"), "enable/disable prometheus metrics endpoint`") + rootCmd.Flags().Bool("prometheus.enabled", viper.GetBool("prometheus.enabled"), "enable/disable prometheus metrics endpoint") rootCmd.Flags().String("prometheus.metricspath", viper.GetString("prometheus.metricspath"), "http endpoint for prometheus metrics") _ = viper.BindPFlag("address", rootCmd.Flags().Lookup("address")) From 810b14ed571412e7a7ddb568ebee69ac1498faea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 2 Aug 2026 12:27:08 -0400 Subject: [PATCH 43/71] fix(plugins): confine plugin filesystem mounts to their root (#5881) * fix(plugins): confine plugin filesystem mounts to their root A plugin granted read-write filesystem access could escape its mount by creating a relative symlink inside it and then writing through that link, reaching any path the server process can write, including navidrome.db. wazero resolves guest paths by concatenating them onto the host root. Its WASI layer validates every path argument except the symlink target, which path_symlink forwards unvalidated by design, and fs.ValidPath splits on "/" only, so on Windows a "..\" path escapes the mount as well. Mounts now go through a jailedFS wrapper that denies symlink creation and rejects any path that is not filepath.IsLocal. That requires bypassing extism's AllowedPaths, which discards any FSConfig passed alongside it, so the mounts are built directly and applied per instance instead. Following symlinks that already exist in a mount is unchanged: music libraries rely on it, and read-only mounts already reject creating new ones. * test(plugins): guard against setting extism AllowedPaths Extracts the extism manifest construction so a test can assert AllowedPaths is never set. Setting it makes extism build its own FSConfig and discard the jailed mounts, silently restoring the symlink escape. Verified by simulating the regression: with AllowedPaths populated for plugins holding the filesystem permission, the new spec fails, as do two of the end-to-end sandbox specs. --- plugins/manager_loader.go | 59 +++------ plugins/manager_loader_test.go | 86 ++++-------- plugins/manager_plugin.go | 16 ++- plugins/plugins_suite_test.go | 62 +++++---- plugins/sandbox_fs.go | 157 ++++++++++++++++++++++ plugins/sandbox_fs_internal_test.go | 127 ++++++++++++++++++ plugins/sandbox_fs_test.go | 182 ++++++++++++++++++++++++++ plugins/testdata/test-library/main.go | 26 +++- 8 files changed, 572 insertions(+), 143 deletions(-) create mode 100644 plugins/sandbox_fs.go create mode 100644 plugins/sandbox_fs_internal_test.go create mode 100644 plugins/sandbox_fs_test.go diff --git a/plugins/manager_loader.go b/plugins/manager_loader.go index 0ba5fcf76..fc9296427 100644 --- a/plugins/manager_loader.go +++ b/plugins/manager_loader.go @@ -307,22 +307,10 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { return fmt.Errorf("opening package: %w", err) } - // Build extism manifest - pluginManifest := extism.Manifest{ - Wasm: []extism.Wasm{ - extism.WasmData{Data: pkg.WasmBytes, Name: "main"}, - }, - Config: pluginConfig, - Timeout: uint64(defaultTimeout.Milliseconds()), - } + pluginManifest := buildExtismManifest(pkg, pluginConfig) - if pkg.Manifest.Permissions != nil && pkg.Manifest.Permissions.Http != nil { - if hosts := pkg.Manifest.Permissions.Http.RequiredHosts; len(hosts) > 0 { - pluginManifest.AllowedHosts = hosts - } - } - - // Configure filesystem access for library permission + // Configure filesystem access for library permission, applied per instance + var fsConfig wazero.FSConfig if pkg.Manifest.HasLibraryFilesystemPermission() { adminCtx := adminContext(ctx) libraries, err := m.ds.Library(adminCtx).GetAll() @@ -330,8 +318,7 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { return fmt.Errorf("failed to get libraries for filesystem access: %w", err) } - allowedPaths := buildAllowedPaths(ctx, libraries, allowedLibraries, p.AllLibraries, p.AllowWriteAccess) - pluginManifest.AllowedPaths = allowedPaths + fsConfig = buildFSConfig(buildMounts(ctx, libraries, allowedLibraries, p.AllLibraries, p.AllowWriteAccess)) } // Build host functions based on permissions from manifest @@ -386,7 +373,7 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { } // Create instance to detect capabilities - instance, err := compiled.Instance(ctx, extism.PluginInstanceConfig{}) + instance, err := compiled.Instance(ctx, instanceConfig(fsConfig)) if err != nil { compiled.Close(ctx) return fmt.Errorf("creating instance: %w", err) @@ -413,6 +400,7 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { allowedUserIDs: allowedUsers, allUsers: p.AllUsers, libraries: newLibraryAccess(allowedLibraries, p.AllLibraries), + fsConfig: fsConfig, lyricsSem: make(chan struct{}, maxConcurrentLyricsCalls), } m.mu.Unlock() @@ -459,31 +447,18 @@ func parsePluginConfig(configJSON string) (map[string]string, error) { return pluginConfig, nil } -// buildAllowedPaths constructs the extism AllowedPaths map for filesystem access. -// When allowWriteAccess is false (default), paths are prefixed with "ro:" for read-only. -// Only libraries that match the allowed set (or all libraries if allLibraries is true) are included. -func buildAllowedPaths(ctx context.Context, libraries model.Libraries, allowedLibraryIDs []int, allLibraries, allowWriteAccess bool) map[string]string { - allowedLibrarySet := make(map[int]struct{}, len(allowedLibraryIDs)) - for _, id := range allowedLibraryIDs { - allowedLibrarySet[id] = struct{}{} +// buildExtismManifest describes the plugin to extism. It must never set +// AllowedPaths: extism would replace our jailed FSConfig with plain dir mounts. +func buildExtismManifest(pkg *ndpPackage, pluginConfig map[string]string) extism.Manifest { + manifest := extism.Manifest{ + Wasm: []extism.Wasm{extism.WasmData{Data: pkg.WasmBytes, Name: "main"}}, + Config: pluginConfig, + Timeout: uint64(defaultTimeout.Milliseconds()), } - allowedPaths := make(map[string]string) - for _, lib := range libraries { - _, allowed := allowedLibrarySet[lib.ID] - if allLibraries || allowed { - mountPoint := toPluginMountPoint(int32(lib.ID)) - hostPath := lib.Path - if !allowWriteAccess { - hostPath = "ro:" + hostPath - } - allowedPaths[hostPath] = mountPoint - log.Trace(ctx, "Added library to allowed paths", "libraryID", lib.ID, "mountPoint", mountPoint, "writeAccess", allowWriteAccess, "hostPath", hostPath) + if pkg.Manifest.Permissions != nil && pkg.Manifest.Permissions.Http != nil { + if hosts := pkg.Manifest.Permissions.Http.RequiredHosts; len(hosts) > 0 { + manifest.AllowedHosts = hosts } } - if allowWriteAccess { - log.Info(ctx, "Granting read-write filesystem access to libraries", "libraryCount", len(allowedPaths), "allLibraries", allLibraries) - } else { - log.Debug(ctx, "Granting read-only filesystem access to libraries", "libraryCount", len(allowedPaths), "allLibraries", allLibraries) - } - return allowedPaths + return manifest } diff --git a/plugins/manager_loader_test.go b/plugins/manager_loader_test.go index cc07f0611..5e6851481 100644 --- a/plugins/manager_loader_test.go +++ b/plugins/manager_loader_test.go @@ -1,11 +1,32 @@ package plugins import ( - "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) +var _ = Describe("buildExtismManifest", func() { + var pkg *ndpPackage + + BeforeEach(func() { + pkg = &ndpPackage{ + WasmBytes: []byte("wasm"), + Manifest: &Manifest{Permissions: &Permissions{ + Library: &LibraryPermission{Reason: new("test"), Filesystem: true}, + Http: &HTTPPermission{Reason: new("test"), RequiredHosts: []string{"example.com"}}, + }}, + } + }) + + It("never sets AllowedPaths, even with filesystem permission", func() { + Expect(buildExtismManifest(pkg, nil).AllowedPaths).To(BeEmpty()) + }) + + It("carries the hosts the plugin is allowed to reach", func() { + Expect(buildExtismManifest(pkg, nil).AllowedHosts).To(Equal([]string{"example.com"})) + }) +}) + var _ = Describe("parsePluginConfig", func() { It("returns nil for empty string", func() { result, err := parsePluginConfig("") @@ -57,66 +78,3 @@ var _ = Describe("parsePluginConfig", func() { Expect(result).ToNot(BeNil()) }) }) - -var _ = Describe("buildAllowedPaths", func() { - var libraries model.Libraries - - BeforeEach(func() { - libraries = model.Libraries{ - {ID: 1, Path: "/music/library1"}, - {ID: 2, Path: "/music/library2"}, - {ID: 3, Path: "/music/library3"}, - } - }) - - Context("read-only (default)", func() { - It("mounts all libraries with ro: prefix when allLibraries is true", func() { - result := buildAllowedPaths(nil, libraries, nil, true, false) - Expect(result).To(HaveLen(3)) - Expect(result).To(HaveKeyWithValue("ro:/music/library1", "/libraries/1")) - Expect(result).To(HaveKeyWithValue("ro:/music/library2", "/libraries/2")) - Expect(result).To(HaveKeyWithValue("ro:/music/library3", "/libraries/3")) - }) - - It("mounts only selected libraries with ro: prefix", func() { - result := buildAllowedPaths(nil, libraries, []int{1, 3}, false, false) - Expect(result).To(HaveLen(2)) - Expect(result).To(HaveKeyWithValue("ro:/music/library1", "/libraries/1")) - Expect(result).To(HaveKeyWithValue("ro:/music/library3", "/libraries/3")) - Expect(result).ToNot(HaveKey("ro:/music/library2")) - }) - }) - - Context("read-write (allowWriteAccess=true)", func() { - It("mounts all libraries without ro: prefix when allLibraries is true", func() { - result := buildAllowedPaths(nil, libraries, nil, true, true) - Expect(result).To(HaveLen(3)) - Expect(result).To(HaveKeyWithValue("/music/library1", "/libraries/1")) - Expect(result).To(HaveKeyWithValue("/music/library2", "/libraries/2")) - Expect(result).To(HaveKeyWithValue("/music/library3", "/libraries/3")) - }) - - It("mounts only selected libraries without ro: prefix", func() { - result := buildAllowedPaths(nil, libraries, []int{2}, false, true) - Expect(result).To(HaveLen(1)) - Expect(result).To(HaveKeyWithValue("/music/library2", "/libraries/2")) - }) - }) - - Context("edge cases", func() { - It("returns empty map when no libraries match", func() { - result := buildAllowedPaths(nil, libraries, []int{99}, false, false) - Expect(result).To(BeEmpty()) - }) - - It("returns empty map when libraries list is empty", func() { - result := buildAllowedPaths(nil, nil, []int{1}, false, false) - Expect(result).To(BeEmpty()) - }) - - It("returns empty map when allLibraries is false and no IDs provided", func() { - result := buildAllowedPaths(nil, libraries, nil, false, false) - Expect(result).To(BeEmpty()) - }) - }) -}) diff --git a/plugins/manager_plugin.go b/plugins/manager_plugin.go index 155663781..13375a70f 100644 --- a/plugins/manager_plugin.go +++ b/plugins/manager_plugin.go @@ -24,16 +24,24 @@ type plugin struct { allowedUserIDs []string // User IDs this plugin can access (from DB configuration) allUsers bool // If true, plugin can access all users libraries libraryAccess - lyricsSem chan struct{} // Caps concurrent lyrics calls (see LyricsPlugin.GetLyrics) + lyricsSem chan struct{} // Caps concurrent lyrics calls (see LyricsPlugin.GetLyrics) + fsConfig wazero.FSConfig // Sandboxed library mounts, nil if no filesystem permission +} + +// instanceConfig is used by every call site, so all instances get the sandboxed mounts. +func instanceConfig(fsConfig wazero.FSConfig) extism.PluginInstanceConfig { + moduleConfig := wazero.NewModuleConfig().WithSysWalltime().WithRandSource(rand.Reader) + if fsConfig != nil { + moduleConfig = moduleConfig.WithFSConfig(fsConfig) + } + return extism.PluginInstanceConfig{ModuleConfig: moduleConfig} } // instance creates a new plugin instance for the given context. // The context is used for cancellation - if cancelled during a call, // the module will be terminated and the instance becomes unusable. func (p *plugin) instance(ctx context.Context) (*extism.Plugin, error) { - instance, err := p.compiled.Instance(ctx, extism.PluginInstanceConfig{ - ModuleConfig: wazero.NewModuleConfig().WithSysWalltime().WithRandSource(rand.Reader), - }) + instance, err := p.compiled.Instance(ctx, instanceConfig(p.fsConfig)) if err != nil { return nil, err } diff --git a/plugins/plugins_suite_test.go b/plugins/plugins_suite_test.go index bb081988e..77956e2f9 100644 --- a/plugins/plugins_suite_test.go +++ b/plugins/plugins_suite_test.go @@ -12,6 +12,7 @@ import ( "os/exec" "path/filepath" "runtime" + "strings" "testing" "time" @@ -81,46 +82,43 @@ func createTestManagerWithPlugins(pluginConfig map[string]map[string]string, plu return createTestManagerWithPluginsAndMetrics(pluginConfig, noopMetricsRecorder{}, plugins...) } +// installTestPlugins copies the given .ndp packages into dir and returns their +// enabled DB rows, so callers can grant whatever access the test needs. +func installTestPlugins(dir string, plugins ...string) model.Plugins { + var rows model.Plugins + for _, plugin := range plugins { + data, err := os.ReadFile(filepath.Join(testdataDir, plugin)) + Expect(err).ToNot(HaveOccurred()) + destPath := filepath.Join(dir, plugin) + Expect(os.WriteFile(destPath, data, 0600)).To(Succeed()) + + hash := sha256.Sum256(data) + rows = append(rows, model.Plugin{ + ID: strings.TrimSuffix(plugin, PackageExtension), + Path: destPath, + SHA256: hex.EncodeToString(hash[:]), + Enabled: true, + }) + } + return rows +} + // createTestManagerWithPluginsAndMetrics creates a new plugin Manager with the given plugin config, -// metrics recorder, and specified plugins. It creates a temp directory, copies the specified plugins, and starts the manager. -// Returns the manager and temp directory path. +// metrics recorder, and specified plugins. It creates a temp directory, copies the specified plugins, +// and starts the manager. Returns the manager and temp directory path. func createTestManagerWithPluginsAndMetrics(pluginConfig map[string]map[string]string, metrics PluginMetricsRecorder, plugins ...string) (*Manager, string) { // Create temp directory tmpDir, err := os.MkdirTemp("", "plugins-test-*") Expect(err).ToNot(HaveOccurred()) - // Copy test plugins to temp dir and build plugin list with SHA256 - var enabledPlugins model.Plugins - for _, plugin := range plugins { - srcPath := filepath.Join(testdataDir, plugin) - destPath := filepath.Join(tmpDir, plugin) - data, err := os.ReadFile(srcPath) - Expect(err).ToNot(HaveOccurred()) - err = os.WriteFile(destPath, data, 0600) - Expect(err).ToNot(HaveOccurred()) - - // Compute SHA256 for the plugin - hash := sha256.Sum256(data) - hashHex := hex.EncodeToString(hash[:]) - pluginName := plugin[:len(plugin)-len(PackageExtension)] // Remove .ndp extension - - // Build config JSON if provided - configJSON := "" - if pluginConfig != nil && pluginConfig[pluginName] != nil { - // Encode config to JSON - configBytes, err := json.Marshal(pluginConfig[pluginName]) + enabledPlugins := installTestPlugins(tmpDir, plugins...) + for i, p := range enabledPlugins { + enabledPlugins[i].AllUsers = true // Allow all users by default in tests + if pluginConfig[p.ID] != nil { + configBytes, err := json.Marshal(pluginConfig[p.ID]) Expect(err).ToNot(HaveOccurred()) - configJSON = string(configBytes) + enabledPlugins[i].Config = string(configBytes) } - - enabledPlugins = append(enabledPlugins, model.Plugin{ - ID: pluginName, - Path: destPath, - SHA256: hashHex, - Enabled: true, - Config: configJSON, - AllUsers: true, // Allow all users by default in tests - }) } // Setup config diff --git a/plugins/sandbox_fs.go b/plugins/sandbox_fs.go new file mode 100644 index 000000000..57bba85af --- /dev/null +++ b/plugins/sandbox_fs.go @@ -0,0 +1,157 @@ +package plugins + +import ( + "context" + "io/fs" + "path/filepath" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/tetratelabs/wazero" + experimentalsys "github.com/tetratelabs/wazero/experimental/sys" + "github.com/tetratelabs/wazero/experimental/sysfs" + "github.com/tetratelabs/wazero/sys" +) + +// jailedFS confines a mount to its root: wazero resolves guest paths by bare +// concatenation. Named field, not embedding, so a new sys.FS method can't slip through. +type jailedFS struct { + fs experimentalsys.FS +} + +// escapes reports whether a guest path can resolve outside the mount root. +// WASI only validates "/"-separated paths, missing `..\`, `C:x` and device names. +func escapes(path string) bool { + switch path { + case "", ".", "/": // how wazero opens the mount root itself + return false + } + return !filepath.IsLocal(path) +} + +func (jailedFS) Symlink(string, string) experimentalsys.Errno { + return experimentalsys.EPERM +} + +func (j jailedFS) OpenFile(path string, flag experimentalsys.Oflag, perm fs.FileMode) (experimentalsys.File, experimentalsys.Errno) { + if escapes(path) { + return nil, experimentalsys.EPERM + } + return j.fs.OpenFile(path, flag, perm) +} + +func (j jailedFS) Lstat(path string) (sys.Stat_t, experimentalsys.Errno) { + if escapes(path) { + return sys.Stat_t{}, experimentalsys.EPERM + } + return j.fs.Lstat(path) +} + +func (j jailedFS) Stat(path string) (sys.Stat_t, experimentalsys.Errno) { + if escapes(path) { + return sys.Stat_t{}, experimentalsys.EPERM + } + return j.fs.Stat(path) +} + +func (j jailedFS) Mkdir(path string, perm fs.FileMode) experimentalsys.Errno { + if escapes(path) { + return experimentalsys.EPERM + } + return j.fs.Mkdir(path, perm) +} + +func (j jailedFS) Chmod(path string, perm fs.FileMode) experimentalsys.Errno { + if escapes(path) { + return experimentalsys.EPERM + } + return j.fs.Chmod(path, perm) +} + +func (j jailedFS) Rename(from, to string) experimentalsys.Errno { + if escapes(from) || escapes(to) { + return experimentalsys.EPERM + } + return j.fs.Rename(from, to) +} + +func (j jailedFS) Rmdir(path string) experimentalsys.Errno { + if escapes(path) { + return experimentalsys.EPERM + } + return j.fs.Rmdir(path) +} + +func (j jailedFS) Unlink(path string) experimentalsys.Errno { + if escapes(path) { + return experimentalsys.EPERM + } + return j.fs.Unlink(path) +} + +func (j jailedFS) Link(oldPath, newPath string) experimentalsys.Errno { + if escapes(oldPath) || escapes(newPath) { + return experimentalsys.EPERM + } + return j.fs.Link(oldPath, newPath) +} + +func (j jailedFS) Readlink(path string) (string, experimentalsys.Errno) { + if escapes(path) { + return "", experimentalsys.EPERM + } + return j.fs.Readlink(path) +} + +func (j jailedFS) Utimens(path string, atim, mtim int64) experimentalsys.Errno { + if escapes(path) { + return experimentalsys.EPERM + } + return j.fs.Utimens(path, atim, mtim) +} + +// mount is a host directory exposed to a plugin at guestPath. +type mount struct { + hostPath string + guestPath string + readOnly bool +} + +// buildMounts lists the libraries the plugin may reach through the filesystem. +func buildMounts(ctx context.Context, libraries model.Libraries, allowedLibraryIDs []int, allLibraries, allowWriteAccess bool) []mount { + allowedLibrarySet := make(map[int]struct{}, len(allowedLibraryIDs)) + for _, id := range allowedLibraryIDs { + allowedLibrarySet[id] = struct{}{} + } + var mounts []mount + for _, lib := range libraries { + _, allowed := allowedLibrarySet[lib.ID] + if allLibraries || allowed { + m := mount{hostPath: lib.Path, guestPath: toPluginMountPoint(int32(lib.ID)), readOnly: !allowWriteAccess} + mounts = append(mounts, m) + log.Trace(ctx, "Added library to plugin mounts", "libraryID", lib.ID, "mountPoint", m.guestPath, "readOnly", m.readOnly, "hostPath", m.hostPath) + } + } + if allowWriteAccess { + log.Info(ctx, "Granting read-write filesystem access to libraries", "libraryCount", len(mounts), "allLibraries", allLibraries) + } else { + log.Debug(ctx, "Granting read-only filesystem access to libraries", "libraryCount", len(mounts), "allLibraries", allLibraries) + } + return mounts +} + +// buildFSConfig mounts each host directory jailed to its root. +func buildFSConfig(mounts []mount) wazero.FSConfig { + if len(mounts) == 0 { + return nil + } + cfg := wazero.NewFSConfig() + for _, m := range mounts { + var mounted experimentalsys.FS = jailedFS{fs: sysfs.DirFS(m.hostPath)} + if m.readOnly { + mounted = &sysfs.ReadFS{FS: mounted} + } + cfg = cfg.(sysfs.FSConfig).WithSysFSMount(mounted, m.guestPath) + } + return cfg +} diff --git a/plugins/sandbox_fs_internal_test.go b/plugins/sandbox_fs_internal_test.go new file mode 100644 index 000000000..73011225d --- /dev/null +++ b/plugins/sandbox_fs_internal_test.go @@ -0,0 +1,127 @@ +package plugins + +import ( + "os" + "path/filepath" + "runtime" + + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + experimentalsys "github.com/tetratelabs/wazero/experimental/sys" + "github.com/tetratelabs/wazero/experimental/sysfs" +) + +var _ = Describe("buildMounts", func() { + var libraries model.Libraries + + BeforeEach(func() { + libraries = model.Libraries{ + {ID: 1, Path: "/music/library1"}, + {ID: 2, Path: "/music/library2"}, + {ID: 3, Path: "/music/library3"}, + } + }) + + It("mounts all libraries read-only by default", func() { + Expect(buildMounts(nil, libraries, nil, true, false)).To(Equal([]mount{ + {hostPath: "/music/library1", guestPath: "/libraries/1", readOnly: true}, + {hostPath: "/music/library2", guestPath: "/libraries/2", readOnly: true}, + {hostPath: "/music/library3", guestPath: "/libraries/3", readOnly: true}, + })) + }) + + It("mounts only the selected libraries", func() { + Expect(buildMounts(nil, libraries, []int{1, 3}, false, false)).To(Equal([]mount{ + {hostPath: "/music/library1", guestPath: "/libraries/1", readOnly: true}, + {hostPath: "/music/library3", guestPath: "/libraries/3", readOnly: true}, + })) + }) + + It("mounts writable when write access is granted", func() { + Expect(buildMounts(nil, libraries, []int{2}, false, true)).To(Equal([]mount{ + {hostPath: "/music/library2", guestPath: "/libraries/2", readOnly: false}, + })) + }) + + DescribeTable("mounts nothing", + func(libs model.Libraries, allowedIDs []int, allLibraries bool) { + Expect(buildMounts(nil, libs, allowedIDs, allLibraries, false)).To(BeEmpty()) + }, + Entry("when no library matches", libraries, []int{99}, false), + Entry("when there are no libraries", model.Libraries(nil), []int{1}, false), + Entry("when no library is selected", libraries, nil, false), + ) +}) + +var _ = Describe("jailedFS", func() { + var ( + fs jailedFS + root string + outsideDir string + ) + + BeforeEach(func() { + tmpDir := GinkgoT().TempDir() + root = filepath.Join(tmpDir, "root") + outsideDir = filepath.Join(tmpDir, "outside") + Expect(os.MkdirAll(root, 0755)).To(Succeed()) + Expect(os.MkdirAll(outsideDir, 0755)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(outsideDir, "secret.txt"), []byte("secret"), 0600)).To(Succeed()) + + fs = jailedFS{sysfs.DirFS(root)} + }) + + It("denies creating symlinks", func() { + Expect(fs.Symlink("../outside", "link")).To(Equal(experimentalsys.EPERM)) + }) + + It("denies reading through a path that escapes the root", func() { + _, errno := fs.OpenFile("../outside/secret.txt", experimentalsys.O_RDONLY, 0) + Expect(errno).To(Equal(experimentalsys.EPERM)) + }) + + It("denies traversal with the host separator", func() { + _, errno := fs.OpenFile(`..\outside\secret.txt`, experimentalsys.O_RDONLY, 0) + + if runtime.GOOS == "windows" { + Expect(errno).To(Equal(experimentalsys.EPERM)) + } else { + // A backslash is a legal filename character on POSIX, not a separator + Expect(errno).To(Equal(experimentalsys.ENOENT)) + } + }) + + It("denies absolute paths", func() { + _, errno := fs.OpenFile("/etc/passwd", experimentalsys.O_RDONLY, 0) + Expect(errno).To(Equal(experimentalsys.EPERM)) + }) + + It("denies creating a directory outside the root", func() { + Expect(fs.Mkdir("../outside/new-dir", 0755)).To(Equal(experimentalsys.EPERM)) + Expect(filepath.Join(outsideDir, "new-dir")).ToNot(BeADirectory()) + }) + + It("denies escaping through either side of a rename", func() { + Expect(fs.Rename("../outside/secret.txt", "stolen.txt")).To(Equal(experimentalsys.EPERM)) + Expect(fs.Rename("inside.txt", "../outside/leaked.txt")).To(Equal(experimentalsys.EPERM)) + }) + + // Music libraries symlink in folders from elsewhere; following them is intended + It("follows symlinks that already exist in the mount", func() { + if err := os.Symlink(outsideDir, filepath.Join(root, "linked")); err != nil { + Skip("cannot create symlinks here: " + err.Error()) // Windows without privileges + } + + _, errno := fs.OpenFile("linked/secret.txt", experimentalsys.O_RDONLY, 0) + Expect(errno).To(BeZero()) + }) + + It("allows access within the root", func() { + Expect(fs.Mkdir("sub", 0755)).To(BeZero()) + f, errno := fs.OpenFile("sub/file.txt", experimentalsys.O_CREAT|experimentalsys.O_WRONLY, 0600) + Expect(errno).To(BeZero()) + Expect(f.Close()).To(BeZero()) + Expect(filepath.Join(root, "sub", "file.txt")).To(BeAnExistingFile()) + }) +}) diff --git a/plugins/sandbox_fs_test.go b/plugins/sandbox_fs_test.go new file mode 100644 index 000000000..ea033283f --- /dev/null +++ b/plugins/sandbox_fs_test.go @@ -0,0 +1,182 @@ +//go:build !windows + +package plugins + +import ( + "context" + "encoding/json" + "net/http" + "os" + "path/filepath" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type sandboxInput struct { + Operation string `json:"operation"` + MountPoint string `json:"mount_point,omitempty"` + FilePath string `json:"file_path,omitempty"` + Content string `json:"content,omitempty"` + Target string `json:"target,omitempty"` +} + +type sandboxOutput struct { + FileContent string `json:"file_content,omitempty"` + Error *string `json:"error,omitempty"` +} + +// startSandboxManager loads test-library against libraryDir with the given grant. +func startSandboxManager(tmpDir, libraryDir string, grant func(*model.Plugin)) *Manager { + GinkgoHelper() + installed := installTestPlugins(tmpDir, "test-library"+PackageExtension) + grant(&installed[0]) + + DeferCleanup(configtest.SetupConfig()) + conf.Server.Plugins.Enabled = true + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) + conf.Server.Plugins.AutoReload = false + + mockPluginRepo := tests.CreateMockPluginRepo() + mockPluginRepo.Permitted = true + mockPluginRepo.SetData(installed) + + mockLibraryRepo := &tests.MockLibraryRepo{} + mockLibraryRepo.SetData(model.Libraries{{ID: 1, Name: "Test Library", Path: libraryDir}}) + + manager := &Manager{ + plugins: make(map[string]*plugin), + ds: &tests.MockDataStore{MockedPlugin: mockPluginRepo, MockedLibrary: mockLibraryRepo}, + subsonicRouter: http.NotFoundHandler(), + } + Expect(manager.Start(GinkgoT().Context())).To(Succeed()) + DeferCleanup(func() { _ = manager.Stop() }) + return manager +} + +// callSandbox runs one filesystem operation inside the plugin sandbox. +func callSandbox(manager *Manager, input sandboxInput) sandboxOutput { + GinkgoHelper() + manager.mu.RLock() + p := manager.plugins["test-library"] + manager.mu.RUnlock() + Expect(p).ToNot(BeNil()) + + instance, err := p.instance(context.Background()) + Expect(err).ToNot(HaveOccurred()) + defer instance.Close(context.Background()) + + inputBytes, err := json.Marshal(input) + Expect(err).ToNot(HaveOccurred()) + _, outputBytes, err := instance.Call("nd_test_library", inputBytes) + Expect(err).ToNot(HaveOccurred()) + + var output sandboxOutput + Expect(json.Unmarshal(outputBytes, &output)).To(Succeed()) + return output +} + +var _ = Describe("Plugin filesystem sandbox", Ordered, ContinueOnFailure, func() { + var ( + manager *Manager + libraryDir string + outsideDir string + secretFile string + mountPoint string + ) + + call := func(input sandboxInput) sandboxOutput { + GinkgoHelper() + input.MountPoint = mountPoint + return callSandbox(manager, input) + } + + BeforeAll(func() { + tmpDir := GinkgoT().TempDir() + libraryDir = filepath.Join(tmpDir, "music-library") + Expect(os.MkdirAll(libraryDir, 0755)).To(Succeed()) + + // Sibling of the mount root: anything reached here escaped the sandbox + outsideDir = filepath.Join(tmpDir, "outside") + Expect(os.MkdirAll(outsideDir, 0755)).To(Succeed()) + secretFile = filepath.Join(outsideDir, "secret.txt") + Expect(os.WriteFile(secretFile, []byte("secret"), 0600)).To(Succeed()) + + mountPoint = toPluginMountPoint(1) + manager = startSandboxManager(tmpDir, libraryDir, func(p *model.Plugin) { + p.AllLibraries = true + p.AllowWriteAccess = true + }) + }) + + It("allows writing inside the mount", func() { + out := call(sandboxInput{Operation: "write_file", FilePath: "inside.txt", Content: "hello"}) + + Expect(out.Error).To(BeNil()) + Expect(os.ReadFile(filepath.Join(libraryDir, "inside.txt"))).To(BeEquivalentTo("hello")) + }) + + It("cannot escape the mount by creating a symlink", func() { + linked := call(sandboxInput{Operation: "symlink", FilePath: "escape", Target: "../outside"}) + Expect(linked.Error).ToNot(BeNil()) + _, err := os.Lstat(filepath.Join(libraryDir, "escape")) + Expect(err).To(MatchError(os.ErrNotExist), "the plugin created a symlink out of the mount") + + wrote := call(sandboxInput{Operation: "write_file", FilePath: "escape/via-created-symlink.txt", Content: "escaped"}) + + Expect(wrote.Error).ToNot(BeNil()) + Expect(filepath.Join(outsideDir, "via-created-symlink.txt")).ToNot(BeAnExistingFile()) + }) + + // Accepted residual, pinned so a future tightening can't happen silently + It("still follows a symlink planted in the mount by something else", func() { + Expect(os.Symlink(outsideDir, filepath.Join(libraryDir, "planted"))).To(Succeed()) + + out := call(sandboxInput{Operation: "write_file", FilePath: "planted/via-symlink.txt", Content: "escaped"}) + + Expect(out.Error).To(BeNil()) + Expect(filepath.Join(outsideDir, "via-symlink.txt")).To(BeAnExistingFile()) + }) + + It("cannot read outside the mount with ..", func() { + out := call(sandboxInput{Operation: "read_file", FilePath: "../outside/secret.txt"}) + + Expect(out.Error).ToNot(BeNil()) + Expect(out.FileContent).ToNot(Equal("secret")) + }) + + It("cannot write outside the mount with ..", func() { + out := call(sandboxInput{Operation: "write_file", FilePath: "../outside/via-dotdot.txt", Content: "escaped"}) + + Expect(out.Error).ToNot(BeNil()) + Expect(filepath.Join(outsideDir, "via-dotdot.txt")).ToNot(BeAnExistingFile()) + }) +}) + +var _ = Describe("Plugin filesystem sandbox without library access", Ordered, func() { + var manager *Manager + var libraryDir string + + BeforeAll(func() { + tmpDir := GinkgoT().TempDir() + libraryDir = filepath.Join(tmpDir, "music-library") + Expect(os.MkdirAll(libraryDir, 0755)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(libraryDir, "track.txt"), []byte("audio"), 0600)).To(Succeed()) + + manager = startSandboxManager(tmpDir, libraryDir, func(*model.Plugin) {}) + }) + + It("mounts nothing when no library is granted", func() { + manager.mu.RLock() + p := manager.plugins["test-library"] + manager.mu.RUnlock() + Expect(p.fsConfig).To(BeNil()) + + out := callSandbox(manager, sandboxInput{Operation: "read_file", MountPoint: toPluginMountPoint(1), FilePath: "track.txt"}) + Expect(out.Error).ToNot(BeNil()) + }) +}) diff --git a/plugins/testdata/test-library/main.go b/plugins/testdata/test-library/main.go index 9c04c5f9b..e83c9aec6 100644 --- a/plugins/testdata/test-library/main.go +++ b/plugins/testdata/test-library/main.go @@ -14,10 +14,12 @@ import ( // TestLibraryInput is the input for nd_test_library callback. type TestLibraryInput struct { - Operation string `json:"operation"` // "get_library", "get_all_libraries", "read_file", "list_dir" + Operation string `json:"operation"` // "get_library", "get_all_libraries", "read_file", "list_dir", "write_file", "symlink" LibraryID int32 `json:"library_id,omitempty"` MountPoint string `json:"mount_point,omitempty"` // For filesystem operations FilePath string `json:"file_path,omitempty"` // For read_file operation (relative to mount point) + Content string `json:"content,omitempty"` // For write_file operation + Target string `json:"target,omitempty"` // For symlink operation } // TestLibraryOutput is the output from nd_test_library callback. @@ -88,6 +90,28 @@ func ndTestLibrary() int32 { pdk.OutputJSON(TestLibraryOutput{DirEntries: names}) return 0 + case "write_file": + // Write a file to the mounted library directory + fullPath := filepath.Join(input.MountPoint, input.FilePath) + if err := os.WriteFile(fullPath, []byte(input.Content), 0600); err != nil { + errStr := err.Error() + pdk.OutputJSON(TestLibraryOutput{Error: &errStr}) + return 0 + } + pdk.OutputJSON(TestLibraryOutput{}) + return 0 + + case "symlink": + // Create a symlink inside the mounted library directory + fullPath := filepath.Join(input.MountPoint, input.FilePath) + if err := os.Symlink(input.Target, fullPath); err != nil { + errStr := err.Error() + pdk.OutputJSON(TestLibraryOutput{Error: &errStr}) + return 0 + } + pdk.OutputJSON(TestLibraryOutput{}) + return 0 + default: errStr := "unknown operation: " + input.Operation pdk.OutputJSON(TestLibraryOutput{Error: &errStr}) From b40b41584ad5e4e08430aabcd2a0d6484c998bc2 Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Sun, 2 Aug 2026 09:39:51 -0700 Subject: [PATCH 44/71] feat(subsonic): Implement OpenSubsonic topSongsByArtistId extension (#5853) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * implement topSongsByArtistId extension * do not look up by artist name if empty --------- Co-authored-by: Deluan Quintão --- core/artwork/e2e/helpers_test.go | 2 +- core/external/provider.go | 34 ++++++-- core/external/provider_topsongs_test.go | 84 +++++++++++++++---- server/subsonic/browsing.go | 5 +- server/subsonic/e2e/e2e_suite_test.go | 2 +- server/subsonic/e2e/subsonic_browsing_test.go | 68 ++++++++------- server/subsonic/opensubsonic.go | 1 + server/subsonic/opensubsonic_test.go | 6 +- 8 files changed, 143 insertions(+), 59 deletions(-) diff --git a/core/artwork/e2e/helpers_test.go b/core/artwork/e2e/helpers_test.go index e3abca097..ddc926cec 100644 --- a/core/artwork/e2e/helpers_test.go +++ b/core/artwork/e2e/helpers_test.go @@ -171,7 +171,7 @@ func (n *noopProvider) UpdateArtistInfo(context.Context, string, int, bool) (*mo func (n *noopProvider) SimilarSongs(context.Context, string, int) (model.MediaFiles, error) { return nil, nil } -func (n *noopProvider) TopSongs(context.Context, string, int) (model.MediaFiles, error) { +func (n *noopProvider) TopSongs(context.Context, string, string, int) (model.MediaFiles, error) { return nil, nil } func (n *noopProvider) ArtistImage(context.Context, string) (*url.URL, error) { diff --git a/core/external/provider.go b/core/external/provider.go index 459e8a205..869841e06 100644 --- a/core/external/provider.go +++ b/core/external/provider.go @@ -34,7 +34,7 @@ type Provider interface { UpdateAlbumInfo(ctx context.Context, id string) (*model.Album, error) UpdateArtistInfo(ctx context.Context, id string, count int, includeNotPresent bool) (*model.Artist, error) SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error) - TopSongs(ctx context.Context, artist string, count int) (model.MediaFiles, error) + TopSongs(ctx context.Context, artist, artistId string, count int) (model.MediaFiles, error) ArtistImage(ctx context.Context, id string) (*url.URL, error) AlbumImage(ctx context.Context, id string) (*url.URL, error) } @@ -440,11 +440,16 @@ func (e *provider) AlbumImage(ctx context.Context, id string) (*url.URL, error) return url.Parse(img.URL) } -func (e *provider) TopSongs(ctx context.Context, artistName string, count int) (model.MediaFiles, error) { - artist, err := e.findArtistByName(ctx, artistName) +func (e *provider) TopSongs(ctx context.Context, artistName, id string, count int) (model.MediaFiles, error) { + artist, err := e.findArtist(ctx, artistName, id) if err != nil { - log.Error(ctx, "Artist not found", "name", artistName, err) - return nil, nil + if errors.Is(err, model.ErrNotFound) { + log.Error(ctx, "Artist not found", "name", artistName, "id", id, err) + return nil, nil + } + + log.Error(ctx, "Failure occurred when trying to fetch artist", "name", artistName, "id", id, err) + return nil, err } songs, err := e.getMatchingTopSongs(ctx, e.ag, artist, count) @@ -713,7 +718,24 @@ func (e *provider) loadArtistsByName(ctx context.Context, similar []agents.Artis return matches, nil } -func (e *provider) findArtistByName(ctx context.Context, artistName string) (*auxArtist, error) { +func (e *provider) findArtist(ctx context.Context, artistName, id string) (*auxArtist, error) { + if id != "" { + artist, err := e.ds.Artist(ctx).Get(id) + if err == nil { + return &auxArtist{Artist: *artist}, nil + } + + if errors.Is(err, model.ErrNotFound) { + log.Warn(ctx, "Could not find artist by id", "id", id, err) + } else { + return nil, err + } + } + + if artistName == "" { + return nil, model.ErrNotFound + } + artists, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Like{"artist.name": artistName}, Max: 1, diff --git a/core/external/provider_topsongs_test.go b/core/external/provider_topsongs_test.go index 86f9110e2..795a7ff74 100644 --- a/core/external/provider_topsongs_test.go +++ b/core/external/provider_topsongs_test.go @@ -65,7 +65,7 @@ var _ = Describe("Provider - TopSongs", func() { song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "mbid-song-2"} mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once() - songs, err := p.TopSongs(ctx, "Artist One", 2) + songs, err := p.TopSongs(ctx, "Artist One", "", 2) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(2)) @@ -76,6 +76,59 @@ var _ = Describe("Provider - TopSongs", func() { mediaFileRepo.AssertExpectations(GinkgoT()) }) + Context("artist id and/or name", func() { + artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"} + + BeforeEach(func() { + agentSongs := []agents.Song{{Name: "Song One", MBID: "mbid-song-1"}} + ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 2).Return(agentSongs, nil).Once() + + song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-song-1"} + mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once() + }) + + It("returns top songs for a known artist by id only", func() { + artistRepo.On("Get", "artist-1").Return(&artist1, nil).Once() + + songs, err := p.TopSongs(ctx, "", "artist-1", 2) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(HaveLen(1)) + Expect(songs[0].ID).To(Equal("song-1")) + artistRepo.AssertExpectations(GinkgoT()) + ag.AssertExpectations(GinkgoT()) + mediaFileRepo.AssertExpectations(GinkgoT()) + }) + + It("returns top songs for a known artist by id, falling back to name", func() { + artistRepo.On("Get", "fake-id").Return(nil, model.ErrNotFound).Once() + artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once() + + songs, err := p.TopSongs(ctx, "Artist One", "fake-id", 2) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(HaveLen(1)) + Expect(songs[0].ID).To(Equal("song-1")) + artistRepo.AssertExpectations(GinkgoT()) + ag.AssertExpectations(GinkgoT()) + mediaFileRepo.AssertExpectations(GinkgoT()) + }) + }) + + It("bails if lookup by id returns a fatal error", func() { + artistRepo.On("Get", "artist-1").Return(nil, model.ErrInvalidAuth).Once() + songs, err := p.TopSongs(ctx, "Artist One", "artist-1", 2) + Expect(songs).To(BeEmpty()) + Expect(err).To(HaveOccurred()) + }) + + It("bails if lookup by name returns a fatal error", func() { + artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(nil, model.ErrInvalidAuth).Once() + songs, err := p.TopSongs(ctx, "Artist One", "", 2) + Expect(songs).To(BeEmpty()) + Expect(err).To(HaveOccurred()) + }) + It("backfills name and MBID onto an unnamed primary credit (the queried artist) and matches", func() { artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"} artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil) @@ -96,7 +149,7 @@ var _ = Describe("Provider - TopSongs", func() { } mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{track}, nil) - songs, err := p.TopSongs(ctx, "Artist One", 1) + songs, err := p.TopSongs(ctx, "Artist One", "", 1) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(1)) @@ -116,7 +169,8 @@ var _ = Describe("Provider - TopSongs", func() { ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 1).Return(agentSongs, nil).Once() // Library track is credited to Artist One (the queried artist) under a same title. If the - // queried MBID were wrongly stamped onto the "Artist Two" credit, that mismatched name+MBID + // queried MBID were wrongly stamped onto the "Artist Two" credit, that mismatched name+MBID artistRepo.On("Get", "fake-id").Return(nil, model.ErrNotFound).Once() + // could mis-resolve. With the guard, "Artist Two" stays MBID-less and does not match One's track. track := model.MediaFile{ ID: "one-track", Title: "Collab Song", ArtistID: "artist-1", @@ -126,7 +180,7 @@ var _ = Describe("Provider - TopSongs", func() { } mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{track}, nil) - songs, err := p.TopSongs(ctx, "Artist One", 1) + songs, err := p.TopSongs(ctx, "Artist One", "", 1) Expect(err).ToNot(HaveOccurred()) // "Artist Two" (named, MBID-less, not in the library) does not resolve to One's track. @@ -137,7 +191,7 @@ var _ = Describe("Provider - TopSongs", func() { // Mock artist not found artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{}, nil).Once() - songs, err := p.TopSongs(ctx, "Unknown Artist", 5) + songs, err := p.TopSongs(ctx, "Unknown Artist", "", 5) Expect(err).ToNot(HaveOccurred()) // TopSongs returns nil error if artist not found Expect(songs).To(BeNil()) @@ -154,7 +208,7 @@ var _ = Describe("Provider - TopSongs", func() { agentErr := errors.New("agent error") ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 5).Return(nil, agentErr).Once() - songs, err := p.TopSongs(ctx, "Artist One", 5) + songs, err := p.TopSongs(ctx, "Artist One", "", 5) Expect(err).To(MatchError(agentErr)) Expect(songs).To(BeNil()) @@ -170,7 +224,7 @@ var _ = Describe("Provider - TopSongs", func() { // Mock agent ErrNotFound ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 5).Return(nil, agents.ErrNotFound).Once() - songs, err := p.TopSongs(ctx, "Artist One", 5) + songs, err := p.TopSongs(ctx, "Artist One", "", 5) Expect(err).To(MatchError(model.ErrNotFound)) Expect(songs).To(BeNil()) @@ -191,7 +245,7 @@ var _ = Describe("Provider - TopSongs", func() { song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-song-1"} mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once() - songs, err := p.TopSongs(ctx, "Artist One", 1) + songs, err := p.TopSongs(ctx, "Artist One", "", 1) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(1)) @@ -220,7 +274,7 @@ var _ = Describe("Provider - TopSongs", func() { mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once() // bulk MBID query mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{}, nil).Once() // title track-fetch for song2: no match - songs, err := p.TopSongs(ctx, "Artist One", 2) + songs, err := p.TopSongs(ctx, "Artist One", "", 2) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(1)) @@ -242,7 +296,7 @@ var _ = Describe("Provider - TopSongs", func() { ag.On("GetArtistTopSongs", canceledCtx, "artist-1", "Artist One", "mbid-artist-1", 5).Return(nil, context.Canceled).Once() cancel() // Cancel the context before calling - songs, err := p.TopSongs(canceledCtx, "Artist One", 5) + songs, err := p.TopSongs(canceledCtx, "Artist One", "", 5) Expect(err).To(MatchError(context.Canceled)) Expect(songs).To(BeNil()) @@ -276,7 +330,7 @@ var _ = Describe("Provider - TopSongs", func() { } mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once() - songs, err := p.TopSongs(ctx, "Artist One", 2) + songs, err := p.TopSongs(ctx, "Artist One", "", 2) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(2)) @@ -313,7 +367,7 @@ var _ = Describe("Provider - TopSongs", func() { } mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song2}, nil).Once() - songs, err := p.TopSongs(ctx, "Artist One", 2) + songs, err := p.TopSongs(ctx, "Artist One", "", 2) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(2)) @@ -341,7 +395,7 @@ var _ = Describe("Provider - TopSongs", func() { song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "mbid-song-2"} mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once() - songs, err := p.TopSongs(ctx, "Artist One", 1) + songs, err := p.TopSongs(ctx, "Artist One", "", 1) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(1)) @@ -369,7 +423,7 @@ var _ = Describe("Provider - TopSongs", func() { song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1"} mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once() - songs, err := p.TopSongs(ctx, "Artist One", 2) + songs, err := p.TopSongs(ctx, "Artist One", "", 2) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(2)) @@ -397,7 +451,7 @@ var _ = Describe("Provider - TopSongs", func() { song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-song-1"} mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once() - songs, err := p.TopSongs(ctx, "Artist One", 1) + songs, err := p.TopSongs(ctx, "Artist One", "", 1) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(1)) diff --git a/server/subsonic/browsing.go b/server/subsonic/browsing.go index f6a7047c4..59d267321 100644 --- a/server/subsonic/browsing.go +++ b/server/subsonic/browsing.go @@ -381,13 +381,14 @@ func (api *Router) GetSimilarSongs2(r *http.Request) (*responses.Subsonic, error func (api *Router) GetTopSongs(r *http.Request) (*responses.Subsonic, error) { ctx := r.Context() p := req.Params(r) + id, idErr := p.String("id") artist, err := p.String("artist") - if err != nil { + if err != nil && idErr != nil { return nil, err } count := p.IntOr("count", 50) - songs, err := api.provider.TopSongs(ctx, artist, count) + songs, err := api.provider.TopSongs(ctx, artist, id, count) if err != nil && !errors.Is(err, model.ErrNotFound) { return nil, err } diff --git a/server/subsonic/e2e/e2e_suite_test.go b/server/subsonic/e2e/e2e_suite_test.go index 4e9039d68..97d7fb40a 100644 --- a/server/subsonic/e2e/e2e_suite_test.go +++ b/server/subsonic/e2e/e2e_suite_test.go @@ -353,7 +353,7 @@ func (n noopProvider) SimilarSongs(context.Context, string, int) (model.MediaFil return nil, nil } -func (n noopProvider) TopSongs(context.Context, string, int) (model.MediaFiles, error) { +func (n noopProvider) TopSongs(context.Context, string, string, int) (model.MediaFiles, error) { return nil, nil } diff --git a/server/subsonic/e2e/subsonic_browsing_test.go b/server/subsonic/e2e/subsonic_browsing_test.go index 55aeb8e9e..992f9e0fb 100644 --- a/server/subsonic/e2e/subsonic_browsing_test.go +++ b/server/subsonic/e2e/subsonic_browsing_test.go @@ -13,6 +13,15 @@ var _ = Describe("Browsing Endpoints", func() { setupTestDB() }) + getBeatlesId := func() string { + artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"name": "The Beatles"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(artists).ToNot(BeEmpty()) + return artists[0].ID + } + Describe("getMusicFolders", func() { It("returns the configured music library", func() { resp := doReq("getMusicFolders") @@ -85,12 +94,7 @@ var _ = Describe("Browsing Endpoints", func() { Describe("getMusicDirectory", func() { It("returns an artist directory with its albums as children", func() { - artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{ - Filters: squirrel.Eq{"name": "The Beatles"}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(artists).ToNot(BeEmpty()) - beatlesID := artists[0].ID + beatlesID := getBeatlesId() resp := doReq("getMusicDirectory", "id", beatlesID) @@ -125,12 +129,7 @@ var _ = Describe("Browsing Endpoints", func() { Describe("getArtist", func() { It("returns artist with albums in ID3 format", func() { - artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{ - Filters: squirrel.Eq{"name": "The Beatles"}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(artists).ToNot(BeEmpty()) - beatlesID := artists[0].ID + beatlesID := getBeatlesId() resp := doReq("getArtist", "id", beatlesID) @@ -141,12 +140,7 @@ var _ = Describe("Browsing Endpoints", func() { }) It("returns album names for the artist", func() { - artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{ - Filters: squirrel.Eq{"name": "The Beatles"}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(artists).ToNot(BeEmpty()) - beatlesID := artists[0].ID + beatlesID := getBeatlesId() resp := doReq("getArtist", "id", beatlesID) @@ -381,13 +375,7 @@ var _ = Describe("Browsing Endpoints", func() { Describe("getArtistInfo", func() { It("returns artist info for a valid artist", func() { - artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{ - Filters: squirrel.Eq{"name": "The Beatles"}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(artists).ToNot(BeEmpty()) - beatlesID := artists[0].ID - + beatlesID := getBeatlesId() resp := doReq("getArtistInfo", "id", beatlesID) Expect(resp.Status).To(Equal(responses.StatusOK)) @@ -397,13 +385,7 @@ var _ = Describe("Browsing Endpoints", func() { Describe("getArtistInfo2", func() { It("returns artist info2 for a valid artist", func() { - artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{ - Filters: squirrel.Eq{"name": "The Beatles"}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(artists).ToNot(BeEmpty()) - beatlesID := artists[0].ID - + beatlesID := getBeatlesId() resp := doReq("getArtistInfo2", "id", beatlesID) Expect(resp.Status).To(Equal(responses.StatusOK)) @@ -426,6 +408,28 @@ var _ = Describe("Browsing Endpoints", func() { Expect(resp.TopSongs).ToNot(BeNil()) Expect(resp.TopSongs.Song).To(BeEmpty()) }) + + It("returns an error if no arguments are specified", func() { + resp := doReq("getTopSongs") + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.TopSongs).To(BeNil()) + }) + + It("returns a response only by id", func() { + beatlesID := getBeatlesId() + resp := doReq("getTopSongs", "id", beatlesID) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.TopSongs).ToNot(BeNil()) + }) + + It("returns a response when both id and name are included", func() { + beatlesID := getBeatlesId() + resp := doReq("getTopSongs", "id", beatlesID, "artist", "The Beatles") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.TopSongs).ToNot(BeNil()) + }) }) Describe("getSimilarSongs", func() { diff --git a/server/subsonic/opensubsonic.go b/server/subsonic/opensubsonic.go index 97b3cafcc..2b2a31bf3 100644 --- a/server/subsonic/opensubsonic.go +++ b/server/subsonic/opensubsonic.go @@ -15,6 +15,7 @@ func (api *Router) GetOpenSubsonicExtensions(_ *http.Request) (*responses.Subson {Name: "indexBasedQueue", Versions: []int32{1}}, {Name: "transcoding", Versions: []int32{1}}, {Name: "playbackReport", Versions: []int32{1}}, + {Name: "topSongsByArtistId", Versions: []int32{1}}, } if api.sonic != nil && api.sonic.HasProvider() { extensions = append(extensions, responses.OpenSubsonicExtension{ diff --git a/server/subsonic/opensubsonic_test.go b/server/subsonic/opensubsonic_test.go index e4217303f..2615a652d 100644 --- a/server/subsonic/opensubsonic_test.go +++ b/server/subsonic/opensubsonic_test.go @@ -55,13 +55,14 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { err := json.Unmarshal(w.Body.Bytes(), &response) Expect(err).NotTo(HaveOccurred()) Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll( - HaveLen(6), + HaveLen(7), ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1, 2}}), ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "playbackReport", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "topSongsByArtistId", Versions: []int32{1}}), )) Expect(*response.Subsonic.OpenSubsonicExtensions).NotTo( ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}), @@ -85,7 +86,7 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { err := json.Unmarshal(w.Body.Bytes(), &response) Expect(err).NotTo(HaveOccurred()) Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll( - HaveLen(7), + HaveLen(8), ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1, 2}}), @@ -93,6 +94,7 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "playbackReport", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "topSongsByArtistId", Versions: []int32{1}}), )) }) }) From f853ca604a534e6916ea9a2e2bda5a27dc185bb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 2 Aug 2026 12:58:53 -0400 Subject: [PATCH 45/71] refactor(db): migrate all ids to a uniform canonical 128-bit base62 encoding (#5824) * refactor(model): extract canonical 128-bit base62 id codec * feat(model): generate random ids as canonical 128-bit base62 values * feat(scanner): emit legacy PIDs in canonical base62 encoding * feat(db): add id canonicalization transform for the uniform-ids migration * feat(db): migrate all ids to canonical 128-bit base62 encoding * fix(db): canonicalize ids in junction tables and JSON columns * chore(jellyfin): update id-family notes for uniform canonical ids * test(ids): harden codec input contract and migration edge coverage * refactor(model): use log.Fatal for Encode128 contract guard per project convention * fix(db): force full rescan after id migration for legacy PID configs * test(db): guard id-column inventory against schema drift * refactor(ids): compile-time Encode128 contract and unified column rewrite helper * refactor(db): apply review feedback to id migration Filter empty strings in collectColumn's SQL, reuse a prepared statement for rewriteColumn updates, and clarify the legacy ID functions' comment now that they emit the canonical encoding. * feat(auth): split session and public-link JWT secrets, rotating sessions on id migration * test(subsonic): initialize public token secret in helpers suite The suite sets auth.TokenAuth directly instead of calling auth.Init, so the new PublicTokenAuth was nil whenever Ginkgo's spec order ran a helpers spec before any spec that calls auth.Init, panicking in publicurl.ImageURL. * refactor(db): inline canonicalID into its only consumer, the uniform-ids migration * refactor(model): rename Encode128/Decode128 to Encode/Decode With every id now exactly 128 bits, the width suffix is redundant; the package-qualified id.Encode/id.Decode carries the same information. * test(db): make the id-columns guard classify JSON columns too The guard only inspected columns named id/pid/*_id, so it could not see ids embedded in JSON. Widen it to *_ids and to every JSON column, and drive the "covered" set from a new embeddedIDColumns list instead of the inline calls in the migration. Every JSON column the schema has now carries a verdict. The four denormalized caches -- media_file/album.participants, media_file/album.tags, album.folder_ids and artist.similar_artists -- hold only artist, tag and folder ids. Those all come from id.NewHash, whose 22-char base62 encoding of a 128-bit MD5 is already in canonical range, so canonicalID is the identity on them and the migration correctly leaves them alone. A new codec test pins that invariant, since the exemptions depend on it. Verified on a copy of a 727MB/96k-track production database: canonicalizing those four columns changed zero rows, and artist, tag and folder ids were themselves unchanged by the migration (only media_file ids moved, 95108 of 96666). --- consts/consts.go | 1 + core/auth/auth.go | 53 +-- core/auth/auth_test.go | 45 +++ core/publicurl/publicurl_test.go | 2 +- .../20260720015443_uniform_canonical_ids.go | 310 ++++++++++++++++++ db/migrations/id_canonical_test.go | 47 +++ db/migrations/id_columns_guard_test.go | 119 +++++++ db/migrations/migrations_suite_test.go | 16 + db/migrations/uniform_canonical_ids_test.go | 233 +++++++++++++ model/id/id.go | 36 +- model/id/id_suite_test.go | 17 + model/id/id_test.go | 64 ++++ model/metadata/legacy_ids.go | 15 +- model/metadata/persistent_ids_test.go | 15 + plugins/host_artwork_test.go | 2 +- server/jellyfin/README.md | 16 +- server/jellyfin/dto/ids.go | 2 +- server/jellyfin/dto/ids_test.go | 2 +- server/jellyfin/truncated_ids.go | 4 +- server/public/handle_images.go | 2 +- server/public/handle_images_test.go | 2 +- server/public/handle_shares.go | 4 +- server/public/handle_streams.go | 2 +- server/public/handle_streams_test.go | 6 +- server/subsonic/helpers_test.go | 1 + 25 files changed, 957 insertions(+), 59 deletions(-) create mode 100644 db/migrations/20260720015443_uniform_canonical_ids.go create mode 100644 db/migrations/id_canonical_test.go create mode 100644 db/migrations/id_columns_guard_test.go create mode 100644 db/migrations/migrations_suite_test.go create mode 100644 db/migrations/uniform_canonical_ids_test.go create mode 100644 model/id/id_suite_test.go create mode 100644 model/id/id_test.go diff --git a/consts/consts.go b/consts/consts.go index f453ac125..e1a535c79 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -28,6 +28,7 @@ const ( UIAuthorizationHeader = "X-ND-Authorization" UIClientUniqueIDHeader = "X-ND-Client-Unique-Id" JWTSecretKey = "JWTSecret" + JWTPublicSecretKey = "JWTPublicSecret" JWTIssuer = "ND" DefaultSessionTimeout = 48 * time.Hour CookieExpiry = 365 * 24 * 3600 // One year diff --git a/core/auth/auth.go b/core/auth/auth.go index 7b3511bdf..b1e2667bd 100644 --- a/core/auth/auth.go +++ b/core/auth/auth.go @@ -19,35 +19,41 @@ import ( ) var ( - once sync.Once + once sync.Once + // TokenAuth signs UI/API session tokens. Rotated by the id migration so stale sessions die. TokenAuth *jwtauth.JWTAuth + // PublicTokenAuth signs public-link tokens (artwork, share streams), on a separate secret that survives the rotation. + PublicTokenAuth *jwtauth.JWTAuth ) -// Init creates a JWTAuth object from the secret stored in the DB. -// If the secret is not found, it will create a new one and store it in the DB. +// Init creates the JWTAuth objects from the secrets stored in the DB. +// Missing or undecryptable secrets are regenerated and stored. func Init(ds model.DataStore) { once.Do(func() { ctx := context.TODO() log.Info("Setting Session Timeout", "value", conf.Server.SessionTimeout) - secret, err := ds.Property(ctx).Get(consts.JWTSecretKey) - if err != nil || secret == "" { - log.Info(ctx, "Creating new JWT secret, used for encrypting UI sessions") - secret = createNewSecret(ctx, ds) - } else { - if secret, err = utils.Decrypt(ctx, getEncKey(), secret); err != nil { - log.Error(ctx, "Could not decrypt JWT secret, creating a new one", err) - secret = createNewSecret(ctx, ds) - } - } - - TokenAuth = jwtauth.New("HS256", []byte(secret), nil) + TokenAuth = jwtauth.New("HS256", []byte(loadOrCreateSecret(ctx, ds, consts.JWTSecretKey)), nil) + PublicTokenAuth = jwtauth.New("HS256", []byte(loadOrCreateSecret(ctx, ds, consts.JWTPublicSecretKey)), nil) }) } +func loadOrCreateSecret(ctx context.Context, ds model.DataStore, key string) string { + secret, err := ds.Property(ctx).Get(key) + if err != nil || secret == "" { + log.Info(ctx, "Creating new JWT secret", "key", key) + return createNewSecret(ctx, ds, key) + } + if secret, err = utils.Decrypt(ctx, getEncKey(), secret); err != nil { + log.Error(ctx, "Could not decrypt JWT secret, creating a new one", "key", key, err) + return createNewSecret(ctx, ds, key) + } + return secret +} + func CreatePublicToken(claims Claims) (string, error) { claims.Issuer = consts.JWTIssuer - _, token, err := TokenAuth.Encode(claims.ToMap()) + _, token, err := PublicTokenAuth.Encode(claims.ToMap()) return token, err } @@ -56,7 +62,7 @@ func CreateExpiringPublicToken(exp time.Time, claims Claims) (string, error) { if !exp.IsZero() { claims.ExpiresAt = exp } - _, token, err := TokenAuth.Encode(claims.ToMap()) + _, token, err := PublicTokenAuth.Encode(claims.ToMap()) return token, err } @@ -91,6 +97,15 @@ func Validate(tokenStr string) (Claims, error) { return ClaimsFromToken(token), nil } +// ValidatePublic verifies a public-link token against the public secret. +func ValidatePublic(tokenStr string) (Claims, error) { + token, err := jwtauth.VerifyToken(PublicTokenAuth, tokenStr) + if err != nil { + return Claims{}, err + } + return ClaimsFromToken(token), nil +} + func WithAdminUser(ctx context.Context, ds model.DataStore) context.Context { u, err := ds.User(ctx).FindFirstAdmin() if err != nil { @@ -107,14 +122,14 @@ func WithAdminUser(ctx context.Context, ds model.DataStore) context.Context { return request.WithUser(ctx, *u) } -func createNewSecret(ctx context.Context, ds model.DataStore) string { +func createNewSecret(ctx context.Context, ds model.DataStore, key string) string { secret := id.NewRandom() encSecret, err := utils.Encrypt(ctx, getEncKey(), secret) if err != nil { log.Error(ctx, "Could not encrypt JWT secret", err) return secret } - if err := ds.Property(ctx).Put(consts.JWTSecretKey, encSecret); err != nil { + if err := ds.Property(ctx).Put(key, encSecret); err != nil { log.Error(ctx, "Could not save JWT secret in DB", err) } return secret diff --git a/core/auth/auth_test.go b/core/auth/auth_test.go index 3a3585e53..e5cbb2352 100644 --- a/core/auth/auth_test.go +++ b/core/auth/auth_test.go @@ -89,6 +89,51 @@ var _ = Describe("Auth", func() { }) }) + Describe("Session/Public secret split", func() { + claims := func() map[string]any { + return map[string]any{"iss": "issuer", "exp": time.Now().Add(1 * time.Minute).Unix()} + } + + It("verifies a session token via Validate but not ValidatePublic", func() { + _, tokenStr, err := auth.TokenAuth.Encode(claims()) + Expect(err).NotTo(HaveOccurred()) + _, err = auth.Validate(tokenStr) + Expect(err).NotTo(HaveOccurred()) + _, err = auth.ValidatePublic(tokenStr) + Expect(err).To(HaveOccurred()) + }) + + It("verifies a public token via ValidatePublic but not Validate", func() { + _, tokenStr, err := auth.PublicTokenAuth.Encode(claims()) + Expect(err).NotTo(HaveOccurred()) + _, err = auth.ValidatePublic(tokenStr) + Expect(err).NotTo(HaveOccurred()) + _, err = auth.Validate(tokenStr) + Expect(err).To(HaveOccurred()) + }) + + It("decodes public tokens minted by CreatePublicToken via PublicTokenAuth", func() { + tokenStr, err := auth.CreatePublicToken(auth.Claims{ID: "art-1"}) + Expect(err).NotTo(HaveOccurred()) + claims, err := auth.ValidatePublic(tokenStr) + Expect(err).NotTo(HaveOccurred()) + Expect(claims.ID).To(Equal("art-1")) + _, err = auth.Validate(tokenStr) + Expect(err).To(HaveOccurred()) + }) + + It("decodes expiring public tokens minted by CreateExpiringPublicToken via PublicTokenAuth", func() { + exp := time.Now().Add(1 * time.Hour) + tokenStr, err := auth.CreateExpiringPublicToken(exp, auth.Claims{ID: "art-2"}) + Expect(err).NotTo(HaveOccurred()) + claims, err := auth.ValidatePublic(tokenStr) + Expect(err).NotTo(HaveOccurred()) + Expect(claims.ID).To(Equal("art-2")) + _, err = auth.Validate(tokenStr) + Expect(err).To(HaveOccurred()) + }) + }) + Describe("TouchToken", func() { It("updates the expiration time", func() { yesterday := time.Now().Add(-oneDay) diff --git a/core/publicurl/publicurl_test.go b/core/publicurl/publicurl_test.go index a195fb9cd..7e9ee8b8e 100644 --- a/core/publicurl/publicurl_test.go +++ b/core/publicurl/publicurl_test.go @@ -175,7 +175,7 @@ var _ = Describe("Public URL Utilities", func() { BeforeEach(func() { conf.Server.ShareURL = "https://share.example.com" // Initialize JWT auth for token generation - auth.TokenAuth = jwtauth.New("HS256", []byte("test secret"), nil) + auth.PublicTokenAuth = jwtauth.New("HS256", []byte("test secret"), nil) }) It("generates a URL with the artwork token", func() { diff --git a/db/migrations/20260720015443_uniform_canonical_ids.go b/db/migrations/20260720015443_uniform_canonical_ids.go new file mode 100644 index 000000000..346f22050 --- /dev/null +++ b/db/migrations/20260720015443_uniform_canonical_ids.go @@ -0,0 +1,310 @@ +package migrations + +import ( + "context" + "crypto/md5" + "database/sql" + "encoding/hex" + "encoding/json" + "fmt" + "math/big" + "strings" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/model/id" + "github.com/pressly/goose/v3" +) + +// canonicalID maps any historical Navidrome id shape to the canonical 22-char base62 encoding +// of a 128-bit value; unrecognized shapes (including empty and share ids) pass through unchanged. +func canonicalID(s string) string { + switch len(s) { + case 22: + v, ok := new(big.Int).SetString(s, 62) + if !ok || v.Sign() < 0 || v.BitLen() <= 128 { + return s + } + sum := md5.Sum([]byte(s)) + return id.Encode(sum) + case 32: + b, err := hex.DecodeString(s) + if err != nil { + return s + } + return id.Encode([16]byte(b)) + case 36: + if s[8] != '-' || s[13] != '-' || s[18] != '-' || s[23] != '-' { + return s + } + b, err := hex.DecodeString(s[:8] + s[9:13] + s[14:18] + s[19:23] + s[24:]) + if err != nil { + return s + } + return id.Encode([16]byte(b)) + } + return s +} + +func init() { + goose.AddMigrationContext(upUniformCanonicalIds, downUniformCanonicalIds) +} + +// idColumns is the exhaustive list of Navidrome-id-bearing columns; anything absent is deliberately exempt. +var idColumns = []struct{ table, col string }{ + {"media_file", "id"}, {"media_file", "pid"}, {"media_file", "artist_id"}, + {"media_file", "album_id"}, {"media_file", "album_artist_id"}, {"media_file", "folder_id"}, + {"album", "id"}, {"album", "album_artist_id"}, + {"artist", "id"}, + {"folder", "id"}, {"folder", "parent_id"}, + {"tag", "id"}, + {"library_artist", "artist_id"}, + {"user", "id"}, + {"user_props", "user_id"}, + {"playlist", "id"}, {"playlist", "owner_id"}, + {"playlist_tracks", "playlist_id"}, {"playlist_tracks", "media_file_id"}, + {"playlist_fields", "playlist_id"}, + {"annotation", "user_id"}, {"annotation", "item_id"}, + {"bookmark", "user_id"}, {"bookmark", "item_id"}, + {"player", "id"}, {"player", "user_id"}, {"player", "transcoding_id"}, + {"transcoding", "id"}, + {"radio", "id"}, + {"share", "user_id"}, + {"scrobble_buffer", "id"}, {"scrobble_buffer", "user_id"}, {"scrobble_buffer", "media_file_id"}, + {"playqueue", "id"}, {"playqueue", "user_id"}, + {"user_library", "user_id"}, + {"scrobbles", "user_id"}, {"scrobbles", "media_file_id"}, + {"media_file_artists", "media_file_id"}, {"media_file_artists", "artist_id"}, + {"album_artists", "album_id"}, {"album_artists", "artist_id"}, + {"library_tag", "tag_id"}, +} + +// embeddedIDColumns holds ids nested inside a larger value; the id-columns guard checks this +// list against the schema, so every JSON column must appear here or be exempted there. +var embeddedIDColumns = []struct { + table, col string + transform func(string) (string, bool) +}{ + {"playqueue", "items", canonicalizeIDList}, + {"share", "resource_ids", canonicalizeIDList}, + {"plugin", "users", canonicalizePluginUsers}, + {"playlist", "rules", canonicalizePlaylistRules}, +} + +func upUniformCanonicalIds(ctx context.Context, tx *sql.Tx) error { + if err := buildIDMap(ctx, tx); err != nil { + return err + } + for _, tc := range idColumns { + if err := applyIDMap(ctx, tx, tc.table, tc.col); err != nil { + return fmt.Errorf("canonicalizing %s.%s: %w", tc.table, tc.col, err) + } + } + for _, tc := range embeddedIDColumns { + if err := rewriteColumn(ctx, tx, tc.table, tc.col, tc.transform); err != nil { + return fmt.Errorf("canonicalizing %s.%s: %w", tc.table, tc.col, err) + } + } + // Legacy PID specs embed old-shaped album/track ids into composite pids; a full rescan + // rewrites every pid with the new encoding so path-based move matching stays consistent. + if strings.Contains(conf.Server.PID.Track, "legacy") || strings.Contains(conf.Server.PID.Album, "legacy") { + if err := forceFullRescan(ctx, tx); err != nil { + return err + } + } + if err := rotateSessionSecret(ctx, tx); err != nil { + return err + } + _, err := tx.ExecContext(ctx, "DROP TABLE _id_map") + return err +} + +// rotateSessionSecret renames the session JWT secret to the public key, so public-link tokens +// keep verifying while auth.Init mints a fresh session secret, killing every stale session. +func rotateSessionSecret(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, + `UPDATE property SET id = ? WHERE id = ? AND NOT EXISTS (SELECT 1 FROM property WHERE id = ?)`, + consts.JWTPublicSecretKey, consts.JWTSecretKey, consts.JWTPublicSecretKey) + return err +} + +// buildIDMap stages old->new pairs for every id that changes, indexed for the update joins. +func buildIDMap(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, + "CREATE TEMP TABLE _id_map (old_id TEXT PRIMARY KEY, new_id TEXT NOT NULL) WITHOUT ROWID") + if err != nil { + return err + } + ins, err := tx.PrepareContext(ctx, "INSERT OR IGNORE INTO _id_map (old_id, new_id) VALUES (?, ?)") + if err != nil { + return err + } + defer ins.Close() + for _, tc := range idColumns { + if err := collectColumn(ctx, tx, ins, tc.table, tc.col); err != nil { + return fmt.Errorf("collecting %s.%s: %w", tc.table, tc.col, err) + } + } + return nil +} + +func collectColumn(ctx context.Context, tx *sql.Tx, ins *sql.Stmt, table, col string) error { + rows, err := tx.QueryContext(ctx, fmt.Sprintf( + "SELECT DISTINCT %[2]s FROM %[1]s WHERE %[2]s IS NOT NULL AND %[2]s <> ''", table, col)) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var old string + if err := rows.Scan(&old); err != nil { + return err + } + if newID := canonicalID(old); newID != old { + if _, err := ins.ExecContext(ctx, old, newID); err != nil { + return err + } + } + } + return rows.Err() +} + +func applyIDMap(ctx context.Context, tx *sql.Tx, table, col string) error { + _, err := tx.ExecContext(ctx, fmt.Sprintf( + `UPDATE %[1]s SET %[2]s = (SELECT new_id FROM _id_map WHERE old_id = %[1]s.%[2]s) + WHERE %[2]s IN (SELECT old_id FROM _id_map)`, table, col)) + return err +} + +// canonicalizeIDList canonicalizes a comma-separated id list element-wise. +func canonicalizeIDList(s string) (string, bool) { + parts := strings.Split(s, ",") + changed := false + for i, p := range parts { + if n := canonicalID(p); n != p { + parts[i] = n + changed = true + } + } + if !changed { + return s, false + } + return strings.Join(parts, ","), true +} + +// rewriteColumn applies transform to each non-empty cell, updating only rows it changed. +func rewriteColumn(ctx context.Context, tx *sql.Tx, table, col string, transform func(string) (string, bool)) error { + rows, err := tx.QueryContext(ctx, fmt.Sprintf( + "SELECT rowid, %[2]s FROM %[1]s WHERE ifnull(%[2]s, '') <> ''", table, col)) + if err != nil { + return err + } + type change struct { + rowid int64 + val string + } + var changes []change + for rows.Next() { + var rowid int64 + var val string + if err := rows.Scan(&rowid, &val); err != nil { + _ = rows.Close() + return err + } + if newVal, ok := transform(val); ok { + changes = append(changes, change{rowid, newVal}) + } + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return err + } + _ = rows.Close() + if len(changes) == 0 { + return nil + } + upd, err := tx.PrepareContext(ctx, fmt.Sprintf("UPDATE %s SET %s = ? WHERE rowid = ?", table, col)) + if err != nil { + return err + } + defer upd.Close() + for _, c := range changes { + if _, err := upd.ExecContext(ctx, c.val, c.rowid); err != nil { + return err + } + } + return nil +} + +// canonicalizePluginUsers maps a JSON array of user ids; malformed JSON passes through untouched. +func canonicalizePluginUsers(s string) (string, bool) { + var users []string + if err := json.Unmarshal([]byte(s), &users); err != nil { + return s, false + } + changed := false + for i, u := range users { + if n := canonicalID(u); n != u { + users[i] = n + changed = true + } + } + if !changed { + return s, false + } + out, err := json.Marshal(users) + if err != nil { + return s, false + } + return string(out), true +} + +// canonicalizePlaylistRules rewrites inPlaylist/notInPlaylist ids in smart-playlist criteria; malformed JSON passes through. +func canonicalizePlaylistRules(s string) (string, bool) { + var root map[string]any + if err := json.Unmarshal([]byte(s), &root); err != nil { + return s, false + } + if !canonicalizeRulesNode(root) { + return s, false + } + out, err := json.Marshal(root) + if err != nil { + return s, false + } + return string(out), true +} + +// canonicalizeRulesNode walks the criteria tree, canonicalizing the id of any inPlaylist/notInPlaylist object. +func canonicalizeRulesNode(node any) bool { + changed := false + switch v := node.(type) { + case map[string]any: + for k, val := range v { + if lk := strings.ToLower(k); lk == "inplaylist" || lk == "notinplaylist" { + if obj, ok := val.(map[string]any); ok { + if id, ok := obj["id"].(string); ok { + if n := canonicalID(id); n != id { + obj["id"] = n + changed = true + } + } + } + } + if canonicalizeRulesNode(val) { + changed = true + } + } + case []any: + for _, item := range v { + if canonicalizeRulesNode(item) { + changed = true + } + } + } + return changed +} + +func downUniformCanonicalIds(ctx context.Context, tx *sql.Tx) error { + return nil // irreversible data migration +} diff --git a/db/migrations/id_canonical_test.go b/db/migrations/id_canonical_test.go new file mode 100644 index 000000000..0befbe469 --- /dev/null +++ b/db/migrations/id_canonical_test.go @@ -0,0 +1,47 @@ +package migrations + +import ( + "strings" + + "github.com/navidrome/navidrome/model/id" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("canonicalID", func() { + DescribeTable("transforms each historical id shape", + func(in, want string) { + Expect(canonicalID(in)).To(Equal(want)) + }, + Entry("hash-family id (fits 128 bits) is kept", "5cLJPkLA5DK2BADhoeotPk", "5cLJPkLA5DK2BADhoeotPk"), + Entry("overflowing random id is remapped via md5", "zzzzzzzzzzzzzzzzzzzzzz", "3LyqmwQBm5IRqlVjNYASwb"), + Entry("legacy 32-hex is re-encoded value-preserving", "e3b7fc2ae9447bbec37a13bf916e3cf6", "6VHl3uR4kss6sUPKA8Cwnk"), + Entry("playlist uuid is re-encoded value-preserving", "f47ac10b-58cc-4372-a567-0e02b2c3d479", "7rke2SAWaicSeSYzkhww6R"), + Entry("empty string passes through", "", ""), + Entry("share id (10 chars) passes through", "aB3xY9kQz1", "aB3xY9kQz1"), + Entry("truncated Finamp id (16 chars) passes through", "0123456789abcdef", "0123456789abcdef"), + Entry("22 chars with non-base62 char passes through", "!!!!!!!!!!!!!!!!!!!!!!", "!!!!!!!!!!!!!!!!!!!!!!"), + Entry("32 chars non-hex passes through", "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"), + Entry("36 chars without uuid dashes passes through", "000000000000000000000000000000000000", "000000000000000000000000000000000000"), + ) + + // The exemptions for participants/tags/folder_ids/similar_artists rest on this invariant. + It("is the identity on every NewHash id", func() { + for _, parts := range [][]string{ + {""}, {"a"}, {"The Beatles"}, {"genre", "electronic"}, + {"/music/Artist/Album", "1"}, {strings.Repeat("x", 500)}, + } { + h := id.NewHash(parts...) + Expect(h).To(HaveLen(22)) + Expect(canonicalID(h)).To(Equal(h), "NewHash(%v) = %q was rewritten", parts, h) + } + }) + + It("is idempotent for every shape", func() { + for _, s := range []string{"5cLJPkLA5DK2BADhoeotPk", "zzzzzzzzzzzzzzzzzzzzzz", + "e3b7fc2ae9447bbec37a13bf916e3cf6", "f47ac10b-58cc-4372-a567-0e02b2c3d479"} { + once := canonicalID(s) + Expect(canonicalID(once)).To(Equal(once)) + } + }) +}) diff --git a/db/migrations/id_columns_guard_test.go b/db/migrations/id_columns_guard_test.go new file mode 100644 index 000000000..c4cb3b241 --- /dev/null +++ b/db/migrations/id_columns_guard_test.go @@ -0,0 +1,119 @@ +package migrations + +import ( + "context" + "database/sql" + "os" + "strings" + + _ "github.com/mattn/go-sqlite3" + "github.com/pressly/goose/v3" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Version of the uniform-canonical-ids migration whose idColumns list this guard protects. +const idColumnsMigrationVersion = 20260720015443 + +var _ = Describe("idColumns inventory", func() { + It("covers every id-bearing column present at the migration version", func() { + ctx := context.Background() + db, err := sql.Open("sqlite3", "file::memory:") + Expect(err).ToNot(HaveOccurred()) + db.SetMaxOpenConns(1) // non-shared :memory: — a second conn would be an empty DB + DeferCleanup(func() { _ = db.Close() }) + _, err = db.ExecContext(ctx, "PRAGMA foreign_keys=off") + Expect(err).ToNot(HaveOccurred()) + + goose.SetBaseFS(os.DirFS(".")) + DeferCleanup(func() { goose.SetBaseFS(nil) }) + Expect(goose.SetDialect("sqlite3")).To(Succeed()) + Expect(goose.UpToContext(ctx, db, ".", idColumnsMigrationVersion)).To(Succeed()) + + covered := map[string]bool{} + for _, tc := range idColumns { + covered[tc.table+"."+tc.col] = true + } + for _, tc := range embeddedIDColumns { + covered[tc.table+"."+tc.col] = true + } + // Columns that are id-named or JSON but need no rewrite. + exempt := map[string]string{ + "share.id": "public share URLs, generated separately", + "property.id": "property key, not an entity id", + "plugin.id": "plugin name, not an entity id", + "media_file.participants": "hash-family artist ids, unchanged", + "album.participants": "hash-family artist ids, unchanged", + "media_file.tags": "hash-family tag ids, unchanged", + "album.tags": "hash-family tag ids, unchanged", + "album.folder_ids": "hash-family folder ids, unchanged", + "artist.similar_artists": "hash-family artist ids, unchanged", + "media_file.search_participants": "participant names for FTS, not ids", + "album.search_participants": "participant names for FTS, not ids", + "album.discs": "disc number -> title map", + "media_file.lyrics": "synced lyrics, no ids", + "folder.image_files": "image file names, no ids", + "plugin.manifest": "plugin-authored manifest, no Navidrome ids", + "plugin.config": "free-form plugin config, must not be rewritten", + "plugin.libraries": "integer library ids", + } + + tables, err := queryColumn(ctx, db, "SELECT name FROM sqlite_master WHERE type='table'") + Expect(err).ToNot(HaveOccurred()) + + var dangling []string + for _, table := range tables { + if strings.HasPrefix(table, "sqlite_") || table == "goose_db_version" || strings.Contains(table, "_fts") { + continue + } + rows, err := db.QueryContext(ctx, "SELECT name, type FROM pragma_table_info(?)", table) + Expect(err).ToNot(HaveOccurred()) + for rows.Next() { + var name, typ string + Expect(rows.Scan(&name, &typ)).To(Succeed()) + lname := strings.ToLower(name) + utyp := strings.ToUpper(typ) + // JSON can hide an id under any key, so every JSON column needs a verdict. + isJSON := strings.Contains(utyp, "JSON") + isIDName := lname == "id" || lname == "pid" || + strings.HasSuffix(lname, "_id") || strings.HasSuffix(lname, "_ids") + if !isJSON && !isIDName { + continue + } + if !isJSON && !strings.Contains(utyp, "TEXT") && !strings.Contains(utyp, "CHAR") { + continue // INTEGER ids (rowid PKs, library.id) are not canonical ids + } + if strings.HasPrefix(lname, "mbz_") { + continue // MusicBrainz UUIDs, not Navidrome ids + } + key := table + "." + name + if covered[key] || exempt[key] != "" { + continue + } + dangling = append(dangling, key) + } + Expect(rows.Err()).ToNot(HaveOccurred()) + Expect(rows.Close()).To(Succeed()) + } + Expect(dangling).To(BeEmpty(), + "id-bearing columns missing from idColumns; add them to the migration or exempt with a reason: %v", dangling) + }) +}) + +func queryColumn(ctx context.Context, db *sql.DB, query string) ([]string, error) { + rows, err := db.QueryContext(ctx, query) + if err != nil { + return nil, err + } + defer rows.Close() + var out []string + for rows.Next() { + var s string + if err := rows.Scan(&s); err != nil { + return nil, err + } + out = append(out, s) + } + return out, rows.Err() +} diff --git a/db/migrations/migrations_suite_test.go b/db/migrations/migrations_suite_test.go new file mode 100644 index 000000000..bb08ad8aa --- /dev/null +++ b/db/migrations/migrations_suite_test.go @@ -0,0 +1,16 @@ +package migrations + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// tests.Init is omitted: the tests package imports db, which imports this package. +func TestMigrations(t *testing.T) { + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Migrations Suite") +} diff --git a/db/migrations/uniform_canonical_ids_test.go b/db/migrations/uniform_canonical_ids_test.go new file mode 100644 index 000000000..ced75cb7e --- /dev/null +++ b/db/migrations/uniform_canonical_ids_test.go @@ -0,0 +1,233 @@ +package migrations + +import ( + "context" + "database/sql" + "encoding/json" + + _ "github.com/mattn/go-sqlite3" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("upUniformCanonicalIds", func() { + var db *sql.DB + var tx *sql.Tx + ctx := context.Background() + + const ( + hashID = "5cLJPkLA5DK2BADhoeotPk" // canonical, kept + randOld = "zzzzzzzzzzzzzzzzzzzzzz" // overflows -> remapped + randNew = "3LyqmwQBm5IRqlVjNYASwb" + legacyOld = "e3b7fc2ae9447bbec37a13bf916e3cf6" // 32-hex -> re-encoded + legacyNew = "6VHl3uR4kss6sUPKA8Cwnk" + uuidOld = "f47ac10b-58cc-4372-a567-0e02b2c3d479" // uuid -> re-encoded + uuidNew = "7rke2SAWaicSeSYzkhww6R" + shareID = "aB3xY9kQz1" // exempt family + + sessionSecret = "encrypted-session-secret-sentinel" + ) + + BeforeEach(func() { + var err error + db, err = sql.Open("sqlite3", "file::memory:") + Expect(err).ToNot(HaveOccurred()) + db.SetMaxOpenConns(1) // non-shared :memory: — every new conn is a fresh empty DB + DeferCleanup(func() { _ = db.Close() }) + + // Minimal fixture: every table/column idColumns and the list rewrites touch. + _, err = db.Exec(` + CREATE TABLE media_file (id text, pid text, artist_id text, album_id text, album_artist_id text, folder_id text, mbz_recording_id text); + CREATE TABLE album (id text, album_artist_id text); + CREATE TABLE artist (id text); + CREATE TABLE folder (id text, parent_id text); + CREATE TABLE tag (id text); + CREATE TABLE library_artist (artist_id text); + CREATE TABLE user (id text); + CREATE TABLE user_props (user_id text); + CREATE TABLE playlist (id text, owner_id text, rules text); + CREATE TABLE playlist_tracks (playlist_id text, media_file_id text); + CREATE TABLE playlist_fields (playlist_id text); + CREATE TABLE annotation (user_id text, item_id text, item_type text); + CREATE TABLE bookmark (user_id text, item_id text); + CREATE TABLE player (id text, user_id text, transcoding_id text); + CREATE TABLE transcoding (id text); + CREATE TABLE radio (id text); + CREATE TABLE share (id text, user_id text, resource_ids text, contents text); + CREATE TABLE scrobble_buffer (id text, user_id text, media_file_id text); + CREATE TABLE playqueue (id text, user_id text, items text); + CREATE TABLE user_library (user_id text, library_id integer); + CREATE TABLE scrobbles (id integer, media_file_id text, user_id text); + CREATE TABLE media_file_artists (media_file_id text, artist_id text); + CREATE TABLE album_artists (album_id text, artist_id text); + CREATE TABLE library_tag (tag_id text, library_id integer); + CREATE TABLE plugin (id text, users text); + CREATE TABLE property (id text primary key, value text); + `) + Expect(err).ToNot(HaveOccurred()) + + seed := func(query string, args ...any) { + _, err := db.Exec(query, args...) + ExpectWithOffset(1, err).ToNot(HaveOccurred()) + } + // media_file: legacy id/pid, hash artist, legacy album, mbz uuid must stay untouched + seed(`INSERT INTO media_file VALUES (?, ?, ?, ?, '', '', ?)`, legacyOld, legacyOld, hashID, legacyOld, uuidOld) + seed(`INSERT INTO album VALUES (?, ?)`, legacyOld, hashID) + seed(`INSERT INTO user VALUES (?)`, randOld) + // uuid id, random owner, smart-playlist rules with an embedded inPlaylist id and a sibling operator + seed(`INSERT INTO playlist VALUES (?, ?, ?)`, uuidOld, randOld, `{"all":[{"inPlaylist":{"id":"`+uuidOld+`"}},{"inTheLast":{"lastPlayed":30}}]}`) + seed(`INSERT INTO annotation VALUES (?, ?, 'media_file')`, randOld, legacyOld) + seed(`INSERT INTO playqueue VALUES (?, ?, ?)`, randOld, randOld, legacyOld+","+hashID) + seed(`INSERT INTO share VALUES (?, ?, ?, 'Album Foo...')`, shareID, randOld, legacyOld+","+uuidOld) + seed(`INSERT INTO user_library VALUES (?, 1)`, randOld) + seed(`INSERT INTO scrobbles VALUES (1, ?, ?)`, legacyOld, randOld) + seed(`INSERT INTO media_file_artists VALUES (?, ?)`, legacyOld, hashID) + seed(`INSERT INTO album_artists VALUES (?, ?)`, legacyOld, hashID) + seed(`INSERT INTO library_tag VALUES (?, 1)`, hashID) + seed(`INSERT INTO plugin VALUES ('lastfm', ?)`, `["`+randOld+`","`+hashID+`"]`) + seed(`INSERT INTO plugin VALUES ('empty', '[]')`) // exempt: empty user list untouched + // malformed JSON in both a plugin list and a playlist rule: must pass through byte-for-byte + seed(`INSERT INTO plugin VALUES ('broken', 'not-json')`) + seed(`INSERT INTO playlist VALUES (?, ?, '{broken')`, hashID, hashID) + seed(`INSERT INTO property VALUES (?, ?)`, consts.JWTSecretKey, sessionSecret) + }) + + JustBeforeEach(func() { + var err error + tx, err = db.Begin() + Expect(err).ToNot(HaveOccurred()) + Expect(upUniformCanonicalIds(ctx, tx)).To(Succeed()) + Expect(tx.Commit()).To(Succeed()) + }) + + get := func(query string) string { + var s string + ExpectWithOffset(1, db.QueryRow(query).Scan(&s)).To(Succeed()) + return s + } + + It("canonicalizes every id family and keeps references consistent", func() { + Expect(get(`SELECT id FROM media_file`)).To(Equal(legacyNew)) + Expect(get(`SELECT pid FROM media_file`)).To(Equal(legacyNew)) + Expect(get(`SELECT artist_id FROM media_file`)).To(Equal(hashID)) + Expect(get(`SELECT album_id FROM media_file`)).To(Equal(legacyNew)) + Expect(get(`SELECT id FROM album`)).To(Equal(legacyNew)) + Expect(get(`SELECT id FROM user`)).To(Equal(randNew)) + Expect(get(`SELECT id FROM playlist WHERE owner_id='` + randNew + `'`)).To(Equal(uuidNew)) + Expect(get(`SELECT owner_id FROM playlist WHERE id='` + uuidNew + `'`)).To(Equal(randNew)) + Expect(get(`SELECT item_id FROM annotation`)).To(Equal(legacyNew)) + Expect(get(`SELECT user_id FROM annotation`)).To(Equal(randNew)) + }) + + It("rewrites list columns element-wise", func() { + Expect(get(`SELECT items FROM playqueue`)).To(Equal(legacyNew + "," + hashID)) + Expect(get(`SELECT resource_ids FROM share`)).To(Equal(legacyNew + "," + uuidNew)) + }) + + It("leaves exempt columns alone", func() { + Expect(get(`SELECT id FROM share`)).To(Equal(shareID)) + Expect(get(`SELECT contents FROM share`)).To(Equal("Album Foo...")) + Expect(get(`SELECT mbz_recording_id FROM media_file`)).To(Equal(uuidOld)) + }) + + It("canonicalizes junction and membership tables", func() { + Expect(get(`SELECT user_id FROM user_library`)).To(Equal(randNew)) + Expect(get(`SELECT media_file_id FROM scrobbles`)).To(Equal(legacyNew)) + Expect(get(`SELECT user_id FROM scrobbles`)).To(Equal(randNew)) + Expect(get(`SELECT media_file_id FROM media_file_artists`)).To(Equal(legacyNew)) + Expect(get(`SELECT artist_id FROM media_file_artists`)).To(Equal(hashID)) // hash-family, unchanged + Expect(get(`SELECT album_id FROM album_artists`)).To(Equal(legacyNew)) + Expect(get(`SELECT artist_id FROM album_artists`)).To(Equal(hashID)) // hash-family, unchanged + Expect(get(`SELECT tag_id FROM library_tag`)).To(Equal(hashID)) // hash-family, unchanged + }) + + It("rewrites JSON columns element-wise", func() { + Expect(get(`SELECT users FROM plugin WHERE id='lastfm'`)).To(Equal(`["` + randNew + `","` + hashID + `"]`)) + Expect(get(`SELECT id FROM plugin WHERE id='lastfm'`)).To(Equal("lastfm")) // plugin name, untouched + + var rules map[string]any + Expect(json.Unmarshal([]byte(get(`SELECT rules FROM playlist WHERE id='`+uuidNew+`'`)), &rules)).To(Succeed()) + all, ok := rules["all"].([]any) + Expect(ok).To(BeTrue()) + Expect(all).To(HaveLen(2)) + inPl := all[0].(map[string]any)["inPlaylist"].(map[string]any) + Expect(inPl["id"]).To(Equal(uuidNew)) + }) + + It("preserves sibling operators alongside a rewritten inPlaylist id", func() { + var rules map[string]any + Expect(json.Unmarshal([]byte(get(`SELECT rules FROM playlist WHERE id='`+uuidNew+`'`)), &rules)).To(Succeed()) + all := rules["all"].([]any) + var sawInPlaylist, sawInTheLast bool + for _, e := range all { + op := e.(map[string]any) + if pl, ok := op["inPlaylist"].(map[string]any); ok { + Expect(pl["id"]).To(Equal(uuidNew)) + sawInPlaylist = true + } + if last, ok := op["inTheLast"].(map[string]any); ok { + Expect(last["lastPlayed"]).To(Equal(float64(30))) + sawInTheLast = true + } + } + Expect(sawInPlaylist).To(BeTrue()) + Expect(sawInTheLast).To(BeTrue()) + }) + + It("leaves exempt JSON rows untouched", func() { + Expect(get(`SELECT users FROM plugin WHERE id='empty'`)).To(Equal("[]")) + }) + + It("passes malformed JSON columns through byte-for-byte", func() { + Expect(get(`SELECT users FROM plugin WHERE id='broken'`)).To(Equal("not-json")) + Expect(get(`SELECT rules FROM playlist WHERE id='` + hashID + `'`)).To(Equal("{broken")) + }) + + rescanCount := func() int { + var count int + ExpectWithOffset(1, db.QueryRow( + `SELECT count(*) FROM property WHERE id = ?`, consts.FullScanAfterMigrationFlagKey).Scan(&count)).To(Succeed()) + return count + } + + It("does not force a full rescan for the default PID config", func() { + Expect(rescanCount()).To(Equal(0)) + }) + + Context("with a legacy PID configuration", func() { + BeforeEach(func() { + prev := conf.Server.PID.Album + conf.Server.PID.Album = "album_legacy" + DeferCleanup(func() { conf.Server.PID.Album = prev }) + }) + + It("forces a full rescan so composite pids are rewritten", func() { + Expect(rescanCount()).To(Equal(1)) + }) + }) + + propCount := func(key string) int { + var count int + ExpectWithOffset(1, db.QueryRow(`SELECT count(*) FROM property WHERE id = ?`, key).Scan(&count)).To(Succeed()) + return count + } + + It("rotates the session secret to the public key", func() { + Expect(propCount(consts.JWTSecretKey)).To(Equal(0)) + Expect(get(`SELECT value FROM property WHERE id = '` + consts.JWTPublicSecretKey + `'`)).To(Equal(sessionSecret)) + }) + + Context("with no stored session secret", func() { + BeforeEach(func() { + _, err := db.Exec(`DELETE FROM property WHERE id = ?`, consts.JWTSecretKey) + Expect(err).ToNot(HaveOccurred()) + }) + + It("is a no-op", func() { + Expect(propCount(consts.JWTSecretKey)).To(Equal(0)) + Expect(propCount(consts.JWTPublicSecretKey)).To(Equal(0)) + }) + }) +}) diff --git a/model/id/id.go b/model/id/id.go index b54542898..2885bc6ad 100644 --- a/model/id/id.go +++ b/model/id/id.go @@ -2,20 +2,36 @@ package id import ( "crypto/md5" + "crypto/rand" "fmt" "math/big" "strings" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/utils/nanoid" ) func NewRandom() string { - id, err := nanoid.Generate("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 22) - if err != nil { - log.Error("Could not generate new ID", err) + var b [16]byte + _, _ = rand.Read(b[:]) + return Encode(b) +} + +// Encode renders a 16-byte value as the canonical 22-char zero-padded base62 id. +func Encode(b [16]byte) string { + return fmt.Sprintf("%022s", new(big.Int).SetBytes(b[:]).Text(62)) +} + +// Decode is the exact inverse of Encode. +func Decode(s string) ([]byte, error) { + if len(s) != 22 { + return nil, fmt.Errorf("invalid id length %d", len(s)) } - return id + v, ok := new(big.Int).SetString(s, 62) + if !ok || v.Sign() < 0 { + return nil, fmt.Errorf("invalid base62 id %q", s) + } + if v.BitLen() > 128 { + return nil, fmt.Errorf("id %q overflows 128 bits", s) + } + return v.FillBytes(make([]byte, 16)), nil } func NewHash(data ...string) string { @@ -24,11 +40,7 @@ func NewHash(data ...string) string { hash.Write([]byte(d)) hash.Write([]byte(string('\u200b'))) } - h := hash.Sum(nil) - bi := big.NewInt(0) - bi.SetBytes(h) - s := bi.Text(62) - return fmt.Sprintf("%022s", s) + return Encode([16]byte(hash.Sum(nil))) } func NewTagID(name, value string) string { diff --git a/model/id/id_suite_test.go b/model/id/id_suite_test.go new file mode 100644 index 000000000..e8a41f4e5 --- /dev/null +++ b/model/id/id_suite_test.go @@ -0,0 +1,17 @@ +package id_test + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestID(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "ID Suite") +} diff --git a/model/id/id_test.go b/model/id/id_test.go new file mode 100644 index 000000000..407560a75 --- /dev/null +++ b/model/id/id_test.go @@ -0,0 +1,64 @@ +package id_test + +import ( + "github.com/navidrome/navidrome/model/id" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Encode/Decode", func() { + It("encodes 16 bytes as 22-char zero-padded base62", func() { + Expect(id.Encode([16]byte{})).To(Equal("0000000000000000000000")) + allFF := [16]byte{0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff} + Expect(id.Encode(allFF)).To(Equal("7N42dgm5tFLK9N8MT7fHC7")) + }) + + It("round-trips arbitrary 16-byte values", func() { + b := [16]byte{0xe3, 0xb7, 0xfc, 0x2a, 0xe9, 0x44, 0x7b, 0xbe, + 0xc3, 0x7a, 0x13, 0xbf, 0x91, 0x6e, 0x3c, 0xf6} + s := id.Encode(b) + Expect(s).To(Equal("6VHl3uR4kss6sUPKA8Cwnk")) + Expect(id.Decode(s)).To(Equal(b[:])) + }) + + It("rejects invalid input", func() { + _, err := id.Decode("short") + Expect(err).To(HaveOccurred()) + _, err = id.Decode("!!!!!!!!!!!!!!!!!!!!!!") // 22 chars, not base62 + Expect(err).To(HaveOccurred()) + _, err = id.Decode("-000000000000000000001") // sign is not part of the alphabet + Expect(err).To(HaveOccurred()) + _, err = id.Decode("zzzzzzzzzzzzzzzzzzzzzz") // > 2^128 + Expect(err).To(HaveOccurred()) + }) +}) + +var _ = Describe("NewRandom", func() { + It("emits 22-char canonical ids that always fit 128 bits", func() { + seen := make(map[string]struct{}) + for range 1000 { + s := id.NewRandom() + Expect(s).To(HaveLen(22)) + _, err := id.Decode(s) + Expect(err).ToNot(HaveOccurred(), "id %q must decode to 128 bits", s) + seen[s] = struct{}{} + } + Expect(seen).To(HaveLen(1000)) + }) +}) + +var _ = Describe("NewHash", func() { + It("keeps its historical output byte-for-byte (golden)", func() { + Expect(id.NewHash("test")).To(Equal("5cLJPkLA5DK2BADhoeotPk")) + Expect(id.NewHash("[unknown artist]")).To(Equal("7lsE5pS09fPS1VuFqwXbia")) + Expect(id.NewTagID("genre", "electronic")).To(Equal("7bLYq0Np81m1Wgy5N31nuG")) + }) + + It("always emits 22 decodable chars", func() { + h := id.NewHash("anything", "at", "all") + Expect(h).To(HaveLen(22)) + _, err := id.Decode(h) + Expect(err).ToNot(HaveOccurred()) + }) +}) diff --git a/model/metadata/legacy_ids.go b/model/metadata/legacy_ids.go index 18a273550..7156c4f50 100644 --- a/model/metadata/legacy_ids.go +++ b/model/metadata/legacy_ids.go @@ -9,17 +9,19 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" ) -// These are the legacy ID functions that were used in the original Navidrome ID generation. -// They are kept here for backwards compatibility with existing databases. +// These legacy ID functions hash the same inputs as the original Navidrome ID generation, +// now emitted in the canonical base62 encoding (matching what the uniform-ids migration stores). func legacyTrackID(mf model.MediaFile, prependLibId bool) string { - id := mf.Path + key := mf.Path if prependLibId && mf.LibraryID != model.DefaultLibraryID { - id = fmt.Sprintf("%d\\%s", mf.LibraryID, id) + key = fmt.Sprintf("%d\\%s", mf.LibraryID, key) } - return fmt.Sprintf("%x", md5.Sum([]byte(id))) + sum := md5.Sum([]byte(key)) + return id.Encode(sum) } func legacyAlbumID(mf model.MediaFile, md Metadata, prependLibId bool) string { @@ -33,7 +35,8 @@ func legacyAlbumID(mf model.MediaFile, md Metadata, prependLibId bool) string { if prependLibId && mf.LibraryID != model.DefaultLibraryID { albumPath = fmt.Sprintf("%d\\%s", mf.LibraryID, albumPath) } - return fmt.Sprintf("%x", md5.Sum([]byte(albumPath))) + sum := md5.Sum([]byte(albumPath)) + return id.Encode(sum) } func legacyMapAlbumArtistName(md Metadata) string { diff --git a/model/metadata/persistent_ids_test.go b/model/metadata/persistent_ids_test.go index eb66d11d1..8e38bbd42 100644 --- a/model/metadata/persistent_ids_test.go +++ b/model/metadata/persistent_ids_test.go @@ -218,6 +218,21 @@ var _ = Describe("getPID", func() { }) Context("legacy specs", func() { + It("emits canonical 22-char base62 ids", func() { + mf := model.MediaFile{Path: "/music/a.mp3", LibraryID: 1} + // md5("/music/a.mp3") = e3b7fc2ae9447bbec37a13bf916e3cf6 re-encoded as base62 + Expect(legacyTrackID(mf, false)).To(Equal("6VHl3uR4kss6sUPKA8Cwnk")) + }) + It("prepends the library id for a non-default library", func() { + mf := model.MediaFile{Path: "/music/a.mp3", LibraryID: 2} + // id.Encode(md5.Sum([]byte("2\\/music/a.mp3"))) + Expect(legacyTrackID(mf, true)).To(Equal("4EK5DHQBMeFuDHw6S3iooO")) + }) + It("emits a canonical album id (golden)", func() { + mf := model.MediaFile{LibraryID: 1} + // id.Encode(md5.Sum([]byte("[unknown artist]\\[unknown album]"))) + Expect(legacyAlbumID(mf, Metadata{}, false)).To(Equal("6xBmxSAUFJSQuW7UvwCq8X")) + }) Context("track_legacy", func() { When("library ID is default (1)", func() { It("should not prepend library ID even when prependLibId is true", func() { diff --git a/plugins/host_artwork_test.go b/plugins/host_artwork_test.go index ed8a0e810..218d3d892 100644 --- a/plugins/host_artwork_test.go +++ b/plugins/host_artwork_test.go @@ -225,7 +225,7 @@ func decodeArtworkURL(artworkURL string) model.ArtworkID { } // Decode JWT token - token, err := auth.TokenAuth.Decode(tokenPart) + token, err := auth.PublicTokenAuth.Decode(tokenPart) Expect(err).ToNot(HaveOccurred(), "Failed to decode JWT token") c := auth.ClaimsFromToken(token) diff --git a/server/jellyfin/README.md b/server/jellyfin/README.md index 17d2797ab..13ec79aa8 100644 --- a/server/jellyfin/README.md +++ b/server/jellyfin/README.md @@ -90,9 +90,9 @@ skipped, so it doesn't create a nameless player. 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`. +which chokes on Navidrome's base62 ids (e.g. `5QFKvMsJrd57QE2Le2dKKo`). Because a base62 id can +itself be valid hex, correctness depends on every emit path encoding and every receive path +decoding — see `dto/ids.go`. ## Multi-library behavior @@ -184,13 +184,13 @@ their Navidrome `ArtworkID`. 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. +bytes. Navidrome ids are 22-character base62 strings, not 32-hex 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; +id 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. diff --git a/server/jellyfin/dto/ids.go b/server/jellyfin/dto/ids.go index 3490ba260..2ac7e2a79 100644 --- a/server/jellyfin/dto/ids.go +++ b/server/jellyfin/dto/ids.go @@ -3,7 +3,7 @@ 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. +// Finamp's queue packing) and crash on Navidrome's base62 ids if emitted as-is. func EncodeID(id string) string { if id == "" { return "" diff --git a/server/jellyfin/dto/ids_test.go b/server/jellyfin/dto/ids_test.go index 26a957604..d85786d99 100644 --- a/server/jellyfin/dto/ids_test.go +++ b/server/jellyfin/dto/ids_test.go @@ -6,7 +6,7 @@ import ( ) var _ = Describe("id codec", func() { - It("round-trips a base62 nanoid through Encode/Decode", func() { + It("round-trips a base62 id", func() { id := "5QFKvMsJrd57QE2Le2dKKo" Expect(DecodeID(EncodeID(id))).To(Equal(id)) }) diff --git a/server/jellyfin/truncated_ids.go b/server/jellyfin/truncated_ids.go index b58296391..bb3e4ce9b 100644 --- a/server/jellyfin/truncated_ids.go +++ b/server/jellyfin/truncated_ids.go @@ -11,8 +11,8 @@ import ( ) // 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. +// Jellyfin GUIDs). All Navidrome ids are 22 chars (share ids 10), so length alone flags 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). diff --git a/server/public/handle_images.go b/server/public/handle_images.go index 50f9238e5..633665e0f 100644 --- a/server/public/handle_images.go +++ b/server/public/handle_images.go @@ -68,7 +68,7 @@ func (pub *Router) handleImages(w http.ResponseWriter, r *http.Request) { } func decodeArtworkID(tokenString string) (model.ArtworkID, error) { - token, err := auth.TokenAuth.Decode(tokenString) + token, err := auth.PublicTokenAuth.Decode(tokenString) if err != nil { return model.ArtworkID{}, err } diff --git a/server/public/handle_images_test.go b/server/public/handle_images_test.go index 6895241f6..669f879a6 100644 --- a/server/public/handle_images_test.go +++ b/server/public/handle_images_test.go @@ -9,7 +9,7 @@ import ( var _ = Describe("decodeArtworkID", func() { BeforeEach(func() { - auth.TokenAuth = jwtauth.New("HS256", []byte("super secret"), nil) + auth.PublicTokenAuth = jwtauth.New("HS256", []byte("super secret"), nil) }) It("fails to decode an invalid token", func() { diff --git a/server/public/handle_shares.go b/server/public/handle_shares.go index 76f674483..13a7e4c32 100644 --- a/server/public/handle_shares.go +++ b/server/public/handle_shares.go @@ -111,8 +111,8 @@ func (pub *Router) mapShareToM3U(r *http.Request, s model.Share) *model.Share { // 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. +// necessarily already holds the public-link signing secret, a full-server +// compromise that 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 15abab693..3d624f661 100644 --- a/server/public/handle_streams.go +++ b/server/public/handle_streams.go @@ -107,7 +107,7 @@ func shareContainsTrack(share *model.Share, mediaFileID string) bool { // 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) + c, err := auth.ValidatePublic(tokenString) if err != nil { return shareTrackInfo{}, err } diff --git a/server/public/handle_streams_test.go b/server/public/handle_streams_test.go index 2f32ea6f2..870dfa8ef 100644 --- a/server/public/handle_streams_test.go +++ b/server/public/handle_streams_test.go @@ -29,7 +29,7 @@ func (m *mockStreamer) NewStream(_ context.Context, _ *model.MediaFile, r stream var _ = Describe("decodeStreamInfo", func() { BeforeEach(func() { - auth.TokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil) + auth.PublicTokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil) }) It("decodes a valid token with all fields", func() { @@ -81,7 +81,7 @@ var _ = Describe("decodeStreamInfo", func() { var _ = Describe("encodeMediafileShare", func() { BeforeEach(func() { - auth.TokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil) + auth.PublicTokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil) }) It("includes the share ID in the token", func() { @@ -113,7 +113,7 @@ var _ = Describe("handleStream", func() { var pub *Router BeforeEach(func() { - auth.TokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil) + auth.PublicTokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil) ds = &tests.MockDataStore{} shareRepo = &tests.MockShareRepo{} ds.MockedShare = shareRepo diff --git a/server/subsonic/helpers_test.go b/server/subsonic/helpers_test.go index 3741462a2..8db624649 100644 --- a/server/subsonic/helpers_test.go +++ b/server/subsonic/helpers_test.go @@ -22,6 +22,7 @@ var _ = Describe("helpers", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) auth.TokenAuth = jwtauth.New("HS256", []byte("test secret"), nil) + auth.PublicTokenAuth = jwtauth.New("HS256", []byte("test public secret"), nil) }) Describe("fakePath", func() { From 279ff98e0d5f0639077f730083b0c3fd498c800e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 2 Aug 2026 19:46:05 -0400 Subject: [PATCH 46/71] fix(ui): stop precaching index.html so logins can't show the wrong user (#5882) * fix(ui): stop precaching index.html so logins can't show the wrong user index.html is rendered per-user by the server: it carries __APP_CONFIG__, including the auth payload (user id, name, role, Subsonic token) when authentication comes from a reverse-proxy header. Workbox precached it, and because precacheAndRoute registers its route before the NetworkOnly NavigationRoute, every navigation to /app/ was answered from Cache Storage with a frozen copy of whoever installed the service worker. With ExtAuth, signing out and back in as a different user therefore kept showing the previous user, along with their Subsonic token. Cache Storage ignores the no-store header serve_index.go already sets, and ignores the browser's "disable cache", so only clearing site data recovered. Excluding index.html from the precache manifest lets the NetworkOnly navigation strategy do the job it was written for. Existing poisoned caches heal themselves: the entry is dropped when the new manifest activates. The same stale document caused the create-admin dialog to reappear (#3613), patched then by calling removeHomeCache() after login. That helper only ran on password login and token refresh, so it never covered the ExtAuth path, and a refetch re-poisoned the cache anyway. Fixing the cause makes it dead code, so it is removed. Also serve the offline page on 5xx: it arrives as a normal response, so the existing catch never saw it, and without a precached shell a restarting server would surface a raw gateway error. The offline copy is reworded to fit both causes. * test(ui): cover the service worker navigation fallback rules The handler lived inside sw.js, which only loads in a service worker where workbox arrives via importScripts, so none of it was reachable from vitest. Moving the decision into its own module pins the rules that matter: a 5xx falls back to the offline page like a thrown network error does, while 4xx and 304 still pass through to the app. --- .gitignore | 1 + ui/public/offline.html | 2 +- ui/src/authProvider.js | 2 - ui/src/dataProvider/httpClient.js | 2 - ui/src/sw.js | 20 +++++----- ui/src/swNavigation.js | 14 +++++++ ui/src/swNavigation.test.js | 62 +++++++++++++++++++++++++++++++ ui/src/utils/removeHomeCache.js | 20 ---------- ui/vite.config.js | 3 ++ 9 files changed, 90 insertions(+), 36 deletions(-) create mode 100644 ui/src/swNavigation.js create mode 100644 ui/src/swNavigation.test.js delete mode 100644 ui/src/utils/removeHomeCache.js diff --git a/.gitignore b/.gitignore index fc8eaac69..3567a7d90 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,4 @@ openspec/ .agents go.work* .worktrees/ +.playwright-mcp/ diff --git a/ui/public/offline.html b/ui/public/offline.html index 72f2d04ae..e3c44e8d3 100644 --- a/ui/public/offline.html +++ b/ui/public/offline.html @@ -5,6 +5,6 @@

It looks like we are having trouble connecting.
-Please check your internet connection and try again.

+Please check your internet connection, or try again in a moment.

\ No newline at end of file diff --git a/ui/src/authProvider.js b/ui/src/authProvider.js index 813a4f5b4..18badbc9c 100644 --- a/ui/src/authProvider.js +++ b/ui/src/authProvider.js @@ -1,7 +1,6 @@ import { jwtDecode } from 'jwt-decode' import { baseUrl } from './utils' import config from './config' -import { removeHomeCache } from './utils/removeHomeCache' // config sent from server may contain authentication info, for example when the user is authenticated // by a reverse proxy request header @@ -49,7 +48,6 @@ const authProvider = { storeAuthenticationInfo(response) // Avoid "going to create admin" dialog after logout/login without a refresh config.firstTime = false - removeHomeCache() return response }) .catch((error) => { diff --git a/ui/src/dataProvider/httpClient.js b/ui/src/dataProvider/httpClient.js index 3820f658d..706486fa6 100644 --- a/ui/src/dataProvider/httpClient.js +++ b/ui/src/dataProvider/httpClient.js @@ -3,7 +3,6 @@ import { v4 as uuidv4 } from 'uuid' import { baseUrl } from '../utils' import config from '../config' import { jwtDecode } from 'jwt-decode' -import { removeHomeCache } from '../utils/removeHomeCache' const customAuthorizationHeader = 'X-ND-Authorization' export const clientUniqueIdHeader = 'X-ND-Client-Unique-Id' @@ -27,7 +26,6 @@ const httpClient = (url, options = {}) => { localStorage.setItem('userId', decoded.uid) // Avoid going to create admin dialog after logout/login without a refresh config.firstTime = false - removeHomeCache() } return response }) diff --git a/ui/src/sw.js b/ui/src/sw.js index f4a5664b8..edc272850 100644 --- a/ui/src/sw.js +++ b/ui/src/sw.js @@ -1,5 +1,7 @@ /* eslint-disable */ +import { createNavigationHandler } from './swNavigation' + // documentation: https://developers.google.com/web/tools/workbox/modules/workbox-sw importScripts('3rdparty/workbox/workbox-sw.js') @@ -36,17 +38,13 @@ self.addEventListener('install', async (event) => { }) const networkOnly = new workbox.strategies.NetworkOnly() -const navigationHandler = async (params) => { - try { - // Attempt a network request. - return await networkOnly.handle(params) - } catch (error) { - // If it fails, return the cached HTML. - return caches.match(FALLBACK_HTML_URL, { - cacheName: CACHE_NAME, - }) - } -} +const fallbackToCachedHtml = () => + caches.match(FALLBACK_HTML_URL, { cacheName: CACHE_NAME }) + +const navigationHandler = createNavigationHandler( + networkOnly, + fallbackToCachedHtml, +) // self.__WB_MANIFEST is default injection point workbox.precaching.precacheAndRoute(self.__WB_MANIFEST) diff --git a/ui/src/swNavigation.js b/ui/src/swNavigation.js new file mode 100644 index 000000000..28d01eaef --- /dev/null +++ b/ui/src/swNavigation.js @@ -0,0 +1,14 @@ +// Lives outside sw.js so it can be unit tested: sw.js only loads inside a +// service worker, where workbox arrives via importScripts. +export const createNavigationHandler = + (networkOnly, offlineFallback) => async (params) => { + try { + // Attempt a network request. + const response = await networkOnly.handle(params) + // A 5xx reaches us as a normal response, but carries no usable app + return response.status >= 500 ? offlineFallback() : response + } catch (error) { + // If it fails, return the cached HTML. + return offlineFallback() + } + } diff --git a/ui/src/swNavigation.test.js b/ui/src/swNavigation.test.js new file mode 100644 index 000000000..8ab749757 --- /dev/null +++ b/ui/src/swNavigation.test.js @@ -0,0 +1,62 @@ +import { describe, it, expect, vi } from 'vitest' +import { createNavigationHandler } from './swNavigation' + +const OFFLINE = { offlinePage: true } + +const setup = (networkResult) => { + const networkOnly = { + handle: vi.fn(() => + networkResult instanceof Error + ? Promise.reject(networkResult) + : Promise.resolve(networkResult), + ), + } + const offlineFallback = vi.fn(() => Promise.resolve(OFFLINE)) + return { + networkOnly, + offlineFallback, + handler: createNavigationHandler(networkOnly, offlineFallback), + } +} + +describe('createNavigationHandler', () => { + it('serves the response from the network', async () => { + const response = { status: 200 } + const { handler, offlineFallback } = setup(response) + + await expect(handler({})).resolves.toBe(response) + expect(offlineFallback).not.toHaveBeenCalled() + }) + + it('falls back to the offline page when the network fails', async () => { + const { handler } = setup(new Error('Failed to fetch')) + + await expect(handler({})).resolves.toBe(OFFLINE) + }) + + it.each([500, 502, 503])( + 'falls back to the offline page on %i', + async (status) => { + const { handler } = setup({ status }) + + await expect(handler({})).resolves.toBe(OFFLINE) + }, + ) + + it.each([304, 401, 404])('passes %i through untouched', async (status) => { + const response = { status } + const { handler, offlineFallback } = setup(response) + + await expect(handler({})).resolves.toBe(response) + expect(offlineFallback).not.toHaveBeenCalled() + }) + + it('passes the navigation params to the network strategy', async () => { + const params = { request: { url: '/app/' } } + const { handler, networkOnly } = setup({ status: 200 }) + + await handler(params) + + expect(networkOnly.handle).toHaveBeenCalledWith(params) + }) +}) diff --git a/ui/src/utils/removeHomeCache.js b/ui/src/utils/removeHomeCache.js deleted file mode 100644 index 08ed720e0..000000000 --- a/ui/src/utils/removeHomeCache.js +++ /dev/null @@ -1,20 +0,0 @@ -export const removeHomeCache = async () => { - try { - const workboxKey = (await caches.keys()).find((key) => - key.startsWith('workbox-precache'), - ) - if (!workboxKey) return - - const workboxCache = await caches.open(workboxKey) - const indexKey = (await workboxCache.keys()).find((key) => - key.url.includes('app/index.html'), - ) - - if (indexKey) { - await workboxCache.delete(indexKey) - } - } catch (e) { - // eslint-disable-next-line no-console - console.error('error reading cache', e) - } -} diff --git a/ui/vite.config.js b/ui/vite.config.js index 9d9c845f1..182e825b0 100644 --- a/ui/vite.config.js +++ b/ui/vite.config.js @@ -16,6 +16,9 @@ export default defineConfig({ filename: 'sw.js', injectManifest: { maximumFileSizeToCacheInBytes: 3 * 1024 * 1024, // 3 MiB + // index.html is rendered per-user by the server, so a precached copy + // would pin one user's config (and auth payload) across logins + globIgnores: ['index.html'], }, devOptions: { enabled: true, From 77726af59ca5808dae08681233b7df98ec6cc7a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Mon, 3 Aug 2026 13:09:53 -0400 Subject: [PATCH 47/71] fix(plugins): reject plugin IDs that are unusable as directory names (#5886) The plugin ID is derived from the package filename and used verbatim as a directory name under DataFolder/plugins by the kvstore and taskqueue host services. A package installed as '..ndp' yields the ID '.', whose data directory resolves to the parent of every other plugin's directory, so it overlaps their private data. On Windows, trailing dots and spaces are dropped during path normalization, so 'foo..ndp' and 'foo.ndp' yield distinct IDs that resolve to the same directory and would share the same SQLite files. Discovery and the file watcher now derive the ID through pluginIDFromPath, which rejects '.', '..', empty names, separators, trailing dots or spaces, and anything filepath.IsLocal refuses (Windows reserved names, drive-relative paths). The loader repeats the check, since a sync failure is non-fatal and could otherwise leave a stale row reaching the host services. --- plugins/manager_loader.go | 4 ++++ plugins/manager_loader_test.go | 13 +++++++++++ plugins/manager_sync.go | 6 ++++- plugins/manager_sync_test.go | 40 ++++++++++++++++++++++++++++++++++ plugins/manager_watcher.go | 6 ++++- plugins/package.go | 22 +++++++++++++++++++ plugins/package_test.go | 30 +++++++++++++++++++++++++ 7 files changed, 119 insertions(+), 2 deletions(-) diff --git a/plugins/manager_loader.go b/plugins/manager_loader.go index fc9296427..5238a4755 100644 --- a/plugins/manager_loader.go +++ b/plugins/manager_loader.go @@ -271,6 +271,10 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { return fmt.Errorf("manager is stopped") } + if !validPluginID(p.ID) { + return fmt.Errorf("invalid plugin ID %q", p.ID) + } + // Track this operation m.loadWg.Add(1) defer m.loadWg.Done() diff --git a/plugins/manager_loader_test.go b/plugins/manager_loader_test.go index 5e6851481..6326b2218 100644 --- a/plugins/manager_loader_test.go +++ b/plugins/manager_loader_test.go @@ -1,6 +1,7 @@ package plugins import ( + "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -27,6 +28,18 @@ var _ = Describe("buildExtismManifest", func() { }) }) +var _ = Describe("loadPluginWithConfig", func() { + // Discovery already rejects these, but a row predating that check, or one + // left behind by a failed sync, must not reach the mount setup + It("refuses a plugin whose ID is not usable as a directory name", func() { + m := &Manager{plugins: make(map[string]*plugin)} + + err := m.loadPluginWithConfig(&model.Plugin{ID: "..", Path: "/does/not/matter.ndp"}) + + Expect(err).To(MatchError(ContainSubstring("invalid plugin ID"))) + }) +}) + var _ = Describe("parsePluginConfig", func() { It("returns nil for empty string", func() { result, err := parsePluginConfig("") diff --git a/plugins/manager_sync.go b/plugins/manager_sync.go index 2119f1a5a..480fa1bb9 100644 --- a/plugins/manager_sync.go +++ b/plugins/manager_sync.go @@ -152,7 +152,11 @@ func (m *Manager) syncPlugins(ctx context.Context, folder string) error { log.Trace(ctx, "Skipping non-plugin entry", "name", entry.Name(), "isDir", entry.IsDir()) continue } - name := strings.TrimSuffix(entry.Name(), PackageExtension) + name, ok := pluginIDFromPath(entry.Name()) + if !ok { + log.Warn(ctx, "Skipping plugin with unusable name", "name", entry.Name()) + continue + } filesOnDisk[name] = filepath.Join(folder, entry.Name()) } log.Debug(ctx, "Plugin sync: scanned folder", "folder", folder, "entriesTotal", len(entries), "pluginsFound", len(filesOnDisk)) diff --git a/plugins/manager_sync_test.go b/plugins/manager_sync_test.go index 26da2079b..dd64dcd3f 100644 --- a/plugins/manager_sync_test.go +++ b/plugins/manager_sync_test.go @@ -12,6 +12,46 @@ import ( . "github.com/onsi/gomega" ) +var _ = Describe("syncPlugins", func() { + var m *Manager + var repo *tests.MockPluginRepo + var folder string + + BeforeEach(func() { + folder = GinkgoT().TempDir() + repo = tests.CreateMockPluginRepo() + repo.SetData(model.Plugins{}) + m = &Manager{ds: &tests.MockDataStore{MockedPlugin: repo}} + }) + + writePackage := func(name string) { + GinkgoHelper() + manifest := &Manifest{Name: "Test Plugin", Author: "Test Author", Version: "1.0.0"} + wasm := []byte{0x00, 0x61, 0x73, 0x6d} // Minimal wasm header + Expect(createTestPackage(filepath.Join(folder, name), manifest, wasm)).To(Succeed()) + } + + It("registers a plugin with a usable ID", func() { + writePackage("my-plugin" + PackageExtension) + + Expect(m.syncPlugins(context.Background(), folder)).To(Succeed()) + + _, err := repo.Get("my-plugin") + Expect(err).ToNot(HaveOccurred()) + }) + + It("skips packages whose name yields a path-like ID", func() { + writePackage("." + PackageExtension) + writePackage(".." + PackageExtension) + + Expect(m.syncPlugins(context.Background(), folder)).To(Succeed()) + + all, err := repo.GetAll() + Expect(err).ToNot(HaveOccurred()) + Expect(all).To(BeEmpty()) + }) +}) + var _ = Describe("removePluginFromDB", func() { It("discards buffered scrobbles for the removed plugin", func() { ctx := context.Background() diff --git a/plugins/manager_watcher.go b/plugins/manager_watcher.go index d666d8620..f7f658be9 100644 --- a/plugins/manager_watcher.go +++ b/plugins/manager_watcher.go @@ -89,7 +89,11 @@ func (m *Manager) handleWatcherEvent(event notify.EventInfo) { return } - pluginName := strings.TrimSuffix(filepath.Base(path), PackageExtension) + pluginName, ok := pluginIDFromPath(path) + if !ok { + log.Warn(m.ctx, "Ignoring plugin file with unusable name", "path", path) + return + } log.Trace(m.ctx, "Plugin file event", "plugin", pluginName, "event", event.Event(), "path", path) diff --git a/plugins/package.go b/plugins/package.go index e0405d4d8..77cb6e5ac 100644 --- a/plugins/package.go +++ b/plugins/package.go @@ -5,6 +5,8 @@ import ( "errors" "fmt" "io" + "path/filepath" + "strings" ) const ( @@ -18,6 +20,26 @@ const ( wasmFileName = "plugin.wasm" ) +// validPluginID reports whether id is usable. It names a directory under +// DataFolder/plugins, so a path-like one would point at another plugin's data. +func validPluginID(id string) bool { + if id == "." || id == ".." || strings.ContainsAny(id, `/\`) || !filepath.IsLocal(id) { + return false + } + // Windows drops trailing dots and spaces, so "foo." and "foo" would end up + // sharing a directory + return strings.TrimRight(id, ". ") == id +} + +// pluginIDFromPath derives the plugin ID from a package path. +func pluginIDFromPath(path string) (string, bool) { + id := strings.TrimSuffix(filepath.Base(path), PackageExtension) + if !validPluginID(id) { + return "", false + } + return id, true +} + // ndpPackage represents a loaded .ndp plugin package. // It contains the manifest and wasm bytes read from the archive. type ndpPackage struct { diff --git a/plugins/package_test.go b/plugins/package_test.go index 4a37f4352..953797750 100644 --- a/plugins/package_test.go +++ b/plugins/package_test.go @@ -11,6 +11,36 @@ import ( . "github.com/onsi/gomega" ) +var _ = Describe("pluginIDFromPath", func() { + DescribeTable("derives the ID from the package filename", + func(path, expected string) { + id, ok := pluginIDFromPath(path) + Expect(ok).To(BeTrue()) + Expect(id).To(Equal(expected)) + }, + Entry("plain name", "/plugins/discord-rich-presence.ndp", "discord-rich-presence"), + Entry("name with spaces", "/plugins/My Plugin.ndp", "My Plugin"), + Entry("leading dot", "/plugins/.hidden.ndp", ".hidden"), + Entry("dots inside", "/plugins/v1.2.3.ndp", "v1.2.3"), + ) + + // The ID becomes a directory name under DataFolder/plugins, so a path-like + // one would let a plugin reach another plugin's data + DescribeTable("rejects IDs that are unsafe as a directory name", + func(path string) { + _, ok := pluginIDFromPath(path) + Expect(ok).To(BeFalse()) + }, + Entry("empty", "/plugins/.ndp"), + Entry("current directory", "/plugins/..ndp"), + Entry("parent directory", "/plugins/...ndp"), + // Windows drops trailing dots and spaces, so these would share a + // directory with "foo" + Entry("trailing dot", "/plugins/foo..ndp"), + Entry("trailing space", "/plugins/foo .ndp"), + ) +}) + var _ = Describe("ndpPackage", func() { var tmpDir string From 54fe6c254e5b9eedf105660fe1ca03ee271c24ca Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Mon, 3 Aug 2026 11:23:42 -0700 Subject: [PATCH 48/71] feat(plugins): implement plugin specific storage (#5839) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * implement storage api/hooks * fine. if the error messages are different, just match error * rename storageMount * add independent plugin test * round 2 * .-. * one more copypasta fail * docs(plugins): document the Storage host service The README is the plugin author reference and every other host service has a section there, but Storage had none, so the /storage guest path contract only existed in code. Documents the mount point and its backing directory, the manifest permission, the host function, and the two behaviours an author would otherwise discover the hard way: there is no size limit, and the directory outlives an uninstall. * docs(plugins): correct the library filesystem security notes The README stated in three places that library filesystem access is read-only, which stopped being true when AllowWriteAccess was added: an administrator can grant a plugin write access to libraries. Corrects those and gives the library and storage mounts the same wording for what the sandbox guarantees, since both now go through the same jail. * docs(plugins): describe symlink handling in mounts accurately The security notes claimed paths resolving outside a mount are rejected, which overstates the jail. Only lexical escapes are: '..' and absolute paths. Creating symlinks is denied, but symlinks already present are followed and do reach outside the mount, which is what lets music libraries link folders in from elsewhere. Both behaviours are pinned by tests in the plugins package. --------- Co-authored-by: Deluan Quintão --- plugins/README.md | 53 +++- plugins/host/storage.go | 13 + plugins/host/storage_gen.go | 67 +++++ plugins/host_storage.go | 34 +++ plugins/host_storage_test.go | 270 ++++++++++++++++++ plugins/manager_loader.go | 38 ++- plugins/manifest-schema.json | 14 + plugins/manifest.go | 4 + plugins/manifest_gen.go | 10 + plugins/pdk/go/host/doc.go | 1 + plugins/pdk/go/host/nd_host_storage.go | 46 +++ plugins/pdk/go/host/nd_host_storage_stub.go | 34 +++ plugins/pdk/rust/nd-pdk-host/src/lib.rs | 8 + .../rust/nd-pdk-host/src/nd_host_storage.rs | 34 +++ plugins/testdata/test-storage-plugin-2 | 1 + plugins/testdata/test-storage-plugin/go.mod | 16 ++ plugins/testdata/test-storage-plugin/go.sum | 14 + plugins/testdata/test-storage-plugin/main.go | 50 ++++ .../test-storage-plugin/manifest.json | 11 + 19 files changed, 709 insertions(+), 9 deletions(-) create mode 100644 plugins/host/storage.go create mode 100644 plugins/host/storage_gen.go create mode 100644 plugins/host_storage.go create mode 100644 plugins/host_storage_test.go create mode 100644 plugins/pdk/go/host/nd_host_storage.go create mode 100644 plugins/pdk/go/host/nd_host_storage_stub.go create mode 100644 plugins/pdk/rust/nd-pdk-host/src/nd_host_storage.rs create mode 120000 plugins/testdata/test-storage-plugin-2 create mode 100644 plugins/testdata/test-storage-plugin/go.mod create mode 100644 plugins/testdata/test-storage-plugin/go.sum create mode 100644 plugins/testdata/test-storage-plugin/main.go create mode 100644 plugins/testdata/test-storage-plugin/manifest.json diff --git a/plugins/README.md b/plugins/README.md index 08ab967f3..a2f9532ca 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -30,6 +30,7 @@ The plugin system is built on **[Extism](https://extism.org/)**, a cross-languag - [Scheduler](#scheduler) - [Cache](#cache) - [KVStore](#kvstore) + - [Storage](#storage) - [Task](#task) - [WebSocket](#websocket) - [Library](#library) @@ -548,6 +549,52 @@ usage, err := host.KVStoreGetStorageUsed() fmt.Printf("Using %d bytes\n", usage) ``` +### Storage + +A private read-write directory, mounted into the sandbox at `/storage` and backed by `${DataFolder}/plugins/${pluginID}/storage`. Survives server restarts. Use it for data that doesn't fit a key-value store: caches, downloaded files, generated indexes. + +**Manifest permission:** + +```json +{ + "permissions": { + "storage": { + "reason": "Cache generated playlists between restarts" + } + } +} +``` + +**Host functions:** + +| Function | Parameters | Description | +|--------------------------|------------|------------------------------------| +| `storage_getstoragepath` | – | Get the guest path of the mount | + +**Usage:** + +Normal WASI filesystem calls work inside the mount, so use the `os` package directly: + +```go +import ( + "os" + "path/filepath" + + "github.com/navidrome/navidrome/plugins/pdk/go/host" +) + +// The path never changes, so read it once instead of per operation +var storageDir = host.StorageGetStoragePath() // "/storage" + +err := os.WriteFile(filepath.Join(storageDir, "cache.json"), data, 0600) +content, err := os.ReadFile(filepath.Join(storageDir, "cache.json")) +entries, err := os.ReadDir(storageDir) +``` + +> **Security:** Plugins cannot create symlinks inside the mount, and `..` or absolute paths are rejected. Symlinks that already exist in the directory are still followed, so anything linked in from elsewhere remains reachable. + +> **Note:** There is no size limit, unlike [KVStore](#kvstore). The directory is not deleted when a plugin is uninstalled. + ### Task Background task queue with retry support. Plugins enqueue tasks and process them by exporting the [`nd_task_execute`](#taskworker) capability function. @@ -644,7 +691,7 @@ Access music library metadata and optionally read files from library directories } ``` -- `filesystem` – Set to `true` to enable read-only access to library directories (default: `false`) +- `filesystem` – Set to `true` to enable access to library directories, read-only unless an administrator grants write access (default: `false`) **Host functions:** @@ -683,7 +730,7 @@ content, err := os.ReadFile("/libraries/1/Artist/Album/track.mp3") entries, err := os.ReadDir("/libraries/1/Artist") ``` -> **Security:** Filesystem access is read-only and restricted to configured library paths only. +> **Security:** Plugins cannot create symlinks inside the mount, and `..` or absolute paths are rejected. Symlinks already present in the library are still followed, so folders linked in from elsewhere work as expected. Access is read-only unless an administrator grants the plugin write access (`navidrome plugin edit --write-access`). **Usage:** @@ -1058,7 +1105,7 @@ See [examples/](examples/) for complete working plugins: Plugins run in a secure WebAssembly sandbox provided by [Extism](https://extism.org/) and the [Wazero](https://wazero.io/) runtime: 1. **Host Allowlisting** – Only explicitly allowed hosts are accessible via HTTP/WebSocket -2. **Limited File System** – Read-only access to library directories, only when explicitly granted the `library.filesystem` permission +2. **Limited File System** – Plugins cannot create symlinks inside a mount and `..` or absolute paths are rejected, though symlinks already present are followed. Library access requires the `library.filesystem` permission and is read-only unless an administrator grants write access; the `storage` permission grants a read-write directory private to the plugin 3. **No Network Listeners** – Plugins cannot bind ports 4. **Config Isolation** – Plugins only receive their own config section 5. **Memory Limits** – Controlled by the WebAssembly runtime diff --git a/plugins/host/storage.go b/plugins/host/storage.go new file mode 100644 index 000000000..ed24d7508 --- /dev/null +++ b/plugins/host/storage.go @@ -0,0 +1,13 @@ +package host + +import "context" + +// StorageService provides access to a plugin-specific directory with read/write permissions +// +//nd:hostservice name=Storage permission=storage +type StorageService interface { + // GetStoragePath retrieves the persistent storage path, if allowed + // + //nd:hostfunc + GetStoragePath(ctx context.Context) string +} diff --git a/plugins/host/storage_gen.go b/plugins/host/storage_gen.go new file mode 100644 index 000000000..d705f0831 --- /dev/null +++ b/plugins/host/storage_gen.go @@ -0,0 +1,67 @@ +// Code generated by ndpgen. DO NOT EDIT. + +package host + +import ( + "context" + "encoding/json" + + extism "github.com/extism/go-sdk" +) + +// StorageGetStoragePathResponse is the response type for Storage.GetStoragePath. +type StorageGetStoragePathResponse struct { + Result string `json:"result,omitempty"` +} + +// RegisterStorageHostFunctions registers Storage service host functions. +// The returned host functions should be added to the plugin's configuration. +func RegisterStorageHostFunctions(service StorageService) []extism.HostFunction { + return []extism.HostFunction{ + newStorageGetStoragePathHostFunction(service), + } +} + +func newStorageGetStoragePathHostFunction(service StorageService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "storage_getstoragepath", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + + // Call the service method + result := service.GetStoragePath(ctx) + + // Write JSON response to plugin memory + resp := StorageGetStoragePathResponse{ + Result: result, + } + storageWriteResponse(p, stack, resp) + }, + []extism.ValueType{extism.ValueTypePTR}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + +// storageWriteResponse writes a JSON response to plugin memory. +func storageWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) { + respBytes, err := json.Marshal(resp) + if err != nil { + storageWriteError(p, stack, err) + return + } + respPtr, err := p.WriteBytes(respBytes) + if err != nil { + stack[0] = 0 + return + } + stack[0] = respPtr +} + +// storageWriteError writes an error response to plugin memory. +func storageWriteError(p *extism.CurrentPlugin, stack []uint64, err error) { + errResp := struct { + Error string `json:"error"` + }{Error: err.Error()} + respBytes, _ := json.Marshal(errResp) + respPtr, _ := p.WriteBytes(respBytes) + stack[0] = respPtr +} diff --git a/plugins/host_storage.go b/plugins/host_storage.go new file mode 100644 index 000000000..e2ead7aae --- /dev/null +++ b/plugins/host_storage.go @@ -0,0 +1,34 @@ +package plugins + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/plugins/host" +) + +const storageMount = "/storage" + +type storageServiceImpl struct{} + +func getHostStoragePath(pluginName string) string { + return filepath.Join(conf.Server.DataFolder.String(), "plugins", pluginName, "storage") +} + +func newStorageService(pluginName string) (host.StorageService, error) { + dataDir := getHostStoragePath(pluginName) + if err := os.MkdirAll(dataDir, 0700); err != nil { + return nil, fmt.Errorf("creating plugin data directory: %w", err) + } + + return &storageServiceImpl{}, nil +} + +func (s *storageServiceImpl) GetStoragePath(ctx context.Context) string { + return storageMount +} + +var _ host.StorageService = (*storageServiceImpl)(nil) diff --git a/plugins/host_storage_test.go b/plugins/host_storage_test.go new file mode 100644 index 000000000..d6f895bff --- /dev/null +++ b/plugins/host_storage_test.go @@ -0,0 +1,270 @@ +//go:build !windows + +package plugins + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + + extism "github.com/extism/go-sdk" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("utility functions", Ordered, func() { + var tmpDir string + + BeforeAll(func() { + var err error + tmpDir, err = os.MkdirTemp("", "storage-test-*") + Expect(err).ToNot(HaveOccurred()) + + DeferCleanup(configtest.SetupConfig()) + conf.Server.DataFolder = conf.NewDir(tmpDir) + + DeferCleanup(func() { + _ = os.RemoveAll(tmpDir) + }) + }) + + Describe("GetHostStoragePath", func() { + It("should join data folder, plugins, plugin name, and storage", func() { + actual := getHostStoragePath("plugin-name") + expected := filepath.Join(tmpDir, "plugins", "plugin-name", "storage") + Expect(actual).To(Equal(expected)) + }) + }) + + Describe("GetStoragePath", func() { + It("should return the fixed path", func() { + impl := storageServiceImpl{} + Expect(impl.GetStoragePath(context.TODO())).To(Equal("/storage")) + }) + }) + + Describe("netStorageService", func() { + It("should create the directory on init", func() { + svc, err := newStorageService("plugin-name") + Expect(err).ToNot(HaveOccurred()) + Expect(svc).ToNot(BeNil()) + + dataDir := filepath.Join(tmpDir, "plugins", "plugin-name", "storage") + Expect(dataDir).To(BeADirectory()) + }) + }) +}) + +var _ = Describe("Storage Host Function", Ordered, func() { + const ID = "test-storage-plugin" + + var ( + manager *Manager + tmpDir string + ) + + BeforeAll(func() { + var err error + tmpDir, err = os.MkdirTemp("", "storage-test-*") + Expect(err).ToNot(HaveOccurred()) + + // Setup mock router and data store + router := &fakeSubsonicRouter{} + userRepo := tests.CreateMockUserRepo() + dataStore := &tests.MockDataStore{MockedUser: userRepo} + + // Create and configure manager + manager = &Manager{ + plugins: make(map[string]*plugin), + ds: dataStore, + } + manager.SetSubsonicRouter(router) + + mockPluginRepo := dataStore.Plugin(GinkgoT().Context()).(*tests.MockPluginRepo) + mockPluginRepo.Permitted = true + + // Setup config + DeferCleanup(configtest.SetupConfig()) + conf.Server.DataFolder = conf.NewDir(tmpDir) + conf.Server.Plugins.Enabled = true + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) + conf.Server.Plugins.AutoReload = false + + pluginPaths := []string{ID, ID + "-2"} + plugins := []model.Plugin{} + + for idx := range pluginPaths { + path := pluginPaths[idx] + PackageExtension + // Copy test plugin to temp dir + srcPath := filepath.Join(testdataDir, path) + destPath := filepath.Join(tmpDir, path) + data, err := os.ReadFile(srcPath) + Expect(err).ToNot(HaveOccurred()) + err = os.WriteFile(destPath, data, 0600) + Expect(err).ToNot(HaveOccurred()) + + // Pre-enable the plugin in the mock repo so it loads on startup + // Compute SHA256 of the plugin file to match what syncPlugins will compute + pluginPath := filepath.Join(tmpDir, path) + wasmData, err := os.ReadFile(pluginPath) + Expect(err).ToNot(HaveOccurred()) + hash := sha256.Sum256(wasmData) + hashHex := hex.EncodeToString(hash[:]) + + plugins = append(plugins, model.Plugin{ + ID: pluginPaths[idx], + Path: pluginPath, + SHA256: hashHex, + Enabled: true, + AllUsers: true, // Allow all users for test plugin + }) + } + + mockPluginRepo.SetData(plugins) + + // Start the manager + err = manager.Start(GinkgoT().Context()) + Expect(err).ToNot(HaveOccurred()) + + DeferCleanup(func() { + _ = manager.Stop() + _ = os.RemoveAll(tmpDir) + }) + }) + + var instance *extism.Plugin + BeforeEach(func() { + var err error + manager.mu.RLock() + plugin := manager.plugins[ID] + manager.mu.RUnlock() + Expect(plugin).ToNot(BeNil()) + + ctx := GinkgoT().Context() + instance, err = plugin.instance(ctx) + Expect(err).ToNot(HaveOccurred()) + + DeferCleanup(func() { + instance.Close(ctx) + }) + }) + + Describe("Read", func() { + BeforeAll(func() { + path := filepath.Join(getHostStoragePath(ID), "real") + err := os.WriteFile(path, []byte("1234"), 0600) + Expect(err).ToNot(HaveOccurred()) + + DeferCleanup(func() { + _ = os.Remove(path) + }) + }) + + It("should fail to read missing file", func() { + exit, _, err := instance.Call("call_read", []byte("missing")) + Expect(exit).To(Equal(uint32(1))) + Expect(err).To(HaveOccurred()) + }) + + It("should read an existing file", func() { + exit, output, err := instance.Call("call_read", []byte("real")) + Expect(exit).To(Equal(uint32(0))) + Expect(err).ToNot(HaveOccurred()) + Expect(output).To(Equal([]byte("1234"))) + }) + + It("should not escape read", func() { + path := filepath.Join(getHostStoragePath(ID), "..", "outside") + err := os.WriteFile(path, []byte("outside"), 0600) + Expect(err).ToNot(HaveOccurred()) + + exit, _, err := instance.Call("call_read", []byte("../outside")) + Expect(exit).To(Equal(uint32(1))) + Expect(err).To(HaveOccurred()) + }) + }) + + Describe("Write", func() { + BeforeAll(func() { + path := filepath.Join(getHostStoragePath(ID), "real") + err := os.WriteFile(path, []byte("1234"), 0600) + Expect(err).ToNot(HaveOccurred()) + + DeferCleanup(func() { + _ = os.Remove(path) + }) + }) + + It("should fail to write to nested file", func() { + exit, _, err := instance.Call("call_write", []byte(`{"path":"nested/file","contents":"1234"}`)) + Expect(exit).To(Equal(uint32(1))) + Expect(err).To(HaveOccurred()) + }) + + It("should write to a file", func() { + exit, _, err := instance.Call("call_write", []byte(`{"path":"new","contents":"contents"}`)) + Expect(exit).To(Equal(uint32(0))) + Expect(err).ToNot(HaveOccurred()) + + data, err := os.ReadFile(filepath.Join(getHostStoragePath(ID), "new")) + Expect(err).ToNot(HaveOccurred()) + Expect(data).To(Equal([]byte("contents"))) + + exit, output, err := instance.Call("call_read", []byte("new")) + Expect(exit).To(Equal(uint32(0))) + Expect(err).ToNot(HaveOccurred()) + Expect(output).To(Equal([]byte("contents"))) + }) + + It("should not escape writing a file", func() { + path := filepath.Join(getHostStoragePath(ID), "..", "outside") + err := os.WriteFile(path, []byte("outside"), 0600) + Expect(err).ToNot(HaveOccurred()) + + exit, _, err := instance.Call("call_write", []byte(`{"path":"../new","contents":"contents"}`)) + Expect(exit).To(Equal(uint32(1))) + Expect(err).To(HaveOccurred()) + }) + + It("should have independent storage for multiple plugins", func() { + manager.mu.RLock() + plugin2 := manager.plugins[ID+"-2"] + manager.mu.RUnlock() + + Expect(plugin2).ToNot(BeNil()) + + secondInstance, err := plugin2.instance(GinkgoT().Context()) + Expect(err).ToNot(HaveOccurred()) + defer secondInstance.Close(GinkgoT().Context()) + + instances := []*extism.Plugin{instance, secondInstance} + names := []string{ID, ID + "-2"} + + for idx := range instances { + exit, _, err := instances[idx].Call("call_write", fmt.Appendf(nil, `{"path":"new","contents":"%s"}`, names[idx])) + Expect(exit).To(Equal(uint32(0))) + Expect(err).ToNot(HaveOccurred()) + } + + for idx := range names { + data, err := os.ReadFile(filepath.Join(getHostStoragePath(names[idx]), "new")) + Expect(err).ToNot(HaveOccurred()) + Expect(data).To(Equal([]byte(names[idx]))) + } + + for idx := range instances { + exit, output, err := instances[idx].Call("call_read", []byte("new")) + Expect(exit).To(Equal(uint32(0))) + Expect(err).ToNot(HaveOccurred()) + Expect(output).To(Equal([]byte(names[idx]))) + } + }) + }) +}) diff --git a/plugins/manager_loader.go b/plugins/manager_loader.go index 5238a4755..0e9e7bb62 100644 --- a/plugins/manager_loader.go +++ b/plugins/manager_loader.go @@ -166,6 +166,17 @@ var hostServices = []hostServiceEntry{ return host.RegisterTaskHostFunctions(service), service, nil }, }, + { + name: "Storage", + hasPermission: func(p *Permissions) bool { return p != nil && p.Storage != nil }, + create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) { + service, err := newStorageService(ctx.pluginName) + if err != nil { + return nil, nil, err + } + return host.RegisterStorageHostFunctions(service), nil, nil + }, + }, } // extractManifest reads manifest from an .ndp package and computes its SHA-256 hash. @@ -315,14 +326,29 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { // Configure filesystem access for library permission, applied per instance var fsConfig wazero.FSConfig - if pkg.Manifest.HasLibraryFilesystemPermission() { - adminCtx := adminContext(ctx) - libraries, err := m.ds.Library(adminCtx).GetAll() - if err != nil { - return fmt.Errorf("failed to get libraries for filesystem access: %w", err) + if pkg.Manifest.HasLibraryFilesystemPermission() || pkg.Manifest.HasStoragePermission() { + mounts := []mount{} + + if pkg.Manifest.HasLibraryFilesystemPermission() { + adminCtx := adminContext(ctx) + libraries, err := m.ds.Library(adminCtx).GetAll() + if err != nil { + return fmt.Errorf("failed to get libraries for filesystem access: %w", err) + } + mounts = buildMounts(ctx, libraries, allowedLibraries, p.AllLibraries, p.AllowWriteAccess) } - fsConfig = buildFSConfig(buildMounts(ctx, libraries, allowedLibraries, p.AllLibraries, p.AllowWriteAccess)) + if pkg.Manifest.HasStoragePermission() { + pluginStore := getHostStoragePath(p.ID) + log.Info(ctx, "Granting read-write filesystem access to plugin storage", "path", pluginStore, "id", p.ID) + + mounts = append(mounts, mount{ + hostPath: pluginStore, + guestPath: storageMount, + }) + } + + fsConfig = buildFSConfig(mounts) } // Build host functions based on permissions from manifest diff --git a/plugins/manifest-schema.json b/plugins/manifest-schema.json index 28adeed79..736677313 100644 --- a/plugins/manifest-schema.json +++ b/plugins/manifest-schema.json @@ -92,6 +92,9 @@ }, "matcher": { "$ref": "#/$defs/MatcherPermission" + }, + "storage": { + "$ref": "#/$defs/StoragePermission" } } }, @@ -244,6 +247,17 @@ "description": "Explanation for why matcher access is needed" } } + }, + "StoragePermission": { + "type": "object", + "description": "Storage permissions for enabling persistent read-write storage exclusively for the plugin", + "additionalProperties": false, + "properties": { + "reason": { + "type": "string", + "description": "Explanation for why storage access is needed" + } + } } } } diff --git a/plugins/manifest.go b/plugins/manifest.go index 5e144b5c8..ef70e8d9d 100644 --- a/plugins/manifest.go +++ b/plugins/manifest.go @@ -123,3 +123,7 @@ func (m *Manifest) HasLibraryFilesystemPermission() bool { m.Permissions.Library != nil && m.Permissions.Library.Filesystem } + +func (m *Manifest) HasStoragePermission() bool { + return m.Permissions != nil && m.Permissions.Storage != nil +} diff --git a/plugins/manifest_gen.go b/plugins/manifest_gen.go index c2aa5e298..5e0035bd5 100644 --- a/plugins/manifest_gen.go +++ b/plugins/manifest_gen.go @@ -178,6 +178,9 @@ type Permissions struct { // Scheduler corresponds to the JSON schema field "scheduler". Scheduler *SchedulerPermission `json:"scheduler,omitempty" yaml:"scheduler,omitempty" mapstructure:"scheduler,omitempty"` + // Storage corresponds to the JSON schema field "storage". + Storage *StoragePermission `json:"storage,omitempty" yaml:"storage,omitempty" mapstructure:"storage,omitempty"` + // Subsonicapi corresponds to the JSON schema field "subsonicapi". Subsonicapi *SubsonicAPIPermission `json:"subsonicapi,omitempty" yaml:"subsonicapi,omitempty" mapstructure:"subsonicapi,omitempty"` @@ -197,6 +200,13 @@ type SchedulerPermission struct { Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"` } +// Storage permissions for enabling persistent read-write storage exclusively for +// the plugin +type StoragePermission struct { + // Explanation for why storage access is needed + Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"` +} + // SubsonicAPI service permissions. Requires 'users' permission to be declared. type SubsonicAPIPermission struct { // Explanation for why SubsonicAPI access is needed diff --git a/plugins/pdk/go/host/doc.go b/plugins/pdk/go/host/doc.go index ff2c2a07f..e4157e29c 100644 --- a/plugins/pdk/go/host/doc.go +++ b/plugins/pdk/go/host/doc.go @@ -43,6 +43,7 @@ The following host services are available: - Library: provides access to music library metadata for plugins. - Matcher: resolves externally-obtained songs to local library tracks, - Scheduler: provides task scheduling capabilities for plugins. + - Storage: provides access to a plugin-specific directory with read/write permissions - SubsonicAPI: provides access to Navidrome's Subsonic API from plugins. - Task: provides persistent task queues for plugins. - Users: provides access to user information for plugins. diff --git a/plugins/pdk/go/host/nd_host_storage.go b/plugins/pdk/go/host/nd_host_storage.go new file mode 100644 index 000000000..4f37bc8e2 --- /dev/null +++ b/plugins/pdk/go/host/nd_host_storage.go @@ -0,0 +1,46 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains client wrappers for the Storage host service. +// It is intended for use in Navidrome plugins built with TinyGo. +// +//go:build wasip1 + +package host + +import ( + "encoding/json" + + "github.com/navidrome/navidrome/plugins/pdk/go/pdk" +) + +// storage_getstoragepath is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user storage_getstoragepath +func storage_getstoragepath(uint64) uint64 + +type storageGetStoragePathResponse struct { + Result string `json:"result,omitempty"` +} + +// StorageGetStoragePath calls the storage_getstoragepath host function. +// GetStoragePath retrieves the persistent storage path, if allowed +func StorageGetStoragePath() string { + // No parameters - allocate empty JSON object + reqMem := pdk.AllocateBytes([]byte("{}")) + defer reqMem.Free() + + // Call the host function + responsePtr := storage_getstoragepath(reqMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response storageGetStoragePathResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return "" + } + + return response.Result +} diff --git a/plugins/pdk/go/host/nd_host_storage_stub.go b/plugins/pdk/go/host/nd_host_storage_stub.go new file mode 100644 index 000000000..c2213ece9 --- /dev/null +++ b/plugins/pdk/go/host/nd_host_storage_stub.go @@ -0,0 +1,34 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains mock implementations for non-WASM builds. +// These mocks allow IDE support, compilation, and unit testing on non-WASM platforms. +// Plugin authors can use the exported mock instances to set expectations in tests. +// +//go:build !wasip1 + +package host + +import ( + "github.com/stretchr/testify/mock" +) + +// mockStorageService is the mock implementation for testing. +type mockStorageService struct { + mock.Mock +} + +// StorageMock is the auto-instantiated mock instance for testing. +// Use this to set expectations: host.StorageMock.On("MethodName", args...).Return(values...) +var StorageMock = &mockStorageService{} + +// GetStoragePath is the mock method for StorageGetStoragePath. +func (m *mockStorageService) GetStoragePath() string { + args := m.Called() + return args.String(0) +} + +// StorageGetStoragePath delegates to the mock instance. +// GetStoragePath retrieves the persistent storage path, if allowed +func StorageGetStoragePath() string { + return StorageMock.GetStoragePath() +} diff --git a/plugins/pdk/rust/nd-pdk-host/src/lib.rs b/plugins/pdk/rust/nd-pdk-host/src/lib.rs index cc1fdc190..3da023f5c 100644 --- a/plugins/pdk/rust/nd-pdk-host/src/lib.rs +++ b/plugins/pdk/rust/nd-pdk-host/src/lib.rs @@ -40,6 +40,7 @@ //! - [`library`] - provides access to music library metadata for plugins. //! - [`matcher`] - resolves externally-obtained songs to local library tracks, //! - [`scheduler`] - provides task scheduling capabilities for plugins. +//! - [`storage`] - provides access to a plugin-specific directory with read/write permissions //! - [`subsonicapi`] - provides access to Navidrome's Subsonic API from plugins. //! - [`task`] - provides persistent task queues for plugins. //! - [`users`] - provides access to user information for plugins. @@ -101,6 +102,13 @@ pub mod scheduler { pub use super::nd_host_scheduler::*; } +#[doc(hidden)] +mod nd_host_storage; +/// provides access to a plugin-specific directory with read/write permissions +pub mod storage { + pub use super::nd_host_storage::*; +} + #[doc(hidden)] mod nd_host_subsonicapi; /// provides access to Navidrome's Subsonic API from plugins. diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_storage.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_storage.rs new file mode 100644 index 000000000..336ae565f --- /dev/null +++ b/plugins/pdk/rust/nd-pdk-host/src/nd_host_storage.rs @@ -0,0 +1,34 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains client wrappers for the Storage host service. +// It is intended for use in Navidrome plugins built with extism-pdk. + +use extism_pdk::*; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct StorageGetStoragePathResponse { + #[serde(default)] + result: String, +} + +#[host_fn] +extern "ExtismHost" { + fn storage_getstoragepath(input: Json) -> Json; +} + +/// GetStoragePath retrieves the persistent storage path, if allowed +/// +/// # Returns +/// The result value. +/// +/// # Errors +/// Returns an error if the host function call fails. +pub fn get_storage_path() -> Result { + let response = unsafe { + storage_getstoragepath(Json(serde_json::json!({})))? + }; + + Ok(response.0.result) +} diff --git a/plugins/testdata/test-storage-plugin-2 b/plugins/testdata/test-storage-plugin-2 new file mode 120000 index 000000000..444bafd87 --- /dev/null +++ b/plugins/testdata/test-storage-plugin-2 @@ -0,0 +1 @@ +test-storage-plugin \ No newline at end of file diff --git a/plugins/testdata/test-storage-plugin/go.mod b/plugins/testdata/test-storage-plugin/go.mod new file mode 100644 index 000000000..7b9a2b567 --- /dev/null +++ b/plugins/testdata/test-storage-plugin/go.mod @@ -0,0 +1,16 @@ +module test-storage-plugin + +go 1.25 + +require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/extism/go-pdk v1.1.3 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect + github.com/stretchr/testify v1.11.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go diff --git a/plugins/testdata/test-storage-plugin/go.sum b/plugins/testdata/test-storage-plugin/go.sum new file mode 100644 index 000000000..af880eb51 --- /dev/null +++ b/plugins/testdata/test-storage-plugin/go.sum @@ -0,0 +1,14 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ= +github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/plugins/testdata/test-storage-plugin/main.go b/plugins/testdata/test-storage-plugin/main.go new file mode 100644 index 000000000..17ffc8e6b --- /dev/null +++ b/plugins/testdata/test-storage-plugin/main.go @@ -0,0 +1,50 @@ +// Test plugin for Storage host function integration tests. +// Build with: tinygo build -o ../test-storage-plugin.wasm -target wasip1 -buildmode=c-shared ./main.go +package main + +import ( + "os" + "path/filepath" + + "github.com/navidrome/navidrome/plugins/pdk/go/host" + "github.com/navidrome/navidrome/plugins/pdk/go/pdk" +) + +//go:wasmexport call_read +func callRead() int32 { + path := filepath.Join(host.StorageGetStoragePath(), pdk.InputString()) + content, err := os.ReadFile(path) + if err != nil { + pdk.SetErrorString("failed to read file: " + err.Error()) + return 1 + } + pdk.Output(content) + return 0 +} + +type WriteInput struct { + Path string `json:"path"` + Contents string `json:"contents"` +} + +//go:wasmexport call_write +func callWrite() int32 { + var config WriteInput + err := pdk.InputJSON(&config) + + if err != nil { + pdk.SetErrorString("failed to parse json: " + err.Error()) + return 1 + } + + path := filepath.Join(host.StorageGetStoragePath(), config.Path) + err = os.WriteFile(path, []byte(config.Contents), 0600) + if err != nil { + pdk.SetErrorString("failed to write file: " + err.Error()) + return 1 + } + + return 0 +} + +func main() {} diff --git a/plugins/testdata/test-storage-plugin/manifest.json b/plugins/testdata/test-storage-plugin/manifest.json new file mode 100644 index 000000000..98ac24a47 --- /dev/null +++ b/plugins/testdata/test-storage-plugin/manifest.json @@ -0,0 +1,11 @@ +{ + "name": "Test Storage Plugin", + "author": "Navidrome Test", + "version": "1.0.0", + "description": "Test plugin for storage host function", + "permissions": { + "storage": { + "reason": "persistent storage for testing" + } + } +} From e1b89050df7cb228f04d5870be03af3c985fc3bc Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 3 Aug 2026 14:34:53 -0400 Subject: [PATCH 49/71] chore: exclude .worktrees from golangci-lint checks --- .golangci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.golangci.yml b/.golangci.yml index 200fe122f..0804138dc 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -69,6 +69,7 @@ linters: - examples$ - node_modules - _gen\.go$ + - .worktrees formatters: exclusions: generated: lax From 50633f839dabe49571099977753036d166734982 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Tue, 4 Aug 2026 22:07:16 -0400 Subject: [PATCH 50/71] fix(ui): restore each page's scroll position when navigating back (#5892) * fix(ui): anchor detail pages to the top when opened from a list React Router keeps the previous page's scroll offset, so opening an album from a scrolled list started the detail page mid-song-list. Artist pages had the same bug; it just shows less because the artist list is rarely long enough to scroll far. Keyed on the record id rather than mount, so detail-to-detail navigation (an album's artist link) resets too, and so the scroll waits for the record instead of firing against an empty page. * feat(ui): remember each route's scroll offset A hook that saves window.scrollY per history entry and, on navigation, tops a pushed route or returns a popped one to where it was. Keyed on location.key so back and forward each restore their own offset, and bounded because history entries are not. Callers pass their own readiness: restoring before the rows render leaves the document too short, so the browser clamps to the top and the offset is lost with no error to show for it. * refactor(ui): fold scroll-to-top into route scroll restoration useScrollToTop only ever forced the top, so it fixed opening a detail page from a scrolled list and did nothing for the return trip: coming back to the list landed wherever it happened to be. The new hook covers both, and its readiness argument carries over the reason the old one keyed on the record id - waiting for the record rather than firing against an empty page. * feat(ui): restore the album list's position when returning to it The list is the page worth remembering: scrolling a long way to find an album and landing back at the top is the annoyance the detail-page scroll fix could not reach. Mounted as a pass-through around the List's child rather than beside it, because React Admin calls Children.only on List children and clones that one child with the list props - which is also how this reads loaded/total to wait for rows. * fix(ui): key scroll restoration on the route, not location.key Hash history never assigns location.key - history.js calls createLocation with an undefined key and warns that it cannot carry state - so every route collapsed into one slot. The detail page's own scroll listener then overwrote the list's saved offset with 0, and going back restored that 0. Only the scroll-to-top half worked, which is what made it look fine. Keyed on pathname+search instead. The spec that would have caught this asserts two routes keep separate offsets; the previous ones mocked the router and so only ever confirmed the assumption. * refactor(ui): tidy up scroll restoration after review Four cleanups, none changing behaviour: ScrollRestorer read loaded/total off whatever props React Admin happened to clone in. useListContext is the contract for those values and is already used three times elsewhere, including further down this same file; the clone payload is an implementation detail that would fail silently if it ever changed. The restore runs in a layout effect now. A passive effect is flushed after paint, so an image-heavy grid was painted at the old offset and then jumped. MAX_ENTRIES matches the naming every other module-level cap in common/ uses. The per-instance guard against re-restoring was deletable with a green suite, so it now has a spec: readiness flickering on one route must not scroll twice. * fix(ui): resolve media queries on the first render MUI defaults useMediaQuery and withWidth to SSR-safe two-pass rendering: the first render reports the wrong breakpoint and a layout effect corrects it. Navidrome is client-only, so every mount paints a layout it immediately reflows. Album tiles render without their artist line and grow 14px when it arrives, and react-admin's own buttons paint labelled before collapsing to icons, which together grew the album list 79px right after a scroll restore and dragged the position with it. Setting noSsr on the theme reaches react-admin's internals too. withWidth needs its own option because it gates on a mount flag rather than on the media query. * fix(ui): keep restoring the scroll offset until it sticks A single scrollTo assumes the page is already at its final height. It often is not: the artist page reports ready as soon as its cached header record renders, while the albums below are still loading, so the browser clamps the offset to the top of a viewport-tall document and it is lost with no error. The restore now retries until the offset lands, yielding as soon as the position moves somewhere we did not put it. Keying that on input events instead breaks the trackpad back-swipe, whose wheel momentum keeps firing through the restore window. Offsets are also committed when leaving the page rather than on every scroll. Tearing the page down collapses the document and snaps to the top, and a live listener recorded that clamp over the offset being left behind. * refactor(ui): tidy up scroll restoration after review Folds the offset commit into the scroll tracker's own cleanup now that the tracker ignores the collapse snap, so one effect does what two did. Skips scheduling the retry loop when the first scrollTo already landed, and only pays for the scrollHeight read on scroll events that reach the top, since that is the only case the guard can fire. The theme hook's own prefers-color-scheme query runs above the ThemeProvider carrying the new prop, so it needs noSsr passed directly or the auto theme still renders dark first and flips. Also lifts the repeated fake-timer fixture in the specs into one helper and restores real timers from afterEach, so a failing assertion no longer leaves them installed for every later spec. * fix(ui): bank the scroll offset before the next page tops itself Committing the offset from the scroll listener's passive cleanup ran too late. The incoming page scrolls to the top in its own layout effect, and the outgoing page's listener is still attached at that moment, so it recorded that 0 over the offset being left behind. Whether it did depended on when the browser dispatched the scroll event relative to React's passive flush, which made returning to a detail page lose its position intermittently. Moving the commit back into a layout cleanup makes it deterministic: React runs those in the mutation phase, before the incoming page's layout effects. The scrollHeight guard in the listener went with it, since it only covered the case where the incoming page was shorter than the viewport and the ordering covers all of them. --- ui/src/album/AlbumList.jsx | 21 +- ui/src/album/AlbumShow.jsx | 3 +- ui/src/artist/ArtistShow.jsx | 8 +- ui/src/common/ArtistLinkField.jsx | 3 +- ui/src/common/index.js | 1 + ui/src/common/useScrollRestoration.jsx | 64 ++++++ ui/src/common/useScrollRestoration.test.jsx | 222 ++++++++++++++++++++ ui/src/themes/useCurrentTheme.js | 18 +- 8 files changed, 329 insertions(+), 11 deletions(-) create mode 100644 ui/src/common/useScrollRestoration.jsx create mode 100644 ui/src/common/useScrollRestoration.test.jsx diff --git a/ui/src/album/AlbumList.jsx b/ui/src/album/AlbumList.jsx index 5108bfaa1..cd73fa0c3 100644 --- a/ui/src/album/AlbumList.jsx +++ b/ui/src/album/AlbumList.jsx @@ -1,3 +1,4 @@ +import { cloneElement } from 'react' import { useSelector } from 'react-redux' import { Redirect, useLocation } from 'react-router-dom' import { @@ -23,6 +24,7 @@ import { Title, useAlbumsPerPage, useResourceRefresh, + useScrollRestoration, useSetToggleableFields, } from '../common' import AlbumListActions from './AlbumListActions' @@ -39,6 +41,13 @@ import ExpandInfoDialog from '../dialogs/ExpandInfoDialog' import { humanize } from 'inflection' import { makeStyles } from '@material-ui/core/styles' +// Waits for rows: restoring into an unrendered list leaves the page too short to hold the offset. +const ScrollRestorer = ({ children, ...rest }) => { + const { loaded, total } = useListContext() + useScrollRestoration(loaded && total > 0) + return cloneElement(children, rest) +} + const useStyles = makeStyles({ chip: { margin: 0, @@ -255,11 +264,13 @@ const AlbumList = (props) => { } title={} > - {albumView.grid ? ( - - ) : ( - - )} + + {albumView.grid ? ( + + ) : ( + + )} + } /> diff --git a/ui/src/album/AlbumShow.jsx b/ui/src/album/AlbumShow.jsx index c9e944999..86b5ab1bc 100644 --- a/ui/src/album/AlbumShow.jsx +++ b/ui/src/album/AlbumShow.jsx @@ -10,7 +10,7 @@ import { makeStyles } from '@material-ui/core/styles' import AlbumSongs from './AlbumSongs' import AlbumDetails from './AlbumDetails' import AlbumActions from './AlbumActions' -import { useResourceRefresh, Title } from '../common' +import { useResourceRefresh, useScrollRestoration, Title } from '../common' const useStyles = makeStyles( (theme) => ({ @@ -28,6 +28,7 @@ const AlbumShowLayout = (props) => { const { record } = context const classes = useStyles() useResourceRefresh('album', 'song') + useScrollRestoration(!!record?.id) return ( <> diff --git a/ui/src/artist/ArtistShow.jsx b/ui/src/artist/ArtistShow.jsx index 955a565d6..eef2989d5 100644 --- a/ui/src/artist/ArtistShow.jsx +++ b/ui/src/artist/ArtistShow.jsx @@ -13,7 +13,12 @@ import subsonic from '../subsonic' import AlbumGridView from '../album/AlbumGridView' import MobileArtistDetails from './MobileArtistDetails' import DesktopArtistDetails from './DesktopArtistDetails' -import { useAlbumsPerPage, useResourceRefresh, Title } from '../common/index.js' +import { + useAlbumsPerPage, + useResourceRefresh, + useScrollRestoration, + Title, +} from '../common/index.js' import ArtistActions from './ArtistActions' import { makeStyles } from '@material-ui/core' @@ -85,6 +90,7 @@ const ArtistShowLayout = (props) => { const [, perPageOptions] = useAlbumsPerPage(width) const classes = useStyles() useResourceRefresh('artist', 'album') + useScrollRestoration(!!record?.id) const maxPerPage = 90 let perPage = 0 diff --git a/ui/src/common/ArtistLinkField.jsx b/ui/src/common/ArtistLinkField.jsx index d41b47b06..2a70ded7c 100644 --- a/ui/src/common/ArtistLinkField.jsx +++ b/ui/src/common/ArtistLinkField.jsx @@ -7,7 +7,8 @@ import { intersperse } from '../utils/index.js' import { useDispatch } from 'react-redux' import { closeExtendedInfoDialog } from '../actions/dialogs.js' -const ALink = withWidth()((props) => { +// noSSR: withWidth otherwise renders null until mounted, so the artist line pops in and grows the row. +const ALink = withWidth({ noSSR: true })((props) => { const { artist, width, ...rest } = props const artistLink = useGetHandleArtistClick(width) const dispatch = useDispatch() diff --git a/ui/src/common/index.js b/ui/src/common/index.js index ac8d7f62c..c88ea3e9c 100644 --- a/ui/src/common/index.js +++ b/ui/src/common/index.js @@ -28,6 +28,7 @@ export * from './useAlbumsPerPage' export * from './useGetHandleArtistClick' export * from './useInterval' export * from './useResourceRefresh' +export * from './useScrollRestoration' export * from './useRefreshOnEvents' export * from './useToggleLove' export * from './useTraceUpdate' diff --git a/ui/src/common/useScrollRestoration.jsx b/ui/src/common/useScrollRestoration.jsx new file mode 100644 index 000000000..94e5cb2fd --- /dev/null +++ b/ui/src/common/useScrollRestoration.jsx @@ -0,0 +1,64 @@ +import { useEffect, useLayoutEffect, useRef } from 'react' +import { useHistory, useLocation } from 'react-router-dom' + +// Keyed on the route because hash history assigns no location.key, so every page would +// otherwise share one slot and overwrite the offset we came back for. +const positions = new Map() +const MAX_ENTRIES = 50 +const MAX_FRAMES = 60 + +export const useScrollRestoration = (ready = true) => { + const { pathname, search } = useLocation() + const history = useHistory() + const key = pathname + search + const handled = useRef(null) + const latest = useRef(0) + + useEffect(() => { + latest.current = window.scrollY + const track = () => { + latest.current = window.scrollY + } + window.addEventListener('scroll', track, { passive: true }) + return () => window.removeEventListener('scroll', track) + }, [key]) + + // Layout cleanup, so the offset is banked while this page is torn down. A passive one runs + // after the incoming page tops itself, and this page's listener records that 0 first. + useLayoutEffect( + () => () => { + positions.delete(key) + positions.set(key, latest.current) + if (positions.size > MAX_ENTRIES) { + positions.delete(positions.keys().next().value) + } + }, + [key], + ) + + // Layout effect: a passive one runs after paint, so the list would be painted at the old + // offset first and visibly jump. + useLayoutEffect(() => { + if (!ready || handled.current === key) return + handled.current = key + const saved = positions.get(key) + const top = history.action === 'POP' && saved !== undefined ? saved : 0 + window.scrollTo({ top }) + if (!top || Math.round(window.scrollY) === top) return + + // The page can still be filling in, and until it is tall enough the browser clamps us to + // the top. Keep asking until the offset sticks, yielding if anything else moves us. + let frame + let frames = 0 + let landed = Math.round(window.scrollY) + const retry = () => { + const y = Math.round(window.scrollY) + if (y === top || y !== landed || ++frames > MAX_FRAMES) return + window.scrollTo({ top }) + landed = Math.round(window.scrollY) + frame = requestAnimationFrame(retry) + } + frame = requestAnimationFrame(retry) + return () => cancelAnimationFrame(frame) + }, [ready, key, history.action]) +} diff --git a/ui/src/common/useScrollRestoration.test.jsx b/ui/src/common/useScrollRestoration.test.jsx new file mode 100644 index 000000000..7ca67a724 --- /dev/null +++ b/ui/src/common/useScrollRestoration.test.jsx @@ -0,0 +1,222 @@ +import { renderHook, act } from '@testing-library/react-hooks' +import { render } from '@testing-library/react' +import { useLayoutEffect } from 'react' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +const mockLocation = { pathname: '/album', search: '' } +const mockHistory = { action: 'PUSH' } +vi.mock('react-router-dom', () => ({ + useLocation: () => mockLocation, + useHistory: () => mockHistory, +})) + +import { useScrollRestoration } from './useScrollRestoration' + +describe('useScrollRestoration', () => { + beforeEach(() => { + window.scrollTo = vi.fn() + window.scrollY = 0 + mockLocation.pathname = '/album' + mockLocation.search = '' + mockHistory.action = 'PUSH' + }) + afterEach(() => { + vi.useRealTimers() + vi.clearAllMocks() + }) + + const scrollTo = (y) => { + window.scrollY = y + act(() => { + window.dispatchEvent(new Event('scroll')) + }) + } + + // Returns to a page whose rows have not arrived, so scrollTo still clamps. The returned setter + // grows the page to the height it reaches once they do. + const returnToUnfilledPage = (saved) => { + vi.useFakeTimers() + let maxScroll = saved + window.scrollTo = vi.fn(({ top }) => { + window.scrollY = Math.min(top, maxScroll) + }) + + const first = renderHook(() => useScrollRestoration()) + scrollTo(saved) + first.unmount() + + maxScroll = 0 + window.scrollY = 0 + mockHistory.action = 'POP' + renderHook(() => useScrollRestoration()) + return (height) => (maxScroll = height) + } + + const advanceFrames = () => act(() => vi.advanceTimersByTime(100)) + + it('starts a pushed route at the top', () => { + renderHook(() => useScrollRestoration()) + expect(window.scrollTo).toHaveBeenCalledWith({ top: 0 }) + }) + + it('restores the saved offset when returning to an entry', () => { + const first = renderHook(() => useScrollRestoration()) + scrollTo(640) + first.unmount() + + mockLocation.pathname = '/album/al-1/show' + const second = renderHook(() => useScrollRestoration()) + expect(window.scrollTo).toHaveBeenLastCalledWith({ top: 0 }) + second.unmount() + + mockLocation.pathname = '/album' + mockHistory.action = 'POP' + renderHook(() => useScrollRestoration()) + expect(window.scrollTo).toHaveBeenLastCalledWith({ top: 640 }) + }) + + it('tops a pushed route even if that key was seen before', () => { + const first = renderHook(() => useScrollRestoration()) + scrollTo(500) + first.unmount() + + mockHistory.action = 'PUSH' + renderHook(() => useScrollRestoration()) + expect(window.scrollTo).toHaveBeenLastCalledWith({ top: 0 }) + }) + + // Restoring before the rows exist leaves the document too short: the browser clamps to the + // top and the offset is lost with no error. + it('waits for readiness before touching the scroll position', () => { + const { rerender } = renderHook( + ({ ready }) => useScrollRestoration(ready), + { + initialProps: { ready: false }, + }, + ) + expect(window.scrollTo).not.toHaveBeenCalled() + + rerender({ ready: true }) + expect(window.scrollTo).toHaveBeenCalledWith({ top: 0 }) + }) + + // Hash history assigns no location.key, so an implementation keyed on it would collapse every + // route into one slot and the detail page would erase the list's offset before we returned. + it('keeps a separate offset per route', () => { + const list = renderHook(() => useScrollRestoration()) + scrollTo(900) + list.unmount() + + mockLocation.pathname = '/album/al-1/show' + const detail = renderHook(() => useScrollRestoration()) + scrollTo(0) + detail.unmount() + + mockLocation.pathname = '/album' + mockHistory.action = 'POP' + renderHook(() => useScrollRestoration()) + expect(window.scrollTo).toHaveBeenLastCalledWith({ top: 900 }) + }) + + // Without the per-instance guard this fires again when readiness flickers, fighting a user who + // has already started scrolling. Nothing else in the suite pins it. + it('does not scroll again when readiness flickers on the same route', () => { + const { rerender } = renderHook( + ({ ready }) => useScrollRestoration(ready), + { + initialProps: { ready: true }, + }, + ) + expect(window.scrollTo).toHaveBeenCalledTimes(1) + + rerender({ ready: false }) + rerender({ ready: true }) + expect(window.scrollTo).toHaveBeenCalledTimes(1) + }) + + it('scrolls once per entry, not on every re-render', () => { + const { rerender } = renderHook(() => useScrollRestoration()) + rerender() + rerender() + expect(window.scrollTo).toHaveBeenCalledTimes(1) + }) + + // The artist page reports ready as soon as its header record is cached, while the albums below + // are still loading. A single scrollTo lands on a viewport-tall document and is clamped to 0. + it('keeps trying until the page is tall enough to hold the offset', () => { + const fillPageTo = returnToUnfilledPage(800) + expect(window.scrollY).toBe(0) + + fillPageTo(1200) + advanceFrames() + expect(window.scrollY).toBe(800) + }) + + it('stops retrying once the user scrolls somewhere else', () => { + const fillPageTo = returnToUnfilledPage(800) + + fillPageTo(1200) + window.scrollY = 300 + advanceFrames() + expect(window.scrollY).toBe(300) + }) + + // A trackpad back-swipe keeps firing wheel momentum through the restore, so treating input as + // the yield signal cancels the gesture's own restore. + it('keeps restoring while wheel momentum fires, as a back-swipe does', () => { + const fillPageTo = returnToUnfilledPage(800) + + act(() => { + for (let i = 0; i < 10; i++) { + window.dispatchEvent( + new WheelEvent('wheel', { deltaX: -30, deltaY: 0 }), + ) + } + }) + fillPageTo(1200) + advanceFrames() + expect(window.scrollY).toBe(800) + }) + + // The incoming page tops itself in its own layout effect, while this page's scroll listener is + // still attached. Banking the offset any later than the teardown records that 0 instead. + it('records the offset before the next page scrolls to the top', () => { + const Outgoing = () => { + useScrollRestoration() + return null + } + const Incoming = () => { + useLayoutEffect(() => { + window.scrollY = 0 + window.dispatchEvent(new Event('scroll')) + }, []) + return null + } + + const { rerender } = render() + scrollTo(1400) + + mockLocation.pathname = '/album/al-1/show' + rerender() + + mockLocation.pathname = '/album' + mockHistory.action = 'POP' + renderHook(() => useScrollRestoration()) + expect(window.scrollTo).toHaveBeenLastCalledWith({ top: 1400 }) + }) + + it('keeps saving after the restore, so leaving again remembers the new offset', () => { + const first = renderHook(() => useScrollRestoration()) + scrollTo(200) + first.unmount() + + mockHistory.action = 'POP' + const second = renderHook(() => useScrollRestoration()) + expect(window.scrollTo).toHaveBeenLastCalledWith({ top: 200 }) + scrollTo(900) + second.unmount() + + renderHook(() => useScrollRestoration()) + expect(window.scrollTo).toHaveBeenLastCalledWith({ top: 900 }) + }) +}) diff --git a/ui/src/themes/useCurrentTheme.js b/ui/src/themes/useCurrentTheme.js index 0d986033d..2b5d13d13 100644 --- a/ui/src/themes/useCurrentTheme.js +++ b/ui/src/themes/useCurrentTheme.js @@ -3,10 +3,14 @@ import useMediaQuery from '@material-ui/core/useMediaQuery' import themes from './index' import { AUTO_THEME_ID } from '../consts' import config from '../config' -import { useEffect } from 'react' +import { useEffect, useMemo } from 'react' const useCurrentTheme = () => { - const prefersLightMode = useMediaQuery('(prefers-color-scheme: light)') + // Runs above the ThemeProvider carrying the prop below, so it needs its own noSsr or the + // auto theme renders dark first and flips. + const prefersLightMode = useMediaQuery('(prefers-color-scheme: light)', { + noSsr: true, + }) const theme = useSelector((state) => { if (state.theme === AUTO_THEME_ID) { return prefersLightMode ? themes.LightTheme : themes.DarkTheme @@ -50,7 +54,15 @@ const useCurrentTheme = () => { document.body.style.backgroundColor = bgColor }, [theme]) - return theme + // We never server-render, so let media queries resolve on the first render: the default + // defers them to an effect, which makes every mount paint the wrong breakpoint and reflow. + return useMemo( + () => ({ + ...theme, + props: { ...theme.props, MuiUseMediaQuery: { noSsr: true } }, + }), + [theme], + ) } export default useCurrentTheme From d764a1e9d77d255a79a13eac1ef039957051252c Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 6 Aug 2026 00:40:58 -0400 Subject: [PATCH 51/71] fix(log): change debug log level to trace to reduce log noise from cron Signed-off-by: Deluan --- scheduler/log_adapter.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scheduler/log_adapter.go b/scheduler/log_adapter.go index 057818785..f7089f97c 100644 --- a/scheduler/log_adapter.go +++ b/scheduler/log_adapter.go @@ -11,7 +11,7 @@ func (l *logger) Info(msg string, keysAndValues ...any) { "Scheduler: " + msg, } args = append(args, keysAndValues...) - log.Debug(args...) + log.Trace(args...) } func (l *logger) Error(err error, msg string, keysAndValues ...any) { From dce48ff650f0f7ba30d7f4113fc443751d4c93ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Thu, 6 Aug 2026 10:59:50 -0400 Subject: [PATCH 52/71] feat(smartplaylist): add album-level fields for sorting and filtering (#5899) Smart playlists could only sort by the track-level `dateadded`, which scatters an album's tracks because every track has its own timestamp. There was no way to express "newest albums first, tracks in album order". Adds five fields backed by the album table: albumdateadded, albumdatemodified, albumduration, albumsongcount and albumsize, so `"sort": "-albumdateadded, tracknumber"` now works. They filter as well as sort, which also enables rules like "everything from albums added this month" or "skip singles and EPs". These need the album table rather than the album_annotation table the existing album* fields join, so they get their own bit in the join mask. The bitmask already unions joins from both the expression and the sort fields, so a sort-only reference pulls in the join for the main query while correctly staying out of the percentage-limit count query. No COALESCE default is used. That mechanism exists because an album_annotation row is genuinely often absent; the album row always exists and song_count, duration and size are NOT NULL, so leaving the columns bare keeps filters index-friendly. albumdatemodified follows the existing track-level `datemodified` naming rather than the `albumdateupdated` spelling used in the request. albumreleasedate was considered and rejected: album.release_date is allOrNothing() over the tracks, so it equals the track-level releasedate when they agree and collapses to empty when they disagree. Closes https://github.com/navidrome/navidrome/discussions/5347 --- model/criteria/fields.go | 5 ++ persistence/criteria_sql.go | 6 +++ persistence/criteria_sql_test.go | 48 +++++++++++++++++++ persistence/e2e/smartplaylist_test.go | 37 ++++++++++++++ persistence/smart_playlist_repository.go | 14 +++--- persistence/smart_playlist_repository_test.go | 35 ++++++++++++++ 6 files changed, 139 insertions(+), 6 deletions(-) diff --git a/model/criteria/fields.go b/model/criteria/fields.go index 5c9ec898d..a9a634c01 100644 --- a/model/criteria/fields.go +++ b/model/criteria/fields.go @@ -71,6 +71,11 @@ var fieldMap = map[string]FieldInfo{ "albumlastplayed": {}, "albumdateloved": {}, "albumdaterated": {}, + "albumdateadded": {}, + "albumdatemodified": {}, + "albumduration": {}, + "albumsongcount": {}, + "albumsize": {}, "artistrating": {}, "artistloved": {Boolean: true}, "artistplaycount": {}, diff --git a/persistence/criteria_sql.go b/persistence/criteria_sql.go index 43cab4fdd..70f9a1fb5 100644 --- a/persistence/criteria_sql.go +++ b/persistence/criteria_sql.go @@ -21,6 +21,7 @@ const ( smartPlaylistJoinNone smartPlaylistJoinType = 0 smartPlaylistJoinAlbumAnnotation smartPlaylistJoinType = 1 << iota smartPlaylistJoinArtistAnnotation + smartPlaylistJoinAlbum ) func (j smartPlaylistJoinType) has(other smartPlaylistJoinType) bool { @@ -103,6 +104,11 @@ var smartPlaylistFields = map[string]smartPlaylistField{ "albumlastplayed": {expr: "album_annotation.play_date", joinType: smartPlaylistJoinAlbumAnnotation}, "albumdateloved": {expr: "album_annotation.starred_at", joinType: smartPlaylistJoinAlbumAnnotation}, "albumdaterated": {expr: "album_annotation.rated_at", joinType: smartPlaylistJoinAlbumAnnotation}, + "albumdateadded": {expr: "album.created_at", joinType: smartPlaylistJoinAlbum}, + "albumdatemodified": {expr: "album.updated_at", joinType: smartPlaylistJoinAlbum}, + "albumduration": {expr: "album.duration", joinType: smartPlaylistJoinAlbum}, + "albumsongcount": {expr: "album.song_count", joinType: smartPlaylistJoinAlbum}, + "albumsize": {expr: "album.size", joinType: smartPlaylistJoinAlbum}, "artistrating": {expr: "artist_annotation.rating", coalesceDefault: 0, joinType: smartPlaylistJoinArtistAnnotation}, "artistloved": {expr: "artist_annotation.starred", coalesceDefault: false, joinType: smartPlaylistJoinArtistAnnotation}, "artistplaycount": {expr: "artist_annotation.play_count", coalesceDefault: 0, joinType: smartPlaylistJoinArtistAnnotation}, diff --git a/persistence/criteria_sql_test.go b/persistence/criteria_sql_test.go index 59bdc4452..0cd7187c5 100644 --- a/persistence/criteria_sql_test.go +++ b/persistence/criteria_sql_test.go @@ -51,6 +51,11 @@ var _ = Describe("Smart playlist criteria SQL", func() { Entry("not in playlist", criteria.NotInPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id NOT IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1), Entry("album annotation", criteria.Gt{"albumRating": 3}, "album_annotation.rating > ?", 3), Entry("artist annotation", criteria.Is{"artistLoved": true}, "artist_annotation.starred = ?", true), + Entry("album column", criteria.Gt{"albumSongCount": 5}, "album.song_count > ?", 5), + Entry("album duration column", criteria.Lt{"albumDuration": 600}, "album.duration < ?", 600), + Entry("album size column", criteria.Gt{"albumSize": 1000}, "album.size > ?", 1000), + Entry("album date column", criteria.After{"albumDateAdded": time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)}, "album.created_at > ?", time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)), + Entry("album modified column", criteria.Before{"albumDateModified": time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)}, "album.updated_at < ?", time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)), // Annotation fields use a COALESCE default (0 for numeric, false for bool) so that tracks // with no annotation row behave as that default. To keep the annotation index usable, the // COALESCE is dropped when the compared value cannot match the default (the missing-row @@ -298,6 +303,11 @@ var _ = Describe("Smart playlist criteria SQL", func() { Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "random"}).OrderBy()).To(Equal("random() asc")) }) + It("sorts by album columns bare, with no COALESCE default", func() { + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "-albumDateAdded,trackNumber"}).OrderBy()). + To(Equal("album.created_at desc, media_file.track_number asc")) + }) + It("sorts by multiple fields", func() { Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "title,-rating"}).OrderBy()).To(Equal("media_file.title asc, COALESCE(annotation.rating, 0) desc")) }) @@ -323,6 +333,28 @@ var _ = Describe("Smart playlist criteria SQL", func() { } }) + It("declares a joinType matching the table each field selects from", func() { + // Omitting the joinType still compiles, so without this the field would only fail at + // refresh time with "no such column". + joinByTable := map[string]smartPlaylistJoinType{ + "media_file": smartPlaylistJoinNone, + "annotation": smartPlaylistJoinNone, + "album": smartPlaylistJoinAlbum, + "album_annotation": smartPlaylistJoinAlbumAnnotation, + "artist_annotation": smartPlaylistJoinArtistAnnotation, + } + for name, field := range smartPlaylistFields { + if field.expr == "" { + continue + } + table, _, ok := strings.Cut(field.expr, ".") + Expect(ok).To(BeTrue(), "field %q has expr %q with no table prefix", name, field.expr) + want, known := joinByTable[table] + Expect(known).To(BeTrue(), "field %q selects from unknown table %q", name, table) + Expect(field.joinType).To(Equal(want), "field %q selects from %q but declares the wrong joinType", name, table) + } + }) + Describe("JSON condition merging", func() { It("merges multiple role conditions in an OR group into a single EXISTS", func() { expr := criteria.Any{ @@ -585,5 +617,21 @@ var _ = Describe("Smart playlist criteria SQL", func() { Expect(newSmartPlaylistCriteria(c).RequiredJoins().has(smartPlaylistJoinArtistAnnotation)).To(BeTrue()) }) + + It("keeps a sort-only album join out of the expression joins", func() { + c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "love"}}, Sort: "-albumDateAdded"} + cSQL := newSmartPlaylistCriteria(c) + + Expect(cSQL.ExpressionJoins()).To(Equal(smartPlaylistJoinNone)) + Expect(cSQL.RequiredJoins().has(smartPlaylistJoinAlbum)).To(BeTrue()) + }) + + It("distinguishes the album join from the album annotation join", func() { + c := criteria.Criteria{Expression: criteria.All{criteria.Gt{"albumRating": 3}}} + joins := newSmartPlaylistCriteria(c).RequiredJoins() + + Expect(joins.has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue()) + Expect(joins.has(smartPlaylistJoinAlbum)).To(BeFalse()) + }) }) }) diff --git a/persistence/e2e/smartplaylist_test.go b/persistence/e2e/smartplaylist_test.go index 4966d3b8a..b96605d09 100644 --- a/persistence/e2e/smartplaylist_test.go +++ b/persistence/e2e/smartplaylist_test.go @@ -204,6 +204,43 @@ var _ = Describe("Smart Playlists", func() { }) }) + Describe("Album aggregate fields", func() { + // Abbey Road and IV have two tracks each; the other four albums have one. + It("matches albums with more than one track", func() { + results := evaluateRule(`{"all":[{"gt":{"albumsongcount":1}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog")) + }) + + It("matches single-track albums", func() { + results := evaluateRule(`{"all":[{"is":{"albumsongcount":1}}]}`) + Expect(results).To(ConsistOf("So What", "Bohemian Rhapsody", "All Along the Watchtower", + "We Are the Champions")) + }) + + It("matches albumDateAdded inTheLast 1 day", func() { + results := evaluateRule(`{"all":[{"inTheLast":{"albumdateadded":1}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog", + "So What", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) + }) + + It("matches albumDateModified before a far-future date", func() { + results := evaluateRule(`{"all":[{"before":{"albumdatemodified":"2099-01-01"}}]}`) + Expect(results).To(HaveLen(8)) + }) + + // Fixture durations are randomized, so only the aggregate being populated can be asserted. + It("resolves albumDuration and albumSize to non-zero aggregates", func() { + Expect(evaluateRule(`{"all":[{"gt":{"albumduration":0}}]}`)).To(HaveLen(8)) + Expect(evaluateRule(`{"all":[{"gt":{"albumsize":0}}]}`)).To(HaveLen(8)) + }) + + It("groups by album date and orders within the album (issue #5347)", func() { + results := evaluateRuleOrdered( + `{"all":[{"is":{"album":"Abbey Road"}}],"sort":"-albumdateadded,tracknumber"}`) + Expect(results).To(Equal([]string{"Come Together", "Something"})) + }) + }) + Describe("Logic operators", func() { It("matches with ALL (AND)", func() { results := evaluateRule(`{"all":[{"is":{"genre":"Blues"}},{"gt":{"bpm":130}}]}`) diff --git a/persistence/smart_playlist_repository.go b/persistence/smart_playlist_repository.go index f7c41597d..5f84981b3 100644 --- a/persistence/smart_playlist_repository.go +++ b/persistence/smart_playlist_repository.go @@ -122,7 +122,7 @@ func (r *playlistRepository) resolvePercentageLimit(pls *model.Playlist, rulesSQ exprJoins := rulesSQL.ExpressionJoins() countSq := Select("count(*) as count").From("media_file") countSq = r.addMediaFileAnnotationJoin(countSq, userID) - countSq = r.addSmartPlaylistAnnotationJoins(countSq, exprJoins, userID) + countSq = r.addSmartPlaylistJoins(countSq, exprJoins, userID) countSq = r.applyLibraryFilter(countSq, "media_file") cond, err := rulesSQL.Where() @@ -144,7 +144,7 @@ func (r *playlistRepository) resolvePercentageLimit(pls *model.Playlist, rulesSQ } // buildSmartPlaylistQuery constructs the SQL query to select media files matching the smart playlist criteria, -// including necessary joins for annotations and library filtering. +// including the joins its fields require and library filtering. func (r *playlistRepository) buildSmartPlaylistQuery(pls *model.Playlist, rulesSQL smartPlaylistCriteria, userID string) SelectBuilder { orderBy := rulesSQL.OrderBy() sq := Select("row_number() over (order by "+orderBy+") as id", "'"+pls.ID+"' as playlist_id", "media_file.id as media_file_id"). @@ -152,7 +152,7 @@ func (r *playlistRepository) buildSmartPlaylistQuery(pls *model.Playlist, rulesS sq = r.addMediaFileAnnotationJoin(sq, userID) requiredJoins := rulesSQL.RequiredJoins() - sq = r.addSmartPlaylistAnnotationJoins(sq, requiredJoins, userID) + sq = r.addSmartPlaylistJoins(sq, requiredJoins, userID) sq = r.applyLibraryFilter(sq, "media_file") return sq } @@ -166,9 +166,8 @@ func (r *playlistRepository) addMediaFileAnnotationJoin(sq SelectBuilder, userID " AND annotation.user_id = ?)", userID) } -// addSmartPlaylistAnnotationJoins adds left joins to the annotation table for albums and artists as needed based on -// the smart playlist criteria, filtering by user ID to include user-specific annotations in the evaluation. -func (r *playlistRepository) addSmartPlaylistAnnotationJoins(sq SelectBuilder, joins smartPlaylistJoinType, userID string) SelectBuilder { +// addSmartPlaylistJoins adds the left joins required by the criteria's fields. +func (r *playlistRepository) addSmartPlaylistJoins(sq SelectBuilder, joins smartPlaylistJoinType, userID string) SelectBuilder { if joins.has(smartPlaylistJoinAlbumAnnotation) { sq = sq.LeftJoin("annotation AS album_annotation ON ("+ "album_annotation.item_id = media_file.album_id"+ @@ -181,6 +180,9 @@ func (r *playlistRepository) addSmartPlaylistAnnotationJoins(sq SelectBuilder, j " AND artist_annotation.item_type = 'artist'"+ " AND artist_annotation.user_id = ?)", userID) } + if joins.has(smartPlaylistJoinAlbum) { + sq = sq.LeftJoin("album ON album.id = media_file.album_id") + } return sq } diff --git a/persistence/smart_playlist_repository_test.go b/persistence/smart_playlist_repository_test.go index 8e3ae488f..e62436890 100644 --- a/persistence/smart_playlist_repository_test.go +++ b/persistence/smart_playlist_repository_test.go @@ -9,6 +9,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/utils/slice" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/pocketbase/dbx" @@ -392,6 +393,40 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { }) }) + Describe("Smart Playlists with Album Aggregate Criteria", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second + }) + + trackIDsOf := func(rules *criteria.Criteria) []string { + newPls := model.Playlist{Name: "Album Aggregates", OwnerID: "userid", Rules: rules} + Expect(repo.Put(&newPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(newPls.ID) }) + + pls, err := repo.GetWithTracks(newPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + return slice.Map(pls.Tracks, func(t model.PlaylistTrack) string { return t.MediaFileID }) + } + + It("filters on albumSongCount", func() { + // albumMultiDisc (ID "104") is the only fixture album with SongCount > 3 + rules := &criteria.Criteria{Expression: criteria.All{criteria.Gt{"albumSongCount": 3}}} + + Expect(trackIDsOf(rules)).To(ConsistOf("2001", "2002", "2003", "2004")) + }) + + It("sorts by an album field not referenced in the expression (issue #5347)", func() { + // All four tracks share album 104, so the album date ties and disc/track number decide. + rules := &criteria.Criteria{ + Expression: criteria.All{criteria.Is{"album": "Multi Disc Album"}}, + Sort: "-albumDateAdded,discNumber,trackNumber", + } + + Expect(trackIDsOf(rules)).To(HaveExactElements("2002", "2004", "2003", "2001")) + }) + }) + Describe("Smart Playlists with Tag Criteria", func() { var mfRepo model.MediaFileRepository var testPlaylistID string From 0f4c9b82121f870e5c925657458a9b87912f59ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Fri, 7 Aug 2026 09:36:52 -0400 Subject: [PATCH 53/71] fix(ui): don't start playback when closing the disc cover lightbox (#5901) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Lightbox renders through a React portal (react-modal), and React propagates synthetic events up the React tree rather than the DOM tree, so clicking its close button or backdrop bubbled into the ancestor TableRow and triggered playSubset. The existing timestamp guard could not catch this: react-image-lightbox defers onCloseRequest by animationDuration via setTimeout, so lightboxClosedAt was only stamped 200ms after the click had already propagated. The guard is kept because it still covers the separate mobile ghost-click path, where the synthesized click lands on the row after the overlay unmounts. Stopping propagation in onCloseRequest — the approach used by ContextMenus, whose MUI onClose fires synchronously — does not work for the same reason. Instead, wrap the Lightbox in an element that stops propagation at the React tree boundary the portal bubbles through, which also covers the lightbox controls we don't own (zoom buttons, caption, image drag). --- ui/src/common/SongDatagrid.jsx | 20 ++++---- ui/src/common/SongDatagrid.test.jsx | 74 +++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 8 deletions(-) create mode 100644 ui/src/common/SongDatagrid.test.jsx diff --git a/ui/src/common/SongDatagrid.jsx b/ui/src/common/SongDatagrid.jsx index d2c98bbe7..4a813f84d 100644 --- a/ui/src/common/SongDatagrid.jsx +++ b/ui/src/common/SongDatagrid.jsx @@ -78,7 +78,7 @@ const useStyles = makeStyles({ }, }) -const DiscSubtitleRow = forwardRef( +export const DiscSubtitleRow = forwardRef( ({ record, onClick, colSpan, contextAlwaysVisible }, ref) => { const translate = useTranslate() const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md')) @@ -153,13 +153,17 @@ const DiscSubtitleRow = forwardRef( {subtitle} {isLightboxOpen && !imageError && ( - + // Lightbox portals out of the row, but React still bubbles its + // events up this tree, where the row's onClick would play the disc. + e.stopPropagation()}> + + )} diff --git a/ui/src/common/SongDatagrid.test.jsx b/ui/src/common/SongDatagrid.test.jsx new file mode 100644 index 000000000..f8459d8eb --- /dev/null +++ b/ui/src/common/SongDatagrid.test.jsx @@ -0,0 +1,74 @@ +import React from 'react' +import { render, fireEvent, screen } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createTheme, ThemeProvider } from '@material-ui/core/styles' +import { DiscSubtitleRow } from './SongDatagrid' + +vi.mock('../subsonic', () => ({ + default: { getDiscCoverArtUrl: () => 'http://localhost/cover.jpg' }, +})) + +vi.mock('react-redux', () => ({ useDispatch: () => vi.fn() })) + +vi.mock('../common', () => ({ AlbumContextMenu: () => null })) + +vi.mock('react-dnd', () => ({ useDrag: () => [{}, vi.fn()] })) + +const record = { + id: 'song-1', + albumId: 'album-1', + album: 'The Album', + discNumber: 2, + discSubtitle: 'Bonus Disc', + updatedAt: '2024-01-01', +} + +const renderRow = (onClick) => + render( + + + + + +
+
, + ) + +const openLightbox = () => { + fireEvent.click(document.querySelector('img')) + expect(document.querySelector('.ril__closeButton')).toBeTruthy() +} + +describe('DiscSubtitleRow', () => { + beforeEach(() => vi.clearAllMocks()) + + it('plays the disc when the row is clicked', () => { + const onClick = vi.fn() + renderRow(onClick) + fireEvent.click(screen.getByText('Bonus Disc')) + expect(onClick).toHaveBeenCalledWith(2) + }) + + it('does not play the disc when opening the lightbox', () => { + const onClick = vi.fn() + renderRow(onClick) + openLightbox() + expect(onClick).not.toHaveBeenCalled() + }) + + it('does not play the disc when closing the lightbox', () => { + const onClick = vi.fn() + renderRow(onClick) + openLightbox() + fireEvent.click(document.querySelector('.ril__closeButton')) + expect(onClick).not.toHaveBeenCalled() + }) + + it('does not play the disc when clicking the lightbox backdrop', () => { + const onClick = vi.fn() + renderRow(onClick) + openLightbox() + fireEvent.click(document.querySelector('.ril__inner')) + expect(onClick).not.toHaveBeenCalled() + }) +}) From a0bf78cdeab4d0f316edcd37094f03d09f0ede8e Mon Sep 17 00:00:00 2001 From: ant Date: Sat, 8 Aug 2026 12:40:05 -0700 Subject: [PATCH 54/71] fix(contrib): added missing hyphen in OpenRC script that caused crashes on startup (#5906) Signed-off-by: Ant --- contrib/navidrome | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contrib/navidrome b/contrib/navidrome index bb6908797..8ab5afe2e 100644 --- a/contrib/navidrome +++ b/contrib/navidrome @@ -2,7 +2,7 @@ name=$RC_SVCNAME command="/opt/navidrome/${RC_SVCNAME}" -command_args="-datafolder /opt/navidrome" +command_args="--datafolder /opt/navidrome" command_user="${RC_SVCNAME}" pidfile="/var/run/${RC_SVCNAME}.pid" output_log="/opt/navidrome/${RC_SVCNAME}.log" From 5a5311a9c0ac0147fb49ed990f4cbb4f6c7ac0a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 8 Aug 2026 21:57:44 -0400 Subject: [PATCH 55/71] fix(plugins): make generated mock stubs nil-safe for nilable returns (#5909) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ndpgen mock accessors asserted return types unconditionally, so a plugin test using Return(nil, ...) — the natural way to model an empty result or a pagination terminal page — panicked on the untyped-nil type assertion instead of returning the zero value. Guard pointer, slice, map and any returns in both the host-service client stubs and the PDK stub, and regenerate. --- plugins/cmd/ndpgen/internal/generator.go | 62 ++++++++---- plugins/cmd/ndpgen/internal/generator_test.go | 96 ++++++++++++++++++- .../internal/templates/client_stub.go.tmpl | 2 +- .../internal/templates/pdk_stub.go.tmpl | 2 +- plugins/pdk/go/host/mock_nil_returns_test.go | 27 ++++++ plugins/pdk/go/host/nd_host_cache_stub.go | 6 +- plugins/pdk/go/host/nd_host_config_stub.go | 6 +- plugins/pdk/go/host/nd_host_http_stub.go | 6 +- plugins/pdk/go/host/nd_host_kvstore_stub.go | 18 +++- plugins/pdk/go/host/nd_host_library_stub.go | 12 ++- plugins/pdk/go/host/nd_host_matcher_stub.go | 6 +- .../pdk/go/host/nd_host_subsonicapi_stub.go | 6 +- plugins/pdk/go/host/nd_host_task_stub.go | 6 +- plugins/pdk/go/host/nd_host_users_stub.go | 12 ++- plugins/pdk/go/pdk/pdk_stub.go | 24 ++++- 15 files changed, 250 insertions(+), 41 deletions(-) create mode 100644 plugins/pdk/go/host/mock_nil_returns_test.go diff --git a/plugins/cmd/ndpgen/internal/generator.go b/plugins/cmd/ndpgen/internal/generator.go index 50f53cd37..bfc1b7c4e 100644 --- a/plugins/cmd/ndpgen/internal/generator.go +++ b/plugins/cmd/ndpgen/internal/generator.go @@ -27,32 +27,46 @@ func hostFuncMap(svc Service) template.FuncMap { // Uses private (lowercase) type names for request/response structs. func clientFuncMap(svc Service) template.FuncMap { return template.FuncMap{ - "lower": strings.ToLower, - "title": strings.Title, - "exportName": func(m Method) string { return m.FunctionName(svc.ExportPrefix()) }, - "requestType": func(m Method) string { return m.ClientRequestTypeName(svc.Name) }, - "responseType": func(m Method) string { return m.ClientResponseTypeName(svc.Name) }, - "formatDoc": formatDoc, - "mockReturnValues": mockReturnValues, + "lower": strings.ToLower, + "title": strings.Title, + "exportName": func(m Method) string { return m.FunctionName(svc.ExportPrefix()) }, + "requestType": func(m Method) string { return m.ClientRequestTypeName(svc.Name) }, + "responseType": func(m Method) string { return m.ClientResponseTypeName(svc.Name) }, + "formatDoc": formatDoc, + "mockReturnBody": mockReturnBody, } } -// mockReturnValues generates the testify mock return value accessors for a method. -// For example: args.String(0), args.Bool(1), args.Error(2) -func mockReturnValues(m Method) string { +// mockReturnBody generates the testify mock body lines that extract return values. +// Nil-able returns are guarded so tests can use Return(nil, ...) without the +// type-assertion panic testify produces on an untyped nil. +func mockReturnBody(m Method) string { + var b strings.Builder var parts []string - idx := 0 - for _, r := range m.Returns { - parts = append(parts, mockAccessor(r.Type, idx)) - idx++ + for idx, r := range m.Returns { + if isNilableType(r.Type) { + name := fmt.Sprintf("r%d", idx) + fmt.Fprintf(&b, "\tvar %s %s\n\tif v := args.Get(%d); v != nil {\n\t\t%s = v.(%s)\n\t}\n", name, r.Type, idx, name, r.Type) + parts = append(parts, name) + } else { + parts = append(parts, mockAccessor(r.Type, idx)) + } } if m.HasError { - parts = append(parts, fmt.Sprintf("args.Error(%d)", idx)) + parts = append(parts, fmt.Sprintf("args.Error(%d)", len(m.Returns))) } - return strings.Join(parts, ", ") + b.WriteString("\treturn " + strings.Join(parts, ", ")) + return b.String() +} + +// isNilableType reports whether a nil mock return value is a valid intent for the +// type, rather than a malformed test expectation. +func isNilableType(typ string) bool { + return strings.HasPrefix(typ, "*") || strings.HasPrefix(typ, "[]") || + strings.HasPrefix(typ, "map[") || typ == "any" || typ == "interface{}" } // mockAccessor returns the testify mock accessor call for a given type and index. @@ -725,7 +739,7 @@ func pdkFuncMap() template.FuncMap { "returnList": pdkReturnList, "argList": pdkArgList, "argListWithReceiver": pdkArgListWithReceiver, - "mockReturns": pdkMockReturns, + "mockReturnBody": pdkMockReturnBody, "constValue": pdkConstValue, "stubTypeUnderlying": stubTypeUnderlying, "methodReceiver": pdkMethodReceiver, @@ -834,12 +848,20 @@ func pdkMethodReceiver(receiver, typeName string) string { } // pdkMockReturns generates the mock return accessors for a function. -func pdkMockReturns(returns []PDKReturn) string { +func pdkMockReturnBody(returns []PDKReturn) string { + var b strings.Builder var parts []string for i, r := range returns { - parts = append(parts, mockAccessorForType(r.Type, i)) + if isNilableType(r.Type) { + name := fmt.Sprintf("r%d", i) + fmt.Fprintf(&b, "\tvar %s %s\n\tif v := args.Get(%d); v != nil {\n\t\t%s = v.(%s)\n\t}\n", name, r.Type, i, name, r.Type) + parts = append(parts, name) + } else { + parts = append(parts, mockAccessorForType(r.Type, i)) + } } - return strings.Join(parts, ", ") + b.WriteString("\treturn " + strings.Join(parts, ", ")) + return b.String() } // mockAccessorForType returns the testify mock accessor for a type. diff --git a/plugins/cmd/ndpgen/internal/generator_test.go b/plugins/cmd/ndpgen/internal/generator_test.go index 15b97e1ae..ea94c7fc6 100644 --- a/plugins/cmd/ndpgen/internal/generator_test.go +++ b/plugins/cmd/ndpgen/internal/generator_test.go @@ -748,8 +748,9 @@ var _ = Describe("Generator", func() { // Check bool return uses args.Bool(1) Expect(codeStr).To(ContainSubstring("args.Bool(1)")) - // Check []byte return uses args.Get(0).([]byte) - Expect(codeStr).To(ContainSubstring("args.Get(0).([]byte)")) + // []byte is nil-able, so it gets a guarded extraction + Expect(codeStr).To(ContainSubstring("var r0 []byte")) + Expect(codeStr).To(ContainSubstring("r0 = v.([]byte)")) // Check error returns use args.Error(N) Expect(codeStr).To(ContainSubstring("args.Error(")) @@ -1699,6 +1700,97 @@ var _ = Describe("Rust Generation", func() { Expect(codeStr).NotTo(ContainSubstring("use base64")) }) }) + + Describe("nil-safe mock accessors", func() { + Describe("GenerateClientGoStub", func() { + It("guards pointer, slice and map returns so Return(nil, ...) does not panic", func() { + svc := Service{ + Name: "Paged", + Interface: "PagedService", + Methods: []Method{ + { + Name: "GetPage", + HasError: true, + Params: []Param{NewParam("query", "string")}, + Returns: []Param{NewParam("items", "[]Item"), NewParam("next", "*PageOptions")}, + }, + { + Name: "GetLabels", + HasError: true, + Params: []Param{NewParam("id", "string")}, + Returns: []Param{NewParam("labels", "map[string]string")}, + }, + }, + } + + code, err := GenerateClientGoStub(svc, "host") + Expect(err).NotTo(HaveOccurred()) + _, err = format.Source(code) + Expect(err).NotTo(HaveOccurred()) + + codeStr := string(code) + Expect(codeStr).To(ContainSubstring("var r0 []Item")) + Expect(codeStr).To(ContainSubstring("var r1 *PageOptions")) + Expect(codeStr).To(ContainSubstring("if v := args.Get(1); v != nil {")) + Expect(codeStr).To(ContainSubstring("r1 = v.(*PageOptions)")) + Expect(codeStr).To(ContainSubstring("return r0, r1, args.Error(2)")) + Expect(codeStr).To(ContainSubstring("var r0 map[string]string")) + Expect(codeStr).NotTo(ContainSubstring("args.Get(0).([]Item)")) + Expect(codeStr).NotTo(ContainSubstring("args.Get(1).(*PageOptions)")) + }) + + It("keeps non-nilable returns as inline accessors", func() { + svc := Service{ + Name: "Counter", + Interface: "CounterService", + Methods: []Method{ + { + Name: "Count", + HasError: true, + Params: []Param{NewParam("id", "string")}, + Returns: []Param{NewParam("count", "int64"), NewParam("name", "string")}, + }, + }, + } + + code, err := GenerateClientGoStub(svc, "host") + Expect(err).NotTo(HaveOccurred()) + + codeStr := string(code) + Expect(codeStr).To(ContainSubstring("return args.Get(0).(int64), args.String(1), args.Error(2)")) + Expect(codeStr).NotTo(ContainSubstring("var r0")) + }) + }) + + Describe("GeneratePDKGoStub", func() { + It("guards pointer and slice returns", func() { + symbols := &PDKSymbols{ + Functions: []PDKFunc{ + { + Name: "NewHTTPRequest", + Params: []PDKParam{{Name: "method", Type: "HTTPMethod"}, {Name: "url", Type: "string"}}, + Returns: []PDKReturn{{Type: "*HTTPRequest"}}, + }, + { + Name: "Input", + Returns: []PDKReturn{{Type: "[]byte"}}, + }, + }, + } + + code, err := GeneratePDKGoStub(symbols) + Expect(err).NotTo(HaveOccurred()) + _, err = format.Source(code) + Expect(err).NotTo(HaveOccurred()) + + codeStr := string(code) + Expect(codeStr).To(ContainSubstring("var r0 *HTTPRequest")) + Expect(codeStr).To(ContainSubstring("r0 = v.(*HTTPRequest)")) + Expect(codeStr).To(ContainSubstring("var r0 []byte")) + Expect(codeStr).NotTo(ContainSubstring("return args.Get(0).(*HTTPRequest)")) + }) + }) + }) }) func writeFile(path, content string) error { diff --git a/plugins/cmd/ndpgen/internal/templates/client_stub.go.tmpl b/plugins/cmd/ndpgen/internal/templates/client_stub.go.tmpl index d2a8f3f27..eb5e9f844 100644 --- a/plugins/cmd/ndpgen/internal/templates/client_stub.go.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/client_stub.go.tmpl @@ -51,7 +51,7 @@ var {{.Service.Name}}Mock = &mock{{.Service.Name}}Service{} // {{.Name}} is the mock method for {{$.Service.Name}}{{.Name}}. func (m *mock{{$.Service.Name}}Service) {{.Name}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.Name}} {{$p.Type}}{{end}}) {{.ReturnSignature}} { args := m.Called({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.Name}}{{end}}) - return {{mockReturnValues .}} +{{mockReturnBody .}} } // {{$.Service.Name}}{{.Name}} delegates to the mock instance. diff --git a/plugins/cmd/ndpgen/internal/templates/pdk_stub.go.tmpl b/plugins/cmd/ndpgen/internal/templates/pdk_stub.go.tmpl index 6a57a6469..2bd73f107 100644 --- a/plugins/cmd/ndpgen/internal/templates/pdk_stub.go.tmpl +++ b/plugins/cmd/ndpgen/internal/templates/pdk_stub.go.tmpl @@ -34,7 +34,7 @@ func ResetMock() { func {{.Name}}({{paramList .Params}}){{returnList .Returns}} { {{- if .Returns}} args := PDKMock.Called({{argList .Params}}) - return {{mockReturns .Returns}} +{{mockReturnBody .Returns}} {{- else}} PDKMock.Called({{argList .Params}}) {{- end}} diff --git a/plugins/pdk/go/host/mock_nil_returns_test.go b/plugins/pdk/go/host/mock_nil_returns_test.go new file mode 100644 index 000000000..ea5f334f1 --- /dev/null +++ b/plugins/pdk/go/host/mock_nil_returns_test.go @@ -0,0 +1,27 @@ +//go:build !wasip1 + +package host + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// Return(nil, ...) models "no result"; the generated accessors must yield the +// zero value instead of panicking on the type assertion. +func TestMockNilPointerReturn(t *testing.T) { + HTTPMock.On("Send", HTTPRequest{URL: "http://nil.example"}).Return(nil, nil) + + resp, err := HTTPSend(HTTPRequest{URL: "http://nil.example"}) + require.NoError(t, err) + require.Nil(t, resp) +} + +func TestMockNilSliceReturn(t *testing.T) { + LibraryMock.On("GetAllLibraries").Return(nil, nil) + + libs, err := LibraryGetAllLibraries() + require.NoError(t, err) + require.Nil(t, libs) +} diff --git a/plugins/pdk/go/host/nd_host_cache_stub.go b/plugins/pdk/go/host/nd_host_cache_stub.go index 46bb44bcf..faf4e65d2 100644 --- a/plugins/pdk/go/host/nd_host_cache_stub.go +++ b/plugins/pdk/go/host/nd_host_cache_stub.go @@ -154,7 +154,11 @@ func CacheSetBytes(key string, value []byte, ttlSeconds int64) error { // GetBytes is the mock method for CacheGetBytes. func (m *mockCacheService) GetBytes(key string) ([]byte, bool, error) { args := m.Called(key) - return args.Get(0).([]byte), args.Bool(1), args.Error(2) + var r0 []byte + if v := args.Get(0); v != nil { + r0 = v.([]byte) + } + return r0, args.Bool(1), args.Error(2) } // CacheGetBytes delegates to the mock instance. diff --git a/plugins/pdk/go/host/nd_host_config_stub.go b/plugins/pdk/go/host/nd_host_config_stub.go index 463c29b76..0327346a3 100644 --- a/plugins/pdk/go/host/nd_host_config_stub.go +++ b/plugins/pdk/go/host/nd_host_config_stub.go @@ -59,7 +59,11 @@ func ConfigGetInt(key string) (int64, bool) { // Keys is the mock method for ConfigKeys. func (m *mockConfigService) Keys(prefix string) []string { args := m.Called(prefix) - return args.Get(0).([]string) + var r0 []string + if v := args.Get(0); v != nil { + r0 = v.([]string) + } + return r0 } // ConfigKeys delegates to the mock instance. diff --git a/plugins/pdk/go/host/nd_host_http_stub.go b/plugins/pdk/go/host/nd_host_http_stub.go index 09c4d0fcf..8706ac9e5 100644 --- a/plugins/pdk/go/host/nd_host_http_stub.go +++ b/plugins/pdk/go/host/nd_host_http_stub.go @@ -43,7 +43,11 @@ var HTTPMock = &mockHTTPService{} // Send is the mock method for HTTPSend. func (m *mockHTTPService) Send(request HTTPRequest) (*HTTPResponse, error) { args := m.Called(request) - return args.Get(0).(*HTTPResponse), args.Error(1) + var r0 *HTTPResponse + if v := args.Get(0); v != nil { + r0 = v.(*HTTPResponse) + } + return r0, args.Error(1) } // HTTPSend delegates to the mock instance. diff --git a/plugins/pdk/go/host/nd_host_kvstore_stub.go b/plugins/pdk/go/host/nd_host_kvstore_stub.go index fce038aa1..e5243860a 100644 --- a/plugins/pdk/go/host/nd_host_kvstore_stub.go +++ b/plugins/pdk/go/host/nd_host_kvstore_stub.go @@ -64,7 +64,11 @@ func KVStoreSetWithTTL(key string, value []byte, ttlSeconds int64) error { // Get is the mock method for KVStoreGet. func (m *mockKVStoreService) Get(key string) ([]byte, bool, error) { args := m.Called(key) - return args.Get(0).([]byte), args.Bool(1), args.Error(2) + var r0 []byte + if v := args.Get(0); v != nil { + r0 = v.([]byte) + } + return r0, args.Bool(1), args.Error(2) } // KVStoreGet delegates to the mock instance. @@ -81,7 +85,11 @@ func KVStoreGet(key string) ([]byte, bool, error) { // GetMany is the mock method for KVStoreGetMany. func (m *mockKVStoreService) GetMany(keys []string) (map[string][]byte, error) { args := m.Called(keys) - return args.Get(0).(map[string][]byte), args.Error(1) + var r0 map[string][]byte + if v := args.Get(0); v != nil { + r0 = v.(map[string][]byte) + } + return r0, args.Error(1) } // KVStoreGetMany delegates to the mock instance. @@ -116,7 +124,11 @@ func KVStoreHas(key string) (bool, error) { // List is the mock method for KVStoreList. func (m *mockKVStoreService) List(prefix string) ([]string, error) { args := m.Called(prefix) - return args.Get(0).([]string), args.Error(1) + var r0 []string + if v := args.Get(0); v != nil { + r0 = v.([]string) + } + return r0, args.Error(1) } // KVStoreList delegates to the mock instance. diff --git a/plugins/pdk/go/host/nd_host_library_stub.go b/plugins/pdk/go/host/nd_host_library_stub.go index 1e4c230c3..0232c49d9 100644 --- a/plugins/pdk/go/host/nd_host_library_stub.go +++ b/plugins/pdk/go/host/nd_host_library_stub.go @@ -39,7 +39,11 @@ var LibraryMock = &mockLibraryService{} // GetLibrary is the mock method for LibraryGetLibrary. func (m *mockLibraryService) GetLibrary(id int32) (*Library, error) { args := m.Called(id) - return args.Get(0).(*Library), args.Error(1) + var r0 *Library + if v := args.Get(0); v != nil { + r0 = v.(*Library) + } + return r0, args.Error(1) } // LibraryGetLibrary delegates to the mock instance. @@ -56,7 +60,11 @@ func LibraryGetLibrary(id int32) (*Library, error) { // GetAllLibraries is the mock method for LibraryGetAllLibraries. func (m *mockLibraryService) GetAllLibraries() ([]Library, error) { args := m.Called() - return args.Get(0).([]Library), args.Error(1) + var r0 []Library + if v := args.Get(0); v != nil { + r0 = v.([]Library) + } + return r0, args.Error(1) } // LibraryGetAllLibraries delegates to the mock instance. diff --git a/plugins/pdk/go/host/nd_host_matcher_stub.go b/plugins/pdk/go/host/nd_host_matcher_stub.go index 07b34bb82..ade368c06 100644 --- a/plugins/pdk/go/host/nd_host_matcher_stub.go +++ b/plugins/pdk/go/host/nd_host_matcher_stub.go @@ -31,7 +31,11 @@ var MatcherMock = &mockMatcherService{} // MatchSongs is the mock method for MatcherMatchSongs. func (m *mockMatcherService) MatchSongs(songs []types.SongRef, opts MatchOptions) ([]*types.Track, error) { args := m.Called(songs, opts) - return args.Get(0).([]*types.Track), args.Error(1) + var r0 []*types.Track + if v := args.Get(0); v != nil { + r0 = v.([]*types.Track) + } + return r0, args.Error(1) } // MatcherMatchSongs delegates to the mock instance. diff --git a/plugins/pdk/go/host/nd_host_subsonicapi_stub.go b/plugins/pdk/go/host/nd_host_subsonicapi_stub.go index 6d3a56b35..ef3d1b735 100644 --- a/plugins/pdk/go/host/nd_host_subsonicapi_stub.go +++ b/plugins/pdk/go/host/nd_host_subsonicapi_stub.go @@ -39,7 +39,11 @@ func SubsonicAPICall(uri string) (string, error) { // CallRaw is the mock method for SubsonicAPICallRaw. func (m *mockSubsonicAPIService) CallRaw(uri string) (string, []byte, error) { args := m.Called(uri) - return args.String(0), args.Get(1).([]byte), args.Error(2) + var r1 []byte + if v := args.Get(1); v != nil { + r1 = v.([]byte) + } + return args.String(0), r1, args.Error(2) } // SubsonicAPICallRaw delegates to the mock instance. diff --git a/plugins/pdk/go/host/nd_host_task_stub.go b/plugins/pdk/go/host/nd_host_task_stub.go index 286d14035..f2c45a2b5 100644 --- a/plugins/pdk/go/host/nd_host_task_stub.go +++ b/plugins/pdk/go/host/nd_host_task_stub.go @@ -70,7 +70,11 @@ func TaskEnqueue(queueName string, payload []byte) (string, error) { // Get is the mock method for TaskGet. func (m *mockTaskService) Get(taskID string) (*TaskInfo, error) { args := m.Called(taskID) - return args.Get(0).(*TaskInfo), args.Error(1) + var r0 *TaskInfo + if v := args.Get(0); v != nil { + r0 = v.(*TaskInfo) + } + return r0, args.Error(1) } // TaskGet delegates to the mock instance. diff --git a/plugins/pdk/go/host/nd_host_users_stub.go b/plugins/pdk/go/host/nd_host_users_stub.go index 8858b2109..e0f8c0939 100644 --- a/plugins/pdk/go/host/nd_host_users_stub.go +++ b/plugins/pdk/go/host/nd_host_users_stub.go @@ -33,7 +33,11 @@ var UsersMock = &mockUsersService{} // GetUsers is the mock method for UsersGetUsers. func (m *mockUsersService) GetUsers() ([]User, error) { args := m.Called() - return args.Get(0).([]User), args.Error(1) + var r0 []User + if v := args.Get(0); v != nil { + r0 = v.([]User) + } + return r0, args.Error(1) } // UsersGetUsers delegates to the mock instance. @@ -49,7 +53,11 @@ func UsersGetUsers() ([]User, error) { // GetAdmins is the mock method for UsersGetAdmins. func (m *mockUsersService) GetAdmins() ([]User, error) { args := m.Called() - return args.Get(0).([]User), args.Error(1) + var r0 []User + if v := args.Get(0); v != nil { + r0 = v.([]User) + } + return r0, args.Error(1) } // UsersGetAdmins delegates to the mock instance. diff --git a/plugins/pdk/go/pdk/pdk_stub.go b/plugins/pdk/go/pdk/pdk_stub.go index 3bdbb1cb7..8f2707d56 100644 --- a/plugins/pdk/go/pdk/pdk_stub.go +++ b/plugins/pdk/go/pdk/pdk_stub.go @@ -62,7 +62,11 @@ func GetConfig(key string) (string, bool) { // GetVar GetVar returns the byte slice (if any) associated with `key`. func GetVar(key string) []byte { args := PDKMock.Called(key) - return args.Get(0).([]byte) + var r0 []byte + if v := args.Get(0); v != nil { + r0 = v.([]byte) + } + return r0 } // GetVarInt GetVarInt returns the int associated with `key` (or 0 if none). @@ -74,7 +78,11 @@ func GetVarInt(key string) int { // Input Input returns a slice of bytes from the host. func Input() []byte { args := PDKMock.Called() - return args.Get(0).([]byte) + var r0 []byte + if v := args.Get(0); v != nil { + r0 = v.([]byte) + } + return r0 } // InputJSON InputJSON returns unmartialed JSON data from the host "input". @@ -108,7 +116,11 @@ func LogMemory(level LogLevel, m Memory) { // NewHTTPRequest NewHTTPRequest returns a new `HTTPRequest`. func NewHTTPRequest(method HTTPMethod, url string) *HTTPRequest { args := PDKMock.Called(method, url) - return args.Get(0).(*HTTPRequest) + var r0 *HTTPRequest + if v := args.Get(0); v != nil { + r0 = v.(*HTTPRequest) + } + return r0 } func NewMemory(offset uint64, length uint64) Memory { args := PDKMock.Called(offset, length) @@ -139,7 +151,11 @@ func OutputString(s string) { // ParamBytes ParamBytes returns bytes from Extism host memory given an offset. func ParamBytes(offset uint64) []byte { args := PDKMock.Called(offset) - return args.Get(0).([]byte) + var r0 []byte + if v := args.Get(0); v != nil { + r0 = v.([]byte) + } + return r0 } // ParamString ParamString returns UTF-8 string data from Extism host memory given an offset. From b0c6d2e444607e1bb71cafee9361b2dfe4139927 Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Sat, 8 Aug 2026 19:13:29 -0700 Subject: [PATCH 56/71] feat(plugins): add scrobbles access to PDK (#5795) 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 * initial api, some testing * add tests, add count retrieval * add docs, test for rejected user * add permission validation for scrobble retriever * chore(plugins): fix typos in scrobble retriever Rename newScrobbleRetreverService, and fix FromTImestamp/nonero in the ScrobbleRetriever doc comments, which generate into the Go and Rust PDKs. Also corrects two mislabelled test entries. * fix(plugins): make scrobble pagination order deterministic Sorting only by submission_time left the order of equal timestamps up to the query planner, but the cursor skips ties by offset, so an unstable order can repeat or drop scrobbles between pages. Break ties on scrobbles.id, which the existing scrobbles_user_time index already yields for free. Descending is now honoured for every combination of From/To rather than only when both or neither is set. This changes the default for a lone ToTimestamp from newest-first to oldest-first. * refactor(plugins): return the next page's options from GetScrobbles Paging previously meant reading NextTimestamp and Cursor off the response and deciding where each belonged: NextTimestamp into FromTimestamp when ascending or ToTimestamp when descending, and Cursor copied every time, including when 0. Both are silent data-loss bugs when a plugin gets them wrong. GetScrobbles now returns the options for the following page, or nil when the range is exhausted, so a plugin passes the value straight back and repeats. ScrobbleCursor and ScrobbleList are gone; the query itself is unchanged. * docs(plugins): warn against setting ScrobbleOptions.Offset manually The all-ties carry rule assumes Offset counts already-returned rows at the boundary timestamp, which only holds for the options GetScrobbles returns. A hand-built From+Offset combination can silently skip scrobbles, so document the field as managed pagination state instead of a generic skip. * docs(plugins): document the ScrobbleRetriever host service in the README Covers the manifest permissions (including the users requirement), the four host functions, the options/ref field tables, and the pagination loop with its two gotchas: the host-managed offset and the adjusted range on the returned next options. * chore(plugins): regenerate scrobble retriever stub with nil-safe accessors --------- Co-authored-by: Deluan Quintão --- plugins/README.md | 77 ++++ plugins/host/scrobble_retriever.go | 89 ++++ plugins/host/scrobbleretriever_gen.go | 230 +++++++++++ plugins/host_scrobbleretriever.go | 161 ++++++++ plugins/host_scrobbleretriever_test.go | 390 ++++++++++++++++++ plugins/manager_loader.go | 8 + plugins/manifest-schema.json | 14 + plugins/manifest.go | 8 +- plugins/manifest_gen.go | 9 + plugins/manifest_test.go | 30 ++ plugins/pdk/go/host/doc.go | 1 + .../pdk/go/host/nd_host_scrobbleretriever.go | 266 ++++++++++++ .../go/host/nd_host_scrobbleretriever_stub.go | 136 ++++++ plugins/pdk/rust/nd-pdk-host/src/lib.rs | 8 + .../src/nd_host_scrobbleretriever.rs | 237 +++++++++++ .../testdata/test-scrobble-retriever/go.mod | 16 + .../testdata/test-scrobble-retriever/go.sum | 14 + .../testdata/test-scrobble-retriever/main.go | 120 ++++++ .../test-scrobble-retriever/manifest.json | 14 + 19 files changed, 1826 insertions(+), 2 deletions(-) create mode 100644 plugins/host/scrobble_retriever.go create mode 100644 plugins/host/scrobbleretriever_gen.go create mode 100644 plugins/host_scrobbleretriever.go create mode 100644 plugins/host_scrobbleretriever_test.go create mode 100644 plugins/pdk/go/host/nd_host_scrobbleretriever.go create mode 100644 plugins/pdk/go/host/nd_host_scrobbleretriever_stub.go create mode 100644 plugins/pdk/rust/nd-pdk-host/src/nd_host_scrobbleretriever.rs create mode 100644 plugins/testdata/test-scrobble-retriever/go.mod create mode 100644 plugins/testdata/test-scrobble-retriever/go.sum create mode 100644 plugins/testdata/test-scrobble-retriever/main.go create mode 100644 plugins/testdata/test-scrobble-retriever/manifest.json diff --git a/plugins/README.md b/plugins/README.md index a2f9532ca..adc177324 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -38,6 +38,7 @@ The plugin system is built on **[Extism](https://extism.org/)**, a cross-languag - [SubsonicAPI](#subsonicapi) - [Config](#config) - [Users](#users) + - [ScrobbleRetriever](#scrobbleretriever) - [Configuration](#configuration) - [Building Plugins](#building-plugins) - [Examples](#examples) @@ -896,6 +897,82 @@ for _, user := range users { admins, err := host.UsersGetAdmins() ``` +### ScrobbleRetriever + +Retrieve the scrobble history of users the plugin has been granted access to. Each scrobble carries only the media file ID and the submission time; use the Matcher host service to resolve them to track metadata. + +**Manifest permission:** + +```json +{ + "permissions": { + "scrobbleRetriever": { + "reason": "Sync scrobble history to an external service" + }, + "users": { + "reason": "Access user information for scrobble retrieval" + } + } +} +``` + +> **Important:** The `scrobbleRetriever` permission requires the `users` permission. Which users the plugin can act as is controlled through the Navidrome UI. + +**Host functions:** + +| Function | Parameters | Returns | +|---------------------------------------|-----------------------|----------------------------------| +| `scrobbleretriever_getfirsttimestamp` | `username` | Unix timestamp of oldest scrobble, or null | +| `scrobbleretriever_getlasttimestamp` | `username` | Unix timestamp of newest scrobble, or null | +| `scrobbleretriever_getscrobbles` | `username`, `options` | One page of scrobbles + options for the next page | +| `scrobbleretriever_getscrobblecount` | `username`, `options` | Number of scrobbles in the range | + +**ScrobbleOptions fields** (all optional): + +| Field | Type | Description | +|-----------------|---------|----------------------------------------------------------| +| `fromTimestamp` | int64 | Start of the range (inclusive). Default: first scrobble | +| `toTimestamp` | int64 | End of the range (inclusive). Default: last scrobble | +| `descending` | boolean | Newest first. Default: oldest first | +| `maxItems` | int | Page size, capped at 5000 (the default) | +| `offset` | int | Managed by the host for pagination. Never set it manually | + +**ScrobbleRef fields:** + +| Field | Type | Description | +|------------------|--------|------------------------------------------------| +| `id` | int64 | Scrobble ID, unique even for duplicate submissions | +| `mediaFileId` | string | The media file that was scrobbled | +| `submissionTime` | int64 | Unix timestamp of the submission | + +**Usage:** + +`GetScrobbles` returns one page plus the options to fetch the following page. Pass them back unchanged and repeat until they are nil: + +```go +opts := host.ScrobbleOptions{MaxItems: 500} +var all []host.ScrobbleRef +for { + page, next, err := host.ScrobbleRetrieverGetScrobbles("username", opts) + if err != nil { + return err + } + all = append(all, page...) + if next == nil { + break // no more scrobbles + } + opts = *next +} + +// Range boundaries and counts +first, err := host.ScrobbleRetrieverGetFirstTimestamp("username") // nil if no scrobbles +count, err := host.ScrobbleRetrieverGetScrobbleCount("username", host.ScrobbleCountOptions{ + FromTimestamp: first, +}) +``` + +> **Note:** The returned `next` options carry an adjusted `fromTimestamp`/`toTimestamp`, so keep a copy of your original options if you still need the range. + --- ## Configuration diff --git a/plugins/host/scrobble_retriever.go b/plugins/host/scrobble_retriever.go new file mode 100644 index 000000000..2582dd650 --- /dev/null +++ b/plugins/host/scrobble_retriever.go @@ -0,0 +1,89 @@ +package host + +import "context" + +// ScrobbleRef represents one instance of a scrobble (instance id, file id, submission time) +type ScrobbleRef struct { + // The ID of the scrobble. Useful if duplicate scrobbles happen for the same time + ID int64 `json:"id"` + // The ID of the MediaFile submitted at this time + MediaFileID string `json:"mediaFileId"` + // The UNIX timestamp this scrobble was submitted + SubmissionTime int64 `json:"submissionTime"` +} + +// ScrobbleOptions carries optional parameters for retrieving user scrobbles +type ScrobbleOptions struct { + // The starting unix timestamp to query for scrobbles (inclusive). + // If not specified, start from the first scrobble + FromTimestamp *int64 `json:"fromTimestamp,omitempty"` + // The ending unix timestamp to query for scrobbles (inclusive). + // If not specified, go up to the last scrobble + ToTimestamp *int64 `json:"toTimestamp,omitempty"` + // If true, return scrobbles from newest to oldest. Defaults to oldest first + Descending bool `json:"descending"` + // The maximum number of items to retrieve. This is capped at 5000, the + // default if not specified + MaxItems int `json:"maxItems"` + // Pagination state managed by GetScrobbles; only meaningful on the options it returns. + // Never set it or combine it with your own timestamps — scrobbles may be silently skipped + Offset int `json:"offset,omitempty"` +} + +// ScrobbleCountOptions carries optional parameters for counting user scrobbles +type ScrobbleCountOptions struct { + // The starting unix timestamp to query for scrobbles (inclusive). + // If not specified, start from the first scrobble + FromTimestamp *int64 `json:"fromTimestamp,omitempty"` + // The ending unix timestamp to query for scrobbles (inclusive). + // If not specified, go up to the last scrobble + ToTimestamp *int64 `json:"toTimestamp,omitempty"` +} + +// ScrobbleRetrieverService allows a plugin to retrieve scrobbles for one or more authorized users. +// It will only provide the media_file ID and submission time, which can be combined with the MatcherService +// to fetch deduped tracks +// +//nd:hostservice name=ScrobbleRetriever permission=scrobbleRetriever +type ScrobbleRetrieverService interface { + // GetFirstTimestamp returns the unix timestamp of the oldest scrobble for the user. + // If the user has no scrobbles, returns nil + //nd:hostfunc + GetFirstTimestamp(ctx context.Context, username string) (*int64, error) + + // GetLastTimestamp returns the unix timestamp of the most recent scrobble for the user + // If the user has no scrobbles, return nil + //nd:hostfunc + GetLastTimestamp(ctx context.Context, username string) (*int64, error) + + // GetScrobbles returns one page of scrobbles for a user. + // + // Parameters: + // - username: the user to query for scrobbles + // - options.FromTimestamp: If specified, the first UNIX timestamp to start fetching scrobbles (inclusive). Otherwise, start from the first scrobble + // - options.ToTimestamp: If specified, the last UNIX timestamp to fetch (inclusive). Otherwise, end at the last scrobble + // - options.Descending: If true, order from newest to oldest. Otherwise, oldest to newest + // - options.MaxItems: The maximum number of items to retrieve. The maximum value (and default) if not specified is 5000 + // - options.Offset: Pagination state; only valid as received on the options returned by a previous call. Never set it manually + // + // Returns: + // - scrobbles: The scrobbles in the requested range, ordered by submission time + // (ties broken by scrobble ID) in the direction given by options.Descending + // - next: The options for the following page, or nil once no scrobbles remain. + // Pass it back to GetScrobbles unchanged and repeat until it is nil. It carries an + // adjusted FromTimestamp/ToTimestamp, so keep a copy if you still need the original range + //nd:hostfunc + GetScrobbles(ctx context.Context, username string, options ScrobbleOptions) (scrobbles []ScrobbleRef, next *ScrobbleOptions, err error) + + // GetScrobbleCount returns the number of scrobbles for a user in a given range + // + // Parameters: + // - username: the user to query for scrobbles + // - options.FromTimestamp: If specified, the first UNIX timestamp to start fetching scrobbles (inclusive). Otherwise, start from the first scrobble + // - options.ToTimestamp: If specified, the last UNIX timestamp to fetch (inclusive). Otherwise, end at the last scrobble + // + // Returns: + // - the number of scrobbles in the given range, or 0 + //nd:hostfunc + GetScrobbleCount(ctx context.Context, username string, options ScrobbleCountOptions) (int64, error) +} diff --git a/plugins/host/scrobbleretriever_gen.go b/plugins/host/scrobbleretriever_gen.go new file mode 100644 index 000000000..7a00c9c1f --- /dev/null +++ b/plugins/host/scrobbleretriever_gen.go @@ -0,0 +1,230 @@ +// Code generated by ndpgen. DO NOT EDIT. + +package host + +import ( + "context" + "encoding/json" + + extism "github.com/extism/go-sdk" +) + +// ScrobbleRetrieverGetFirstTimestampRequest is the request type for ScrobbleRetriever.GetFirstTimestamp. +type ScrobbleRetrieverGetFirstTimestampRequest struct { + Username string `json:"username"` +} + +// ScrobbleRetrieverGetFirstTimestampResponse is the response type for ScrobbleRetriever.GetFirstTimestamp. +type ScrobbleRetrieverGetFirstTimestampResponse struct { + Result *int64 `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +// ScrobbleRetrieverGetLastTimestampRequest is the request type for ScrobbleRetriever.GetLastTimestamp. +type ScrobbleRetrieverGetLastTimestampRequest struct { + Username string `json:"username"` +} + +// ScrobbleRetrieverGetLastTimestampResponse is the response type for ScrobbleRetriever.GetLastTimestamp. +type ScrobbleRetrieverGetLastTimestampResponse struct { + Result *int64 `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +// ScrobbleRetrieverGetScrobblesRequest is the request type for ScrobbleRetriever.GetScrobbles. +type ScrobbleRetrieverGetScrobblesRequest struct { + Username string `json:"username"` + Options ScrobbleOptions `json:"options"` +} + +// ScrobbleRetrieverGetScrobblesResponse is the response type for ScrobbleRetriever.GetScrobbles. +type ScrobbleRetrieverGetScrobblesResponse struct { + Scrobbles []ScrobbleRef `json:"scrobbles,omitempty"` + Next *ScrobbleOptions `json:"next,omitempty"` + Error string `json:"error,omitempty"` +} + +// ScrobbleRetrieverGetScrobbleCountRequest is the request type for ScrobbleRetriever.GetScrobbleCount. +type ScrobbleRetrieverGetScrobbleCountRequest struct { + Username string `json:"username"` + Options ScrobbleCountOptions `json:"options"` +} + +// ScrobbleRetrieverGetScrobbleCountResponse is the response type for ScrobbleRetriever.GetScrobbleCount. +type ScrobbleRetrieverGetScrobbleCountResponse struct { + Result int64 `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +// RegisterScrobbleRetrieverHostFunctions registers ScrobbleRetriever service host functions. +// The returned host functions should be added to the plugin's configuration. +func RegisterScrobbleRetrieverHostFunctions(service ScrobbleRetrieverService) []extism.HostFunction { + return []extism.HostFunction{ + newScrobbleRetrieverGetFirstTimestampHostFunction(service), + newScrobbleRetrieverGetLastTimestampHostFunction(service), + newScrobbleRetrieverGetScrobblesHostFunction(service), + newScrobbleRetrieverGetScrobbleCountHostFunction(service), + } +} + +func newScrobbleRetrieverGetFirstTimestampHostFunction(service ScrobbleRetrieverService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "scrobbleretriever_getfirsttimestamp", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + // Read JSON request from plugin memory + reqBytes, err := p.ReadBytes(stack[0]) + if err != nil { + scrobbleretrieverWriteError(p, stack, err) + return + } + var req ScrobbleRetrieverGetFirstTimestampRequest + if err := json.Unmarshal(reqBytes, &req); err != nil { + scrobbleretrieverWriteError(p, stack, err) + return + } + + // Call the service method + result, svcErr := service.GetFirstTimestamp(ctx, req.Username) + if svcErr != nil { + scrobbleretrieverWriteError(p, stack, svcErr) + return + } + + // Write JSON response to plugin memory + resp := ScrobbleRetrieverGetFirstTimestampResponse{ + Result: result, + } + scrobbleretrieverWriteResponse(p, stack, resp) + }, + []extism.ValueType{extism.ValueTypePTR}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + +func newScrobbleRetrieverGetLastTimestampHostFunction(service ScrobbleRetrieverService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "scrobbleretriever_getlasttimestamp", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + // Read JSON request from plugin memory + reqBytes, err := p.ReadBytes(stack[0]) + if err != nil { + scrobbleretrieverWriteError(p, stack, err) + return + } + var req ScrobbleRetrieverGetLastTimestampRequest + if err := json.Unmarshal(reqBytes, &req); err != nil { + scrobbleretrieverWriteError(p, stack, err) + return + } + + // Call the service method + result, svcErr := service.GetLastTimestamp(ctx, req.Username) + if svcErr != nil { + scrobbleretrieverWriteError(p, stack, svcErr) + return + } + + // Write JSON response to plugin memory + resp := ScrobbleRetrieverGetLastTimestampResponse{ + Result: result, + } + scrobbleretrieverWriteResponse(p, stack, resp) + }, + []extism.ValueType{extism.ValueTypePTR}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + +func newScrobbleRetrieverGetScrobblesHostFunction(service ScrobbleRetrieverService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "scrobbleretriever_getscrobbles", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + // Read JSON request from plugin memory + reqBytes, err := p.ReadBytes(stack[0]) + if err != nil { + scrobbleretrieverWriteError(p, stack, err) + return + } + var req ScrobbleRetrieverGetScrobblesRequest + if err := json.Unmarshal(reqBytes, &req); err != nil { + scrobbleretrieverWriteError(p, stack, err) + return + } + + // Call the service method + scrobbles, next, svcErr := service.GetScrobbles(ctx, req.Username, req.Options) + if svcErr != nil { + scrobbleretrieverWriteError(p, stack, svcErr) + return + } + + // Write JSON response to plugin memory + resp := ScrobbleRetrieverGetScrobblesResponse{ + Scrobbles: scrobbles, + Next: next, + } + scrobbleretrieverWriteResponse(p, stack, resp) + }, + []extism.ValueType{extism.ValueTypePTR}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + +func newScrobbleRetrieverGetScrobbleCountHostFunction(service ScrobbleRetrieverService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "scrobbleretriever_getscrobblecount", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + // Read JSON request from plugin memory + reqBytes, err := p.ReadBytes(stack[0]) + if err != nil { + scrobbleretrieverWriteError(p, stack, err) + return + } + var req ScrobbleRetrieverGetScrobbleCountRequest + if err := json.Unmarshal(reqBytes, &req); err != nil { + scrobbleretrieverWriteError(p, stack, err) + return + } + + // Call the service method + result, svcErr := service.GetScrobbleCount(ctx, req.Username, req.Options) + if svcErr != nil { + scrobbleretrieverWriteError(p, stack, svcErr) + return + } + + // Write JSON response to plugin memory + resp := ScrobbleRetrieverGetScrobbleCountResponse{ + Result: result, + } + scrobbleretrieverWriteResponse(p, stack, resp) + }, + []extism.ValueType{extism.ValueTypePTR}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + +// scrobbleretrieverWriteResponse writes a JSON response to plugin memory. +func scrobbleretrieverWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) { + respBytes, err := json.Marshal(resp) + if err != nil { + scrobbleretrieverWriteError(p, stack, err) + return + } + respPtr, err := p.WriteBytes(respBytes) + if err != nil { + stack[0] = 0 + return + } + stack[0] = respPtr +} + +// scrobbleretrieverWriteError writes an error response to plugin memory. +func scrobbleretrieverWriteError(p *extism.CurrentPlugin, stack []uint64, err error) { + errResp := struct { + Error string `json:"error"` + }{Error: err.Error()} + respBytes, _ := json.Marshal(errResp) + respPtr, _ := p.WriteBytes(respBytes) + stack[0] = respPtr +} diff --git a/plugins/host_scrobbleretriever.go b/plugins/host_scrobbleretriever.go new file mode 100644 index 000000000..7417d7c50 --- /dev/null +++ b/plugins/host_scrobbleretriever.go @@ -0,0 +1,161 @@ +package plugins + +import ( + "context" + "fmt" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/plugins/host" +) + +// maxScrobbleItems caps how many scrobbles a single GetScrobbles call can return. +const maxScrobbleItems = 5000 + +type scrobbleRetrieverServiceImpl struct { + ds model.DataStore + users userAccess +} + +func newScrobbleRetrieverService(ds model.DataStore, users userAccess) host.ScrobbleRetrieverService { + return &scrobbleRetrieverServiceImpl{ + ds: ds, + users: users, + } +} + +func (s *scrobbleRetrieverServiceImpl) getUserContext(ctx context.Context, username string) (context.Context, error) { + usr, err := s.users.resolve(ctx, s.ds, username) + if err != nil { + return nil, fmt.Errorf("scrobbleRetriever: %w", err) + } + + ctx = request.WithUser(ctx, *usr) + return ctx, nil +} + +func (s *scrobbleRetrieverServiceImpl) getFirstLastScrobble(ctx context.Context, username string, order string) (*int64, error) { + ctx, err := s.getUserContext(ctx, username) + if err != nil { + return nil, err + } + + scrobbles, err := s.ds.Scrobble(ctx).GetAll(model.QueryOptions{Sort: "submission_time", Order: order, Max: 1}) + if err != nil { + return nil, err + } + + if len(scrobbles) == 0 { + return nil, nil + } + + return &scrobbles[0].SubmissionTime, nil +} + +func (s *scrobbleRetrieverServiceImpl) GetFirstTimestamp(ctx context.Context, username string) (*int64, error) { + return s.getFirstLastScrobble(ctx, username, "ASC") +} + +func (s *scrobbleRetrieverServiceImpl) GetLastTimestamp(ctx context.Context, username string) (*int64, error) { + return s.getFirstLastScrobble(ctx, username, "DESC") +} + +func (s *scrobbleRetrieverServiceImpl) GetScrobbles(ctx context.Context, username string, options host.ScrobbleOptions) ([]host.ScrobbleRef, *host.ScrobbleOptions, error) { + ctx, err := s.getUserContext(ctx, username) + if err != nil { + return nil, nil, err + } + + if options.MaxItems < 1 || options.MaxItems > maxScrobbleItems { + options.MaxItems = maxScrobbleItems + } + options.Offset = max(options.Offset, 0) + + order := "ASC" + if options.Descending { + order = "DESC" + } + + // Fetch one more item than requested. The last item is the next timestamp to fetch + lookahead := options.MaxItems + 1 + + scrobbles, err := s.ds.Scrobble(ctx).GetAll(model.QueryOptions{ + Max: lookahead, + Filters: scrobbleRangeFilters(options.FromTimestamp, options.ToTimestamp), + // The id tiebreak makes the order of equal timestamps stable, which is what + // lets Offset skip exactly the ties already returned + Sort: "scrobbles.submission_time, scrobbles.id", + Order: order, + Offset: options.Offset, + }) + if err != nil { + return nil, nil, err + } + + var next *host.ScrobbleOptions + targetLen := len(scrobbles) + + if len(scrobbles) == lookahead { + nextTimestamp := scrobbles[lookahead-1].SubmissionTime + targetLen = lookahead - 1 + + ties := 0 + for i := targetLen - 1; i >= 0; i-- { + if scrobbles[i].SubmissionTime != nextTimestamp { + break + } + ties++ + } + + // Every scrobble in this page shares the timestamp, so the ties skipped by the + // incoming offset are still ahead of us and must be carried over + if ties == targetLen { + ties += options.Offset + } + + advanced := options + advanced.Offset = ties + if options.Descending { + advanced.ToTimestamp = &nextTimestamp + } else { + advanced.FromTimestamp = &nextTimestamp + } + next = &advanced + } + + scrobbleRefs := make([]host.ScrobbleRef, targetLen) + for idx, scrobble := range scrobbles[:targetLen] { + scrobbleRefs[idx] = host.ScrobbleRef{ + ID: scrobble.ID, + MediaFileID: scrobble.MediaFileID, + SubmissionTime: scrobble.SubmissionTime, + } + } + + return scrobbleRefs, next, nil +} + +func (s *scrobbleRetrieverServiceImpl) GetScrobbleCount(ctx context.Context, username string, options host.ScrobbleCountOptions) (int64, error) { + ctx, err := s.getUserContext(ctx, username) + if err != nil { + return 0, err + } + + return s.ds.Scrobble(ctx).CountAll(model.QueryOptions{ + Filters: scrobbleRangeFilters(options.FromTimestamp, options.ToTimestamp), + }) +} + +func scrobbleRangeFilters(from, to *int64) squirrel.And { + var filters squirrel.And + if from != nil { + filters = append(filters, squirrel.GtOrEq{"scrobbles.submission_time": *from}) + } + if to != nil { + filters = append(filters, squirrel.LtOrEq{"scrobbles.submission_time": *to}) + } + return filters +} + +var _ host.ScrobbleRetrieverService = (*scrobbleRetrieverServiceImpl)(nil) diff --git a/plugins/host_scrobbleretriever_test.go b/plugins/host_scrobbleretriever_test.go new file mode 100644 index 000000000..ab817c282 --- /dev/null +++ b/plugins/host_scrobbleretriever_test.go @@ -0,0 +1,390 @@ +//go:build !windows + +package plugins + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "strconv" + "time" + + extism "github.com/extism/go-sdk" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/db" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/persistence" + "github.com/navidrome/navidrome/plugins/host" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// scrobblePage mirrors what the test plugin emits for the multi-return GetScrobbles +type scrobblePage struct { + Scrobbles []host.ScrobbleRef `json:"scrobbles"` + Next *host.ScrobbleOptions `json:"next"` +} + +var _ = Describe("Scrobble Retriever Host Function", Ordered, func() { + var ( + manager *Manager + tmpDir string + dataStore *tests.MockDataStore + ) + + p := func(val int64) *int64 { + return &val + } + + opts := func(from, to *int64, descending bool, offset, maxItems int) *host.ScrobbleOptions { + return &host.ScrobbleOptions{ + FromTimestamp: from, ToTimestamp: to, + Descending: descending, Offset: offset, MaxItems: maxItems, + } + } + + BeforeAll(func() { + ctx := GinkgoT().Context() + + var err error + tmpDir, err = os.MkdirTemp("", "scrobble-retriever-test-*") + Expect(err).ToNot(HaveOccurred()) + + conf.Server.DbPath = filepath.Join(tmpDir, "test-scanner.db?_journal_mode=WAL") + + db.Init(ctx) + DeferCleanup(func() { + Expect(tests.ClearDB()).To(Succeed()) + }) + dataStore = &tests.MockDataStore{RealDS: persistence.New(db.Db())} + + // Copy test plugin to temp dir + srcPath := filepath.Join(testdataDir, "test-scrobble-retriever"+PackageExtension) + destPath := filepath.Join(tmpDir, "test-scrobble-retriever"+PackageExtension) + data, err := os.ReadFile(srcPath) + Expect(err).ToNot(HaveOccurred()) + err = os.WriteFile(destPath, data, 0600) + Expect(err).ToNot(HaveOccurred()) + + // Setup config + DeferCleanup(configtest.SetupConfig()) + conf.Server.Plugins.Enabled = true + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) + conf.Server.Plugins.AutoReload = false + + userRepo := dataStore.User(ctx) + // Add test users + _ = userRepo.Put(&model.User{ + ID: "user1", + UserName: "testuser", + IsAdmin: false, + }) + _ = userRepo.Put(&model.User{ + ID: "admin1", + UserName: "adminuser", + IsAdmin: true, + }) + + err = dataStore.MediaFile(ctx).Put(&model.MediaFile{ID: "1", LibraryID: 1}) + Expect(err).To(BeNil()) + err = dataStore.MediaFile(ctx).Put(&model.MediaFile{ID: "2", LibraryID: 1}) + Expect(err).To(BeNil()) + err = dataStore.MediaFile(ctx).Put(&model.MediaFile{ID: "3", LibraryID: 1}) + Expect(err).To(BeNil()) + + scrobbleCtx := request.WithUser(GinkgoT().Context(), model.User{ID: "admin1", UserName: "adminuser"}) + + scrobbleRepo := dataStore.Scrobble(scrobbleCtx) + err = scrobbleRepo.RecordScrobble("1", time.Unix(0, 0)) + Expect(err).To(BeNil()) + err = scrobbleRepo.RecordScrobble("2", time.Unix(1, 0)) + Expect(err).To(BeNil()) + err = scrobbleRepo.RecordScrobble("3", time.Unix(2, 0)) + Expect(err).To(BeNil()) + err = scrobbleRepo.RecordScrobble("1", time.Unix(2, 0)) + Expect(err).To(BeNil()) + + // Create and configure manager + manager = &Manager{ + plugins: make(map[string]*plugin), + ds: dataStore, + } + router := &fakeSubsonicRouter{} + manager.SetSubsonicRouter(router) + + // Pre-enable the plugin in the mock repo so it loads on startup + // Compute SHA256 of the plugin file to match what syncPlugins will compute + pluginPath := filepath.Join(tmpDir, "test-scrobble-retriever"+PackageExtension) + wasmData, err := os.ReadFile(pluginPath) + Expect(err).ToNot(HaveOccurred()) + hash := sha256.Sum256(wasmData) + hashHex := hex.EncodeToString(hash[:]) + + dataStore.MockedPlugin = tests.CreateMockPluginRepo() + + mockPluginRepo := dataStore.Plugin(GinkgoT().Context()).(*tests.MockPluginRepo) + mockPluginRepo.Permitted = true + enabledPlugin := model.Plugin{ + ID: "test-scrobble-retriever", + Path: pluginPath, + SHA256: hashHex, + Enabled: true, + Users: `["user1","admin1"]`, + } + mockPluginRepo.SetData(model.Plugins{enabledPlugin}) + + // Start the manager + err = manager.Start(GinkgoT().Context()) + Expect(err).ToNot(HaveOccurred()) + + DeferCleanup(func() { + _ = manager.Stop() + _ = os.RemoveAll(tmpDir) + }) + }) + + var instance *extism.Plugin + + BeforeEach(func() { + manager.mu.RLock() + plugin := manager.plugins["test-scrobble-retriever"] + manager.mu.RUnlock() + Expect(plugin).ToNot(BeNil()) + + var err error + ctx := GinkgoT().Context() + instance, err = plugin.instance(ctx) + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(func() { + instance.Close(ctx) + }) + }) + + Describe("not authorized", func() { + It("rejects first timestamp", func() { + exit, _, err := instance.Call("call_get_first_timestamp", []byte("baduser")) + Expect(err).To(HaveOccurred()) + Expect(exit).To(Equal(uint32(1))) + }) + + It("rejects last timestamp", func() { + exit, _, err := instance.Call("call_get_last_timestamp", []byte("baduser")) + Expect(err).To(HaveOccurred()) + Expect(exit).To(Equal(uint32(1))) + }) + + It("rejects scrobbles", func() { + exit, _, err := instance.Call("call_get_scrobbles", []byte(`{"username":"baduser"}`)) + Expect(err).To(HaveOccurred()) + Expect(exit).To(Equal(uint32(1))) + }) + + It("rejects scrobbles", func() { + exit, _, err := instance.Call("call_get_scrobbles_count", []byte(`{"username":"baduser"}`)) + Expect(err).To(HaveOccurred()) + Expect(exit).To(Equal(uint32(1))) + }) + }) + + Describe("no items", func() { + It("calls get first timestamp", func() { + exit, output, err := instance.Call("call_get_first_timestamp", []byte("testuser")) + Expect(err).ToNot(HaveOccurred()) + Expect(exit).To(Equal(uint32(0))) + + Expect(output).To(Equal([]byte("{\"timestamp\":null}"))) + }) + + It("calls get last timestamp", func() { + exit, output, err := instance.Call("call_get_last_timestamp", []byte("testuser")) + Expect(err).ToNot(HaveOccurred()) + Expect(exit).To(Equal(uint32(0))) + + Expect(output).To(Equal([]byte("{\"timestamp\":null}"))) + }) + + It("calls scrobbles", func() { + exit, output, err := instance.Call("call_get_scrobbles", []byte(`{"username":"testuser"}`)) + Expect(err).ToNot(HaveOccurred()) + Expect(exit).To(Equal(uint32(0))) + + Expect(output).To(Equal([]byte(`{"scrobbles":[],"next":null}`))) + }) + + It("calls get scrobble count", func() { + exit, output, err := instance.Call("call_get_scrobbles_count", []byte(`{"username":"testuser"}`)) + Expect(err).ToNot(HaveOccurred()) + Expect(exit).To(Equal(uint32(0))) + Expect(output).To(Equal([]byte("0"))) + }) + }) + + Describe("with items", func() { + scrobbles := []host.ScrobbleRef{ + {ID: 1, MediaFileID: "1", SubmissionTime: 0}, + {ID: 2, MediaFileID: "2", SubmissionTime: 1}, + {ID: 3, MediaFileID: "3", SubmissionTime: 2}, + {ID: 4, MediaFileID: "1", SubmissionTime: 2}, + } + + scrobblesReversed := make([]host.ScrobbleRef, 4) + + BeforeAll(func() { + for idx := range scrobbles { + scrobblesReversed[3-idx] = scrobbles[idx] + } + }) + + It("calls get first timestamp", func() { + exit, output, err := instance.Call("call_get_first_timestamp", []byte("adminuser")) + Expect(err).ToNot(HaveOccurred()) + Expect(exit).To(Equal(uint32(0))) + + Expect(output).To(Equal([]byte("{\"timestamp\":0}"))) + }) + + It("calls get last timestamp", func() { + exit, output, err := instance.Call("call_get_last_timestamp", []byte("adminuser")) + Expect(err).ToNot(HaveOccurred()) + Expect(exit).To(Equal(uint32(0))) + + Expect(output).To(Equal([]byte("{\"timestamp\":2}"))) + }) + + DescribeTable("getScrobbles", func(params string, scrobbles []host.ScrobbleRef, next *host.ScrobbleOptions) { + exit, output, err := instance.Call("call_get_scrobbles", []byte(params)) + Expect(err).ToNot(HaveOccurred()) + Expect(exit).To(Equal(uint32(0))) + + var page scrobblePage + Expect(json.Unmarshal(output, &page)).To(Succeed()) + Expect(page).To(Equal(scrobblePage{Scrobbles: scrobbles, Next: next})) + }, + Entry("calls scrobbles in ascending order", `{"username":"adminuser"}`, scrobbles, nil), + Entry("calls scrobbles in descending order by request", `{"username":"adminuser","descending":true}`, scrobblesReversed, nil), + Entry("calls scrobbles in ascending order, beyond range", `{"username":"adminuser","fromTimestamp":-1, "toTimestamp": 1000}`, scrobbles, nil), + Entry("defaults to ascending even when only toTimestamp is given", `{"username":"adminuser","toTimestamp":2}`, scrobbles, nil), + Entry("calls subset of scrobbles in ascending order, next page", `{"username":"adminuser","maxItems":2}`, scrobbles[:2], opts(p(2), nil, false, 0, 2)), + Entry("calls subset of scrobbles in ascending order, next page with offset", `{"username":"adminuser","maxItems":2,"fromTimestamp":1}`, scrobbles[1:3], opts(p(2), nil, false, 1, 2)), + Entry("calls subset of scrobbles in ascending order, from and to timestamp", `{"username":"adminuser","toTimestamp":2,"fromTimestamp":1}`, scrobbles[1:], nil), + Entry("calls subset of scrobbles in descending order, from and to timestamp", `{"username":"adminuser","toTimestamp":2,"fromTimestamp":1,"descending":true}`, scrobblesReversed[:3], nil), + Entry("calls in reverse order, full", `{"username":"adminuser","toTimestamp":2,"descending":true}`, scrobblesReversed, nil), + Entry("calls in reverse order, with count", `{"username":"adminuser","toTimestamp":2,"descending":true, "maxItems": 3}`, scrobblesReversed[:3], opts(nil, p(0), true, 0, 3)), + Entry("calls in reverse order, with count of 1", `{"username":"adminuser","toTimestamp":2,"descending":true, "maxItems": 1}`, scrobblesReversed[:1], opts(nil, p(2), true, 1, 1)), + ) + + DescribeTable("GetScrobblesCount", func(params string, count int) { + exit, output, err := instance.Call("call_get_scrobbles_count", []byte(params)) + Expect(err).ToNot(HaveOccurred()) + Expect(exit).To(Equal(uint32(0))) + + value, err := strconv.ParseInt(string(output), 10, 64) + Expect(err).ToNot(HaveOccurred()) + Expect(value).To(Equal(int64(count))) + }, + Entry("gets all scrobbles", `{"username":"adminuser"}`, 4), + Entry("gets two scrobbles ascending", `{"username":"adminuser", "fromTimestamp": 2}`, 2), + Entry("gets one scrobble descending", `{"username":"adminuser", "toTimestamp": 0}`, 1), + Entry("filters upper and bottom", `{"username":"adminuser", "fromTimestamp": 1, "toTimestamp": 1}`, 1), + Entry("accepts filter out of range", `{"username":"adminuser", "fromTimestamp": -1, "toTimestamp": 1000}`, 4), + ) + }) + + Context("Complex edge cases - multiple scrobbles at the same timestamp", func() { + duplicates := make([]host.ScrobbleRef, 5) + duplicatesReversed := make([]host.ScrobbleRef, 5) + + BeforeAll(func() { + scrobbleCtx := request.WithUser(GinkgoT().Context(), model.User{ID: "admin1", UserName: "adminuser"}) + + scrobbleRepo := dataStore.Scrobble(scrobbleCtx) + + for i := range 5 { + err := scrobbleRepo.RecordScrobble("3", time.Unix(100, 0)) + Expect(err).To(BeNil()) + + scrobble := host.ScrobbleRef{ID: 5 + int64(i), MediaFileID: "3", SubmissionTime: 100} + duplicates[i] = scrobble + duplicatesReversed[4-i] = scrobble + } + }) + + getPage := func(o host.ScrobbleOptions) scrobblePage { + GinkgoHelper() + payload, err := json.Marshal(struct { + Username string `json:"username"` + FromTimestamp *int64 `json:"fromTimestamp,omitempty"` + ToTimestamp *int64 `json:"toTimestamp,omitempty"` + Descending bool `json:"descending"` + Offset int `json:"offset,omitempty"` + MaxItems int `json:"maxItems"` + }{ + Username: "adminuser", + FromTimestamp: o.FromTimestamp, + ToTimestamp: o.ToTimestamp, + Descending: o.Descending, + Offset: o.Offset, + MaxItems: o.MaxItems, + }) + Expect(err).ToNot(HaveOccurred()) + + exit, output, err := instance.Call("call_get_scrobbles", payload) + Expect(err).ToNot(HaveOccurred()) + Expect(exit).To(Equal(uint32(0))) + + var page scrobblePage + Expect(json.Unmarshal(output, &page)).To(Succeed()) + return page + } + + // every scrobble here shares timestamp 100, so only Offset advances between pages + tied := func(offset, maxItems int, descending bool) host.ScrobbleOptions { + return *opts(p(100), p(100), descending, offset, maxItems) + } + + DescribeTable("Edge cases; duplicate scrobbles", func(offset, count int, descending bool, scrobbles []host.ScrobbleRef, nextOffset *int) { + var next *host.ScrobbleOptions + if nextOffset != nil { + next = opts(p(100), p(100), descending, *nextOffset, count) + } + Expect(getPage(tied(offset, count, descending))).To(Equal(scrobblePage{Scrobbles: scrobbles, Next: next})) + }, + Entry("All tracks, in order", 0, 100, false, duplicates, nil), + Entry("All tracks, in reverse order", 0, 100, true, duplicatesReversed, nil), + Entry("Ascending order, from the start", 0, 1, false, duplicates[:1], new(1)), + Entry("Ascending order, middle page", 1, 2, false, duplicates[1:3], new(3)), + Entry("Ascending order, to the end", 3, 100, false, duplicates[3:], nil), + Entry("Ascending order, from the start, continuing offset", 3, 1, false, duplicates[3:4], new(4)), + Entry("start descending", 0, 2, true, duplicatesReversed[:2], new(2)), + Entry("start descending, next step", 2, 2, true, duplicatesReversed[2:4], new(4)), + Entry("start descending, end", 4, 1, true, duplicatesReversed[4:], nil), + ) + + It("walks every page exactly once when all timestamps collide", func() { + for _, descending := range []bool{false, true} { + var seen []host.ScrobbleRef + o := tied(0, 2, descending) + var page scrobblePage + for range 10 { + page = getPage(o) + seen = append(seen, page.Scrobbles...) + if page.Next == nil { + break + } + o = *page.Next + } + Expect(page.Next).To(BeNil(), "pagination did not terminate") + if descending { + Expect(seen).To(Equal(duplicatesReversed)) + } else { + Expect(seen).To(Equal(duplicates)) + } + } + }) + }) +}) diff --git a/plugins/manager_loader.go b/plugins/manager_loader.go index 0e9e7bb62..439c944f5 100644 --- a/plugins/manager_loader.go +++ b/plugins/manager_loader.go @@ -177,6 +177,14 @@ var hostServices = []hostServiceEntry{ return host.RegisterStorageHostFunctions(service), nil, nil }, }, + { + name: "ScrobbleRetriever", + hasPermission: func(p *Permissions) bool { return p != nil && p.ScrobbleRetriever != nil }, + create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) { + service := newScrobbleRetrieverService(ctx.manager.ds, newUserAccess(ctx.allowedUsers, ctx.allUsers)) + return host.RegisterScrobbleRetrieverHostFunctions(service), nil, nil + }, + }, } // extractManifest reads manifest from an .ndp package and computes its SHA-256 hash. diff --git a/plugins/manifest-schema.json b/plugins/manifest-schema.json index 736677313..dae0fd937 100644 --- a/plugins/manifest-schema.json +++ b/plugins/manifest-schema.json @@ -95,6 +95,9 @@ }, "storage": { "$ref": "#/$defs/StoragePermission" + }, + "scrobbleRetriever": { + "$ref": "#/$defs/ScrobbleRetrieverPermission" } } }, @@ -258,6 +261,17 @@ "description": "Explanation for why storage access is needed" } } + }, + "ScrobbleRetrieverPermission": { + "type": "object", + "description": "Scrobble retriever permissions for retrieving scrobbles from users", + "additionalProperties": false, + "properties": { + "reason": { + "type": "string", + "description": "Explanation for why scrobble retriever access is needed" + } + } } } } diff --git a/plugins/manifest.go b/plugins/manifest.go index ef70e8d9d..5c0d91f51 100644 --- a/plugins/manifest.go +++ b/plugins/manifest.go @@ -53,10 +53,14 @@ func ParseManifest(data []byte) (*Manifest, error) { // This validates rules like "SubsonicAPI permission requires users permission". func (m *Manifest) Validate() error { // SubsonicAPI permission requires users permission - if m.Permissions != nil && m.Permissions.Subsonicapi != nil { - if m.Permissions.Users == nil { + if m.Permissions != nil && m.Permissions.Users == nil { + if m.Permissions.Subsonicapi != nil { return fmt.Errorf("'subsonicapi' permission requires 'users' permission to be declared") } + + if m.Permissions.ScrobbleRetriever != nil { + return fmt.Errorf("'scrobbleRetriever' permission requires 'users' permission to be declared") + } } // Matcher returns library content, so it requires the library permission (which diff --git a/plugins/manifest_gen.go b/plugins/manifest_gen.go index 5e0035bd5..6f5c01596 100644 --- a/plugins/manifest_gen.go +++ b/plugins/manifest_gen.go @@ -178,6 +178,9 @@ type Permissions struct { // Scheduler corresponds to the JSON schema field "scheduler". Scheduler *SchedulerPermission `json:"scheduler,omitempty" yaml:"scheduler,omitempty" mapstructure:"scheduler,omitempty"` + // ScrobbleRetriever corresponds to the JSON schema field "scrobbleRetriever". + ScrobbleRetriever *ScrobbleRetrieverPermission `json:"scrobbleRetriever,omitempty" yaml:"scrobbleRetriever,omitempty" mapstructure:"scrobbleRetriever,omitempty"` + // Storage corresponds to the JSON schema field "storage". Storage *StoragePermission `json:"storage,omitempty" yaml:"storage,omitempty" mapstructure:"storage,omitempty"` @@ -200,6 +203,12 @@ type SchedulerPermission struct { Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"` } +// Scrobble retriever permissions for retrieving scrobbles from users +type ScrobbleRetrieverPermission struct { + // Explanation for why scrobble retriever access is needed + Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"` +} + // Storage permissions for enabling persistent read-write storage exclusively for // the plugin type StoragePermission struct { diff --git a/plugins/manifest_test.go b/plugins/manifest_test.go index 32bcbba08..bfd8bcac1 100644 --- a/plugins/manifest_test.go +++ b/plugins/manifest_test.go @@ -222,6 +222,36 @@ var _ = Describe("Manifest", func() { Expect(err.Error()).To(ContainSubstring("library")) }) + It("validates manifest with scrobbleRetriever and users permissions", func() { + m := &Manifest{ + Name: "Test", + Author: "Author", + Version: "1.0.0", + Permissions: &Permissions{ + ScrobbleRetriever: &ScrobbleRetrieverPermission{}, + Users: &UsersPermission{}, + }, + } + + err := m.Validate() + Expect(err).ToNot(HaveOccurred()) + }) + + It("returns error when scrobbleRetriever without users permission", func() { + m := &Manifest{ + Name: "Test", + Author: "Author", + Version: "1.0.0", + Permissions: &Permissions{ + ScrobbleRetriever: &ScrobbleRetrieverPermission{}, + }, + } + + err := m.Validate() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("scrobbleRetriever")) + }) + It("validates manifest without subsonicapi", func() { m := &Manifest{ Name: "Test", diff --git a/plugins/pdk/go/host/doc.go b/plugins/pdk/go/host/doc.go index e4157e29c..9063f2bd0 100644 --- a/plugins/pdk/go/host/doc.go +++ b/plugins/pdk/go/host/doc.go @@ -43,6 +43,7 @@ The following host services are available: - Library: provides access to music library metadata for plugins. - Matcher: resolves externally-obtained songs to local library tracks, - Scheduler: provides task scheduling capabilities for plugins. + - ScrobbleRetriever: allows a plugin to retrieve scrobbles for one or more authorized users. - Storage: provides access to a plugin-specific directory with read/write permissions - SubsonicAPI: provides access to Navidrome's Subsonic API from plugins. - Task: provides persistent task queues for plugins. diff --git a/plugins/pdk/go/host/nd_host_scrobbleretriever.go b/plugins/pdk/go/host/nd_host_scrobbleretriever.go new file mode 100644 index 000000000..596d7ec1d --- /dev/null +++ b/plugins/pdk/go/host/nd_host_scrobbleretriever.go @@ -0,0 +1,266 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains client wrappers for the ScrobbleRetriever host service. +// It is intended for use in Navidrome plugins built with TinyGo. +// +//go:build wasip1 + +package host + +import ( + "encoding/json" + "errors" + + "github.com/navidrome/navidrome/plugins/pdk/go/pdk" +) + +// ScrobbleCountOptions represents the ScrobbleCountOptions data structure. +// ScrobbleCountOptions carries optional parameters for counting user scrobbles +type ScrobbleCountOptions struct { + FromTimestamp *int64 `json:"fromTimestamp"` + ToTimestamp *int64 `json:"toTimestamp"` +} + +// ScrobbleOptions represents the ScrobbleOptions data structure. +// ScrobbleOptions carries optional parameters for retrieving user scrobbles +type ScrobbleOptions struct { + FromTimestamp *int64 `json:"fromTimestamp"` + ToTimestamp *int64 `json:"toTimestamp"` + Descending bool `json:"descending"` + MaxItems int `json:"maxItems"` + Offset int `json:"offset"` +} + +// ScrobbleRef represents the ScrobbleRef data structure. +// ScrobbleRef represents one instance of a scrobble (instance id, file id, submission time) +type ScrobbleRef struct { + ID int64 `json:"id"` + MediaFileID string `json:"mediaFileId"` + SubmissionTime int64 `json:"submissionTime"` +} + +// scrobbleretriever_getfirsttimestamp is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user scrobbleretriever_getfirsttimestamp +func scrobbleretriever_getfirsttimestamp(uint64) uint64 + +// scrobbleretriever_getlasttimestamp is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user scrobbleretriever_getlasttimestamp +func scrobbleretriever_getlasttimestamp(uint64) uint64 + +// scrobbleretriever_getscrobbles is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user scrobbleretriever_getscrobbles +func scrobbleretriever_getscrobbles(uint64) uint64 + +// scrobbleretriever_getscrobblecount is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user scrobbleretriever_getscrobblecount +func scrobbleretriever_getscrobblecount(uint64) uint64 + +type scrobbleRetrieverGetFirstTimestampRequest struct { + Username string `json:"username"` +} + +type scrobbleRetrieverGetFirstTimestampResponse struct { + Result *int64 `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +type scrobbleRetrieverGetLastTimestampRequest struct { + Username string `json:"username"` +} + +type scrobbleRetrieverGetLastTimestampResponse struct { + Result *int64 `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +type scrobbleRetrieverGetScrobblesRequest struct { + Username string `json:"username"` + Options ScrobbleOptions `json:"options"` +} + +type scrobbleRetrieverGetScrobblesResponse struct { + Scrobbles []ScrobbleRef `json:"scrobbles,omitempty"` + Next *ScrobbleOptions `json:"next,omitempty"` + Error string `json:"error,omitempty"` +} + +type scrobbleRetrieverGetScrobbleCountRequest struct { + Username string `json:"username"` + Options ScrobbleCountOptions `json:"options"` +} + +type scrobbleRetrieverGetScrobbleCountResponse struct { + Result int64 `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +// ScrobbleRetrieverGetFirstTimestamp calls the scrobbleretriever_getfirsttimestamp host function. +// GetFirstTimestamp returns the unix timestamp of the oldest scrobble for the user. +// If the user has no scrobbles, returns nil +func ScrobbleRetrieverGetFirstTimestamp(username string) (*int64, error) { + // Marshal request to JSON + req := scrobbleRetrieverGetFirstTimestampRequest{ + Username: username, + } + reqBytes, err := json.Marshal(req) + if err != nil { + return nil, err + } + reqMem := pdk.AllocateBytes(reqBytes) + defer reqMem.Free() + + // Call the host function + responsePtr := scrobbleretriever_getfirsttimestamp(reqMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response scrobbleRetrieverGetFirstTimestampResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + // Convert Error field to Go error + if response.Error != "" { + return nil, errors.New(response.Error) + } + + return response.Result, nil +} + +// ScrobbleRetrieverGetLastTimestamp calls the scrobbleretriever_getlasttimestamp host function. +// GetLastTimestamp returns the unix timestamp of the most recent scrobble for the user +// If the user has no scrobbles, return nil +func ScrobbleRetrieverGetLastTimestamp(username string) (*int64, error) { + // Marshal request to JSON + req := scrobbleRetrieverGetLastTimestampRequest{ + Username: username, + } + reqBytes, err := json.Marshal(req) + if err != nil { + return nil, err + } + reqMem := pdk.AllocateBytes(reqBytes) + defer reqMem.Free() + + // Call the host function + responsePtr := scrobbleretriever_getlasttimestamp(reqMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response scrobbleRetrieverGetLastTimestampResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + // Convert Error field to Go error + if response.Error != "" { + return nil, errors.New(response.Error) + } + + return response.Result, nil +} + +// ScrobbleRetrieverGetScrobbles calls the scrobbleretriever_getscrobbles host function. +// GetScrobbles returns one page of scrobbles for a user. +// +// Parameters: +// - username: the user to query for scrobbles +// - options.FromTimestamp: If specified, the first UNIX timestamp to start fetching scrobbles (inclusive). Otherwise, start from the first scrobble +// - options.ToTimestamp: If specified, the last UNIX timestamp to fetch (inclusive). Otherwise, end at the last scrobble +// - options.Descending: If true, order from newest to oldest. Otherwise, oldest to newest +// - options.MaxItems: The maximum number of items to retrieve. The maximum value (and default) if not specified is 5000 +// - options.Offset: Pagination state; only valid as received on the options returned by a previous call. Never set it manually +// +// Returns: +// - scrobbles: The scrobbles in the requested range, ordered by submission time +// (ties broken by scrobble ID) in the direction given by options.Descending +// - next: The options for the following page, or nil once no scrobbles remain. +// Pass it back to GetScrobbles unchanged and repeat until it is nil. It carries an +// adjusted FromTimestamp/ToTimestamp, so keep a copy if you still need the original range +func ScrobbleRetrieverGetScrobbles(username string, options ScrobbleOptions) ([]ScrobbleRef, *ScrobbleOptions, error) { + // Marshal request to JSON + req := scrobbleRetrieverGetScrobblesRequest{ + Username: username, + Options: options, + } + reqBytes, err := json.Marshal(req) + if err != nil { + return nil, nil, err + } + reqMem := pdk.AllocateBytes(reqBytes) + defer reqMem.Free() + + // Call the host function + responsePtr := scrobbleretriever_getscrobbles(reqMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response scrobbleRetrieverGetScrobblesResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, nil, err + } + + // Convert Error field to Go error + if response.Error != "" { + return nil, nil, errors.New(response.Error) + } + + return response.Scrobbles, response.Next, nil +} + +// ScrobbleRetrieverGetScrobbleCount calls the scrobbleretriever_getscrobblecount host function. +// GetScrobbleCount returns the number of scrobbles for a user in a given range +// +// Parameters: +// - username: the user to query for scrobbles +// - options.FromTimestamp: If specified, the first UNIX timestamp to start fetching scrobbles (inclusive). Otherwise, start from the first scrobble +// - options.ToTimestamp: If specified, the last UNIX timestamp to fetch (inclusive). Otherwise, end at the last scrobble +// +// Returns: +// - the number of scrobbles in the given range, or 0 +func ScrobbleRetrieverGetScrobbleCount(username string, options ScrobbleCountOptions) (int64, error) { + // Marshal request to JSON + req := scrobbleRetrieverGetScrobbleCountRequest{ + Username: username, + Options: options, + } + reqBytes, err := json.Marshal(req) + if err != nil { + return 0, err + } + reqMem := pdk.AllocateBytes(reqBytes) + defer reqMem.Free() + + // Call the host function + responsePtr := scrobbleretriever_getscrobblecount(reqMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response scrobbleRetrieverGetScrobbleCountResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return 0, err + } + + // Convert Error field to Go error + if response.Error != "" { + return 0, errors.New(response.Error) + } + + return response.Result, nil +} diff --git a/plugins/pdk/go/host/nd_host_scrobbleretriever_stub.go b/plugins/pdk/go/host/nd_host_scrobbleretriever_stub.go new file mode 100644 index 000000000..a75fcebb5 --- /dev/null +++ b/plugins/pdk/go/host/nd_host_scrobbleretriever_stub.go @@ -0,0 +1,136 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains mock implementations for non-WASM builds. +// These mocks allow IDE support, compilation, and unit testing on non-WASM platforms. +// Plugin authors can use the exported mock instances to set expectations in tests. +// +//go:build !wasip1 + +package host + +import ( + "github.com/stretchr/testify/mock" +) + +// ScrobbleCountOptions represents the ScrobbleCountOptions data structure. +// ScrobbleCountOptions carries optional parameters for counting user scrobbles +type ScrobbleCountOptions struct { + FromTimestamp *int64 `json:"fromTimestamp"` + ToTimestamp *int64 `json:"toTimestamp"` +} + +// ScrobbleOptions represents the ScrobbleOptions data structure. +// ScrobbleOptions carries optional parameters for retrieving user scrobbles +type ScrobbleOptions struct { + FromTimestamp *int64 `json:"fromTimestamp"` + ToTimestamp *int64 `json:"toTimestamp"` + Descending bool `json:"descending"` + MaxItems int `json:"maxItems"` + Offset int `json:"offset"` +} + +// ScrobbleRef represents the ScrobbleRef data structure. +// ScrobbleRef represents one instance of a scrobble (instance id, file id, submission time) +type ScrobbleRef struct { + ID int64 `json:"id"` + MediaFileID string `json:"mediaFileId"` + SubmissionTime int64 `json:"submissionTime"` +} + +// mockScrobbleRetrieverService is the mock implementation for testing. +type mockScrobbleRetrieverService struct { + mock.Mock +} + +// ScrobbleRetrieverMock is the auto-instantiated mock instance for testing. +// Use this to set expectations: host.ScrobbleRetrieverMock.On("MethodName", args...).Return(values...) +var ScrobbleRetrieverMock = &mockScrobbleRetrieverService{} + +// GetFirstTimestamp is the mock method for ScrobbleRetrieverGetFirstTimestamp. +func (m *mockScrobbleRetrieverService) GetFirstTimestamp(username string) (*int64, error) { + args := m.Called(username) + var r0 *int64 + if v := args.Get(0); v != nil { + r0 = v.(*int64) + } + return r0, args.Error(1) +} + +// ScrobbleRetrieverGetFirstTimestamp delegates to the mock instance. +// GetFirstTimestamp returns the unix timestamp of the oldest scrobble for the user. +// If the user has no scrobbles, returns nil +func ScrobbleRetrieverGetFirstTimestamp(username string) (*int64, error) { + return ScrobbleRetrieverMock.GetFirstTimestamp(username) +} + +// GetLastTimestamp is the mock method for ScrobbleRetrieverGetLastTimestamp. +func (m *mockScrobbleRetrieverService) GetLastTimestamp(username string) (*int64, error) { + args := m.Called(username) + var r0 *int64 + if v := args.Get(0); v != nil { + r0 = v.(*int64) + } + return r0, args.Error(1) +} + +// ScrobbleRetrieverGetLastTimestamp delegates to the mock instance. +// GetLastTimestamp returns the unix timestamp of the most recent scrobble for the user +// If the user has no scrobbles, return nil +func ScrobbleRetrieverGetLastTimestamp(username string) (*int64, error) { + return ScrobbleRetrieverMock.GetLastTimestamp(username) +} + +// GetScrobbles is the mock method for ScrobbleRetrieverGetScrobbles. +func (m *mockScrobbleRetrieverService) GetScrobbles(username string, options ScrobbleOptions) ([]ScrobbleRef, *ScrobbleOptions, error) { + args := m.Called(username, options) + var r0 []ScrobbleRef + if v := args.Get(0); v != nil { + r0 = v.([]ScrobbleRef) + } + var r1 *ScrobbleOptions + if v := args.Get(1); v != nil { + r1 = v.(*ScrobbleOptions) + } + return r0, r1, args.Error(2) +} + +// ScrobbleRetrieverGetScrobbles delegates to the mock instance. +// GetScrobbles returns one page of scrobbles for a user. +// +// Parameters: +// - username: the user to query for scrobbles +// - options.FromTimestamp: If specified, the first UNIX timestamp to start fetching scrobbles (inclusive). Otherwise, start from the first scrobble +// - options.ToTimestamp: If specified, the last UNIX timestamp to fetch (inclusive). Otherwise, end at the last scrobble +// - options.Descending: If true, order from newest to oldest. Otherwise, oldest to newest +// - options.MaxItems: The maximum number of items to retrieve. The maximum value (and default) if not specified is 5000 +// - options.Offset: Pagination state; only valid as received on the options returned by a previous call. Never set it manually +// +// Returns: +// - scrobbles: The scrobbles in the requested range, ordered by submission time +// (ties broken by scrobble ID) in the direction given by options.Descending +// - next: The options for the following page, or nil once no scrobbles remain. +// Pass it back to GetScrobbles unchanged and repeat until it is nil. It carries an +// adjusted FromTimestamp/ToTimestamp, so keep a copy if you still need the original range +func ScrobbleRetrieverGetScrobbles(username string, options ScrobbleOptions) ([]ScrobbleRef, *ScrobbleOptions, error) { + return ScrobbleRetrieverMock.GetScrobbles(username, options) +} + +// GetScrobbleCount is the mock method for ScrobbleRetrieverGetScrobbleCount. +func (m *mockScrobbleRetrieverService) GetScrobbleCount(username string, options ScrobbleCountOptions) (int64, error) { + args := m.Called(username, options) + return args.Get(0).(int64), args.Error(1) +} + +// ScrobbleRetrieverGetScrobbleCount delegates to the mock instance. +// GetScrobbleCount returns the number of scrobbles for a user in a given range +// +// Parameters: +// - username: the user to query for scrobbles +// - options.FromTimestamp: If specified, the first UNIX timestamp to start fetching scrobbles (inclusive). Otherwise, start from the first scrobble +// - options.ToTimestamp: If specified, the last UNIX timestamp to fetch (inclusive). Otherwise, end at the last scrobble +// +// Returns: +// - the number of scrobbles in the given range, or 0 +func ScrobbleRetrieverGetScrobbleCount(username string, options ScrobbleCountOptions) (int64, error) { + return ScrobbleRetrieverMock.GetScrobbleCount(username, options) +} diff --git a/plugins/pdk/rust/nd-pdk-host/src/lib.rs b/plugins/pdk/rust/nd-pdk-host/src/lib.rs index 3da023f5c..33cbe7725 100644 --- a/plugins/pdk/rust/nd-pdk-host/src/lib.rs +++ b/plugins/pdk/rust/nd-pdk-host/src/lib.rs @@ -40,6 +40,7 @@ //! - [`library`] - provides access to music library metadata for plugins. //! - [`matcher`] - resolves externally-obtained songs to local library tracks, //! - [`scheduler`] - provides task scheduling capabilities for plugins. +//! - [`scrobbleretriever`] - allows a plugin to retrieve scrobbles for one or more authorized users. //! - [`storage`] - provides access to a plugin-specific directory with read/write permissions //! - [`subsonicapi`] - provides access to Navidrome's Subsonic API from plugins. //! - [`task`] - provides persistent task queues for plugins. @@ -102,6 +103,13 @@ pub mod scheduler { pub use super::nd_host_scheduler::*; } +#[doc(hidden)] +mod nd_host_scrobbleretriever; +/// allows a plugin to retrieve scrobbles for one or more authorized users. +pub mod scrobbleretriever { + pub use super::nd_host_scrobbleretriever::*; +} + #[doc(hidden)] mod nd_host_storage; /// provides access to a plugin-specific directory with read/write permissions diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_scrobbleretriever.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_scrobbleretriever.rs new file mode 100644 index 000000000..fe80d98f6 --- /dev/null +++ b/plugins/pdk/rust/nd-pdk-host/src/nd_host_scrobbleretriever.rs @@ -0,0 +1,237 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains client wrappers for the ScrobbleRetriever host service. +// It is intended for use in Navidrome plugins built with extism-pdk. + +use extism_pdk::*; +use serde::{Deserialize, Serialize}; + +/// ScrobbleCountOptions carries optional parameters for counting user scrobbles +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ScrobbleCountOptions { + #[serde(default)] + pub from_timestamp: Option, + #[serde(default)] + pub to_timestamp: Option, +} + +/// ScrobbleOptions carries optional parameters for retrieving user scrobbles +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ScrobbleOptions { + #[serde(default)] + pub from_timestamp: Option, + #[serde(default)] + pub to_timestamp: Option, + pub descending: bool, + pub max_items: i32, + #[serde(default)] + pub offset: i32, +} + +/// ScrobbleRef represents one instance of a scrobble (instance id, file id, submission time) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ScrobbleRef { + pub id: i64, + pub media_file_id: String, + pub submission_time: i64, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct ScrobbleRetrieverGetFirstTimestampRequest { + username: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ScrobbleRetrieverGetFirstTimestampResponse { + #[serde(default)] + result: Option, + #[serde(default)] + error: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct ScrobbleRetrieverGetLastTimestampRequest { + username: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ScrobbleRetrieverGetLastTimestampResponse { + #[serde(default)] + result: Option, + #[serde(default)] + error: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct ScrobbleRetrieverGetScrobblesRequest { + username: String, + options: ScrobbleOptions, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ScrobbleRetrieverGetScrobblesResponse { + #[serde(default)] + scrobbles: Vec, + #[serde(default)] + next: Option, + #[serde(default)] + error: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct ScrobbleRetrieverGetScrobbleCountRequest { + username: String, + options: ScrobbleCountOptions, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ScrobbleRetrieverGetScrobbleCountResponse { + #[serde(default)] + result: i64, + #[serde(default)] + error: Option, +} + +#[host_fn] +extern "ExtismHost" { + fn scrobbleretriever_getfirsttimestamp(input: Json) -> Json; + fn scrobbleretriever_getlasttimestamp(input: Json) -> Json; + fn scrobbleretriever_getscrobbles(input: Json) -> Json; + fn scrobbleretriever_getscrobblecount(input: Json) -> Json; +} + +/// GetFirstTimestamp returns the unix timestamp of the oldest scrobble for the user. +/// If the user has no scrobbles, returns nil +/// +/// # Arguments +/// * `username` - String parameter. +/// +/// # Returns +/// The result value. +/// +/// # Errors +/// Returns an error if the host function call fails. +pub fn get_first_timestamp(username: &str) -> Result, Error> { + let response = unsafe { + scrobbleretriever_getfirsttimestamp(Json(ScrobbleRetrieverGetFirstTimestampRequest { + username: username.to_owned(), + }))? + }; + + if let Some(err) = response.0.error { + return Err(Error::msg(err)); + } + + Ok(response.0.result) +} + +/// GetLastTimestamp returns the unix timestamp of the most recent scrobble for the user +/// If the user has no scrobbles, return nil +/// +/// # Arguments +/// * `username` - String parameter. +/// +/// # Returns +/// The result value. +/// +/// # Errors +/// Returns an error if the host function call fails. +pub fn get_last_timestamp(username: &str) -> Result, Error> { + let response = unsafe { + scrobbleretriever_getlasttimestamp(Json(ScrobbleRetrieverGetLastTimestampRequest { + username: username.to_owned(), + }))? + }; + + if let Some(err) = response.0.error { + return Err(Error::msg(err)); + } + + Ok(response.0.result) +} + +/// GetScrobbles returns one page of scrobbles for a user. +/// +/// Parameters: +/// - username: the user to query for scrobbles +/// - options.FromTimestamp: If specified, the first UNIX timestamp to start fetching scrobbles (inclusive). Otherwise, start from the first scrobble +/// - options.ToTimestamp: If specified, the last UNIX timestamp to fetch (inclusive). Otherwise, end at the last scrobble +/// - options.Descending: If true, order from newest to oldest. Otherwise, oldest to newest +/// - options.MaxItems: The maximum number of items to retrieve. The maximum value (and default) if not specified is 5000 +/// - options.Offset: Pagination state; only valid as received on the options returned by a previous call. Never set it manually +/// +/// Returns: +/// - scrobbles: The scrobbles in the requested range, ordered by submission time +/// (ties broken by scrobble ID) in the direction given by options.Descending +/// - next: The options for the following page, or nil once no scrobbles remain. +/// Pass it back to GetScrobbles unchanged and repeat until it is nil. It carries an +/// adjusted FromTimestamp/ToTimestamp, so keep a copy if you still need the original range +/// +/// # Arguments +/// * `username` - String parameter. +/// * `options` - ScrobbleOptions parameter. +/// +/// # Returns +/// A tuple of (scrobbles, next). +/// +/// # Errors +/// Returns an error if the host function call fails. +pub fn get_scrobbles(username: &str, options: ScrobbleOptions) -> Result<(Vec, Option), Error> { + let response = unsafe { + scrobbleretriever_getscrobbles(Json(ScrobbleRetrieverGetScrobblesRequest { + username: username.to_owned(), + options: options, + }))? + }; + + if let Some(err) = response.0.error { + return Err(Error::msg(err)); + } + + Ok((response.0.scrobbles, response.0.next)) +} + +/// GetScrobbleCount returns the number of scrobbles for a user in a given range +/// +/// Parameters: +/// - username: the user to query for scrobbles +/// - options.FromTimestamp: If specified, the first UNIX timestamp to start fetching scrobbles (inclusive). Otherwise, start from the first scrobble +/// - options.ToTimestamp: If specified, the last UNIX timestamp to fetch (inclusive). Otherwise, end at the last scrobble +/// +/// Returns: +/// - the number of scrobbles in the given range, or 0 +/// +/// # Arguments +/// * `username` - String parameter. +/// * `options` - ScrobbleCountOptions parameter. +/// +/// # Returns +/// The result value. +/// +/// # Errors +/// Returns an error if the host function call fails. +pub fn get_scrobble_count(username: &str, options: ScrobbleCountOptions) -> Result { + let response = unsafe { + scrobbleretriever_getscrobblecount(Json(ScrobbleRetrieverGetScrobbleCountRequest { + username: username.to_owned(), + options: options, + }))? + }; + + if let Some(err) = response.0.error { + return Err(Error::msg(err)); + } + + Ok(response.0.result) +} diff --git a/plugins/testdata/test-scrobble-retriever/go.mod b/plugins/testdata/test-scrobble-retriever/go.mod new file mode 100644 index 000000000..59486d796 --- /dev/null +++ b/plugins/testdata/test-scrobble-retriever/go.mod @@ -0,0 +1,16 @@ +module test-scrobble-retriever + +go 1.25 + +require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/extism/go-pdk v1.1.3 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect + github.com/stretchr/testify v1.11.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go diff --git a/plugins/testdata/test-scrobble-retriever/go.sum b/plugins/testdata/test-scrobble-retriever/go.sum new file mode 100644 index 000000000..af880eb51 --- /dev/null +++ b/plugins/testdata/test-scrobble-retriever/go.sum @@ -0,0 +1,14 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ= +github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/plugins/testdata/test-scrobble-retriever/main.go b/plugins/testdata/test-scrobble-retriever/main.go new file mode 100644 index 000000000..cc824acb9 --- /dev/null +++ b/plugins/testdata/test-scrobble-retriever/main.go @@ -0,0 +1,120 @@ +package main + +import ( + "strconv" + + "github.com/navidrome/navidrome/plugins/pdk/go/host" + "github.com/navidrome/navidrome/plugins/pdk/go/pdk" +) + +func main() { + +} + +type TestScrobbleTimestampOutput struct { + Timestamp *int64 `json:"timestamp"` +} + +//go:wasmexport call_get_first_timestamp +func callGetFirstTimestamp() int32 { + username := pdk.InputString() + + time, err := host.ScrobbleRetrieverGetFirstTimestamp(username) + if err != nil { + pdk.SetErrorString("failed to call scrobble retriever api " + err.Error()) + return 1 + } + + pdk.OutputJSON(TestScrobbleTimestampOutput{Timestamp: time}) + return 0 +} + +//go:wasmexport call_get_last_timestamp +func callGetLastTimestamp() int32 { + username := pdk.InputString() + + time, err := host.ScrobbleRetrieverGetLastTimestamp(username) + if err != nil { + pdk.SetErrorString("failed to call scrobble retriever api " + err.Error()) + return 1 + } + + pdk.OutputJSON(TestScrobbleTimestampOutput{Timestamp: time}) + return 0 +} + +type TestScrobbleOptions struct { + Username string `json:"username"` + FromTimestamp *int64 `json:"fromTimestamp,omitempty"` + ToTimestamp *int64 `json:"toTimestamp,omitempty"` + Descending bool `json:"descending"` + Offset int `json:"offset,omitempty"` + MaxItems int `json:"maxItems"` +} + +// TestScrobblePage mirrors the multi-return shape so tests can assert on both values +type TestScrobblePage struct { + Scrobbles []host.ScrobbleRef `json:"scrobbles"` + Next *host.ScrobbleOptions `json:"next"` +} + +//go:wasmexport call_get_scrobbles +func callGetScrobbles() int32 { + var options TestScrobbleOptions + err := pdk.InputJSON(&options) + + if err != nil { + pdk.SetErrorString("failed to deserialize input " + err.Error()) + return 1 + } + + scrobbles, next, err := host.ScrobbleRetrieverGetScrobbles(options.Username, host.ScrobbleOptions{ + FromTimestamp: options.FromTimestamp, + ToTimestamp: options.ToTimestamp, + MaxItems: options.MaxItems, + Descending: options.Descending, + Offset: options.Offset, + }) + + if err != nil { + pdk.SetErrorString("failed to call scrobble retriever api " + err.Error()) + return 1 + } + + if scrobbles == nil { + scrobbles = []host.ScrobbleRef{} + } + + pdk.OutputJSON(TestScrobblePage{Scrobbles: scrobbles, Next: next}) + return 0 +} + +type TestScrobbleCountOptions struct { + Username string `json:"username"` + FromTimestamp *int64 `json:"fromTimestamp,omitempty"` + ToTimestamp *int64 `json:"toTimestamp,omitempty"` +} + +//go:wasmexport call_get_scrobbles_count +func callGetScrobblesCount() int32 { + var options TestScrobbleOptions + err := pdk.InputJSON(&options) + + if err != nil { + pdk.SetErrorString("failed to deserialize input " + err.Error()) + return 1 + } + + count, err := host.ScrobbleRetrieverGetScrobbleCount(options.Username, host.ScrobbleCountOptions{ + FromTimestamp: options.FromTimestamp, + ToTimestamp: options.ToTimestamp, + }) + + if err != nil { + pdk.SetErrorString("failed to call scrobble retriever api " + err.Error()) + return 1 + } + + pdk.OutputString(strconv.FormatInt(count, 10)) + return 0 +} diff --git a/plugins/testdata/test-scrobble-retriever/manifest.json b/plugins/testdata/test-scrobble-retriever/manifest.json new file mode 100644 index 000000000..a60203704 --- /dev/null +++ b/plugins/testdata/test-scrobble-retriever/manifest.json @@ -0,0 +1,14 @@ +{ + "name": "Test Scrobble Retriever", + "author": "Navidrome Test", + "version": "1.0.0", + "description": "A test plugin for scrobble retriever integration settings", + "permissions": { + "scrobbleRetriever": { + "reason": "For testing scrobble retriever operations" + }, + "users": { + "reason": "Access user information for scrobble retrieval" + } + } +} From 5c5b849a4e6133eb7fc7286007ee2260f0aa3638 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 8 Aug 2026 22:25:48 -0400 Subject: [PATCH 57/71] docs(plugins): sync plugins README with the implementation (#5912) * docs(plugins): document the Matcher host service The Matcher host service (matcher_matchsongs, permission 'matcher') was missing from the README entirely. Add its section with permission example, the matcher-requires-library rule, result ordering/scoping semantics, and a Go usage example. * docs(plugins): add missing nd_scrobbler_playback_report to Scrobbler The Scrobbler capability has four required methods, but the README said three and omitted nd_scrobbler_playback_report from the function table. Add the table row and document the PlaybackReport input payload and its possible playback states. * docs(plugins): remove stale coverartarchive-as example row The AssemblyScript coverartarchive-as example no longer exists in plugins/examples/ (and no AssemblyScript code remains in the repo), so drop its row from the examples table. * docs(plugins): add Command Line Interface section The 'navidrome plugin' CLI (list, info, validate, enable, disable, edit, rescan) was undocumented; the README's only mention was a single inline 'plugin edit --write-access' reference, and it claimed plugins could only be enabled/disabled via the UI. Add a CLI section with all subcommands and the 'plugin edit' flags, and link it from Runtime Management. * docs(plugins): add pdk and types packages to Go PDK table The Go PDK package table was missing two of the packages that ship under plugins/pdk/go/: 'types' (shared DTOs used across capabilities and host services) and 'pdk' (the extism/go-pdk wrapper the README's own examples already import). * docs(plugins): correct capabilities and host services in examples table Several example rows understated what the plugins implement: the scheduler/websocket callback capabilities were omitted for nowplaying-py, library-inspector-rs, crypto-ticker, and discord-rich-presence-rs, and the Discord example also uses the Config host service. * docs(plugins): state that scheduler/taskqueue permissions require callbacks Manifest validation rejects a plugin that declares the scheduler or taskqueue permission without exporting nd_scheduler_callback or nd_task_execute, but the README worded both callbacks as soft suggestions (TaskWorker was even labeled optional). Make the load-time failure explicit in both capability sections. * docs(plugins): document HTTP, WebSocket, and Task limits and defaults Add the runtime limits plugins actually hit: HTTP default 10s timeout, 5-redirect cap with per-hop host re-validation, and 10MB response cap; the 30s timeout on WebSocket callbacks; and the full task_createqueue parameter list (delayMs, retentionMs) with queue defaults, retention bounds, the 1MB payload cap, and task persistence across restarts. * docs(plugins): include scrobbleRetriever in user-scoped authorization note The ScrobbleRetriever host service added in #5795 is user-scoped like subsonicapi and scrobbler, but the Security section's user-scoped authorization item didn't list it. * docs(plugins): clarify matcher user scoping requires the users permission The Matcher usage example set opts.Username while the section's manifest example declares only matcher+library; without the users permission no users can be assigned to the plugin, so userAccess.resolve rejects the call at runtime. Make the example unscoped and state that user scoping requires the users permission with users assigned. Raised by Codex review on PR #5912. --- plugins/README.md | 143 ++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 120 insertions(+), 23 deletions(-) diff --git a/plugins/README.md b/plugins/README.md index adc177324..b04e12bd9 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -34,12 +34,14 @@ The plugin system is built on **[Extism](https://extism.org/)**, a cross-languag - [Task](#task) - [WebSocket](#websocket) - [Library](#library) + - [Matcher](#matcher) - [Artwork](#artwork) - [SubsonicAPI](#subsonicapi) - [Config](#config) - [Users](#users) - [ScrobbleRetriever](#scrobbleretriever) - [Configuration](#configuration) +- [Command Line Interface](#command-line-interface) - [Building Plugins](#building-plugins) - [Examples](#examples) - [Security](#security) @@ -226,13 +228,14 @@ func ndGetArtistBiography() int32 { ### Scrobbler -Integrates with external scrobbling services. All three methods are **required**. +Integrates with external scrobbling services. All four methods are **required**. -| Function | Input | Output | Description | -|------------------------------|-----------------------|--------|-----------------------------| -| `nd_scrobbler_is_authorized` | `{username}` | `bool` | Check if user is authorized | -| `nd_scrobbler_now_playing` | See below | (none) | Send now playing | -| `nd_scrobbler_scrobble` | See below | (none) | Submit a scrobble | +| Function | Input | Output | Description | +|---------------------------------|--------------|--------|------------------------------| +| `nd_scrobbler_is_authorized` | `{username}` | `bool` | Check if user is authorized | +| `nd_scrobbler_now_playing` | See below | (none) | Send now playing | +| `nd_scrobbler_scrobble` | See below | (none) | Submit a scrobble | +| `nd_scrobbler_playback_report` | See below | (none) | Send playback state report | > **Important:** Scrobbler plugins require the `users` permission in their manifest. Scrobble events are only sent for users assigned to the plugin through Navidrome's configuration. @@ -270,6 +273,25 @@ Integrates with external scrobbling services. All three methods are **required** } ``` +**PlaybackReport Input:** + +Same `username` and `track` fields, plus playback state details: + +```json +{ + "username": "john", + "track": { ... }, + "state": "playing", + "positionMs": 45000, + "playbackRate": 1.0, + "playerId": "player-id", + "playerName": "My Client", + "timestamp": 1703270400 +} +``` + +`state` is one of `starting`, `playing`, `paused`, `stopped`, or `expired`. + **Error Handling:** On success, return `0`. On failure, use `pdk.SetError()` with one of these error types: @@ -309,7 +331,7 @@ Each match contains a `song` reference and a `similarity` score (float64, 0.0– ### TaskWorker -Processes tasks from a queue. The method is **optional** — export it if your plugin uses the [Task](#task) host service for background work. +Processes tasks from a queue. **Required** if your plugin uses the [Task](#task) host service: declaring the `taskqueue` permission without exporting this function fails the plugin load. | Function | Input | Output | Description | |---------------------|---------------------------------------------|---------|----------------------| @@ -329,7 +351,7 @@ Useful for initializing connections, scheduling recurring tasks, etc. Errors are ### SchedulerCallback -Receives scheduled task events. **Required** if your plugin uses the [Scheduler](#scheduler) host service. +Receives scheduled task events. **Required** if your plugin uses the [Scheduler](#scheduler) host service: declaring the `scheduler` permission without exporting this function fails the plugin load. | Function | Input | Output | Description | |---------------------------|----------------------------------------------|--------|-----------------------------| @@ -346,6 +368,8 @@ Receives WebSocket events. Export any subset of these to handle events from the | `nd_websocket_on_error` | `{connectionId, error}` | Connection error | | `nd_websocket_on_close` | `{connectionId, code, reason}` | Connection closed | +Each callback invocation is subject to a 30-second timeout. + --- ## Host Services @@ -390,6 +414,8 @@ Make HTTP requests to external services. This is a dedicated host service (separ |-------------|----------------------------------------------------------|----------------------------------| | `http_send` | `method, url, headers, body, timeoutMs, noFollowRedirects` | `statusCode, headers, body` | +**Limits:** Requests time out after 10 seconds by default (override per request with `timeoutMs`). Redirects are followed up to 5 times, re-checking the allowed hosts on every hop. Response bodies are capped at 10MB. + **Usage:** ```go @@ -615,13 +641,21 @@ Background task queue with retry support. Plugins enqueue tasks and process them **Host functions:** -| Function | Parameters | Description | -|---------------------|---------------------------------------------------|----------------------------| -| `task_createqueue` | `name, concurrency, maxRetries, backoffMs, ...` | Create a named task queue | -| `task_enqueue` | `queueName, payload` | Add a task to the queue | -| `task_get` | `taskID` | Get task status and result | -| `task_cancel` | `taskID` | Cancel a pending task | -| `task_clearqueue` | `queueName` | Remove all tasks from queue| +| Function | Parameters | Description | +|---------------------|---------------------------------------------------------------|----------------------------| +| `task_createqueue` | `name, concurrency, maxRetries, backoffMs, delayMs, retentionMs` | Create a named task queue | +| `task_enqueue` | `queueName, payload` | Add a task to the queue | +| `task_get` | `taskID` | Get task status and result | +| `task_cancel` | `taskID` | Cancel a pending task | +| `task_clearqueue` | `queueName` | Remove all tasks from queue| + +Tasks are persisted to SQLite, so pending tasks survive server restarts. Queue behavior: + +- `concurrency` – Parallel workers (default 1), capped by the manifest's `maxConcurrency` +- `maxRetries` – Retries for a failed task (default 0); `backoffMs` (default 1000) doubles on each retry +- `delayMs` – Minimum delay between consecutive task starts, useful for rate limiting (default 0) +- `retentionMs` – How long finished tasks are kept (default 1 hour, min 1 minute, max 1 week) +- Payloads are capped at 1MB **Usage:** @@ -747,6 +781,45 @@ for _, lib := range libraries { } ``` +### Matcher + +Match externally-obtained songs (e.g. results from a recommendation or similarity API) to tracks in the local library, reusing Navidrome's matching algorithm (ID > MBID > ISRC > fuzzy title). + +**Manifest permission:** + +```json +{ + "permissions": { + "matcher": { + "reason": "Resolve external recommendations to library tracks" + }, + "library": { + "reason": "Required by the matcher permission" + } + } +} +``` + +> **Important:** The `matcher` permission requires the `library` permission. + +**Host functions:** + +| Function | Parameters | Returns | +|----------------------|---------------|-------------------------| +| `matcher_matchsongs` | `songs, opts` | Array of matched tracks | + +The result has one entry per input song, in the same order; the entry for a song with no match is empty. Results are limited to the libraries the plugin (and the scoped user, if any) can access. Set `opts.username` to run the match as a specific user: their favorites and ratings inform tiebreaking, and the returned tracks carry their annotations. User scoping additionally requires the [`users`](#users) permission, with users assigned to the plugin. + +**Usage:** + +```go +import "github.com/navidrome/navidrome/plugins/pdk/go/types" + +matches, err := host.MatcherMatchSongs([]types.SongRef{ + {Name: "Song Title", Artists: []types.ArtistRef{{Name: "Artist Name"}}}, +}, host.MatchOptions{}) +``` + ### Artwork Generate public URLs for Navidrome artwork (albums, artists, tracks, playlists). @@ -1008,6 +1081,29 @@ For more advanced access (listing keys, integer values), use the [Config](#confi --- +## Command Line Interface + +Manage plugins from the command line with `navidrome plugin`: + +| Command | Description | +|--------------------------------------------------------|------------------------------------------------------------| +| `navidrome plugin list [-f table\|csv\|json]` | List installed plugins | +| `navidrome plugin info [-f text\|json]` | Show details for an installed plugin or a `.ndp` package | +| `navidrome plugin validate ` | Validate an installed plugin or a `.ndp` package manifest | +| `navidrome plugin enable ` | Enable a plugin | +| `navidrome plugin disable ` | Disable a plugin | +| `navidrome plugin edit ` | Update a plugin's config and/or permissions | +| `navidrome plugin rescan` | Re-discover plugins in the plugins folder | + +**`plugin edit` flags:** + +- `--config ` / `--config-file ` – Set the plugin configuration (`-` reads from stdin) +- `--users ` / `--all-users` – Usernames the plugin may access (comma-separated or JSON array), or all users +- `--libraries ` / `--all-libraries` – Library IDs the plugin may access (comma-separated or JSON array), or all libraries +- `--write-access` / `--no-write-access` – Allow or deny the plugin write access to libraries + +--- + ## Building Plugins ### Supported Languages @@ -1070,6 +1166,8 @@ replace github.com/navidrome/navidrome => ../../.. | `scheduler` | `plugins/pdk/go/scheduler` | Scheduled task callbacks | | `websocket` | `plugins/pdk/go/websocket` | WebSocket event handlers | | `host` | `plugins/pdk/go/host` | Host service SDK (all services) | +| `types` | `plugins/pdk/go/types` | Shared data types (tracks, artists, song refs) | +| `pdk` | `plugins/pdk/go/pdk` | Low-level helpers (wraps extism/go-pdk: config, logging, memory) | See the example plugins in [examples/](examples/) for complete usage patterns. @@ -1168,12 +1266,11 @@ See [examples/](examples/) for complete working plugins: | [minimal](examples/minimal/) | Go | MetadataAgent | – | Basic structure example | | [wikimedia](examples/wikimedia/) | Go | MetadataAgent | HTTP | Wikidata/Wikipedia integration | | [coverartarchive-py](examples/coverartarchive-py/) | Python | MetadataAgent | HTTP | Cover Art Archive | -| [coverartarchive-as](examples/coverartarchive-as/) | AssemblyScript | MetadataAgent | HTTP | Cover Art Archive | -| [webhook-rs](examples/webhook-rs/) | Rust | Scrobbler | HTTP | HTTP webhooks | -| [nowplaying-py](examples/nowplaying-py/) | Python | Lifecycle | Scheduler, SubsonicAPI | Periodic now-playing logger | -| [library-inspector-rs](examples/library-inspector-rs/) | Rust | Lifecycle | Library, Scheduler | Periodic library stats logging | -| [crypto-ticker](examples/crypto-ticker/) | Go | Lifecycle | WebSocket, Scheduler | Real-time crypto prices demo | -| [discord-rich-presence-rs](examples/discord-rich-presence-rs/) | Rust | Scrobbler | HTTP, WebSocket, Cache, Scheduler, Artwork | Discord integration | +| [webhook-rs](examples/webhook-rs/) | Rust | Scrobbler | HTTP | HTTP webhooks | +| [nowplaying-py](examples/nowplaying-py/) | Python | Lifecycle, SchedulerCallback | Scheduler, SubsonicAPI | Periodic now-playing logger | +| [library-inspector-rs](examples/library-inspector-rs/) | Rust | Lifecycle, SchedulerCallback | Library, Scheduler | Periodic library stats logging | +| [crypto-ticker](examples/crypto-ticker/) | Go | Lifecycle, SchedulerCallback, WebSocketCallback | WebSocket, Scheduler | Real-time crypto prices demo | +| [discord-rich-presence-rs](examples/discord-rich-presence-rs/) | Rust | Scrobbler, SchedulerCallback, WebSocketCallback | HTTP, WebSocket, Cache, Scheduler, Artwork, Config | Discord integration | --- @@ -1186,7 +1283,7 @@ Plugins run in a secure WebAssembly sandbox provided by [Extism](https://extism. 3. **No Network Listeners** – Plugins cannot bind ports 4. **Config Isolation** – Plugins only receive their own config section 5. **Memory Limits** – Controlled by the WebAssembly runtime -6. **User-Scoped Authorization** – Plugins with `subsonicapi` or `scrobbler` capabilities can only access/receive events for users assigned to them through Navidrome's configuration +6. **User-Scoped Authorization** – Plugins with `subsonicapi`, `scrobbleRetriever`, or `scrobbler` capabilities can only access/receive events for users assigned to them through Navidrome's configuration 7. **Users Permission** – Plugins requesting user access must be explicitly configured with allowed users; sensitive data (passwords, emails) is never exposed --- @@ -1201,7 +1298,7 @@ If `AutoReload` is disabled, Navidrome needs to be restarted to pick up plugin c ### Enabling/Disabling Plugins -Plugins can be enabled/disabled via the Navidrome UI. The plugin state is persisted in the database. +Plugins can be enabled/disabled via the Navidrome UI or the [`navidrome plugin` CLI](#command-line-interface). The plugin state is persisted in the database. ### Important Notes From 944ca3100fce669e4bd63be09bdd158ab59faa3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 9 Aug 2026 15:03:27 -0400 Subject: [PATCH 58/71] feat(artwork): new artwork pipeline with background resolution and Low Quality Image Placeholders (#5847) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(artwork): add artwork, item_artwork and artwork_queue tables * feat(artwork): add artwork models, repository interfaces and mocks * feat(artwork): implement artwork repository * feat(artwork): implement item_artwork repository with batched hydration * feat(artwork): implement artwork_queue repository * feat(artwork): add content-addressed originals store * feat(artwork): add artwork prune (orphan cleanup) * fix(artwork): never sweep files on transient DB errors during prune * refactor(artwork): fold originals package into core/artwork as ImageStore * refactor(artwork): merge item artwork state into ArtworkRepository * fix(artwork): chunk unbounded IN clauses and restore interface docs * refactor(artwork): apply simplify-pass cleanups Internal item_artwork sqlRepository helper, toSQLArgs upserts, batched queue enqueue, EnqueueStaleAbsent moved to queue repo, snapshot-based prune sweep, mock/real semantics aligned. * fix(artwork): address review findings on prune/sweep races and mock fidelity Sweep now honors an mtime grace window (in-flight acquisitions and temp files), reacquired orphans reset the prune grace window, and the queue mock implements real stale-absent semantics. * fix(artwork): atomic orphan deletion and timestamp semantics from review DeleteOrphans re-checks age+references at delete time, PutItemArtwork defaults attempted_at, queue mock timestamps mirror SQL. * fix(artwork): guard orphan file removal with the prune grace window Duplicate ImageStore writes refresh the file mtime and Remove skips files newer than the cutoff, so overlapping acquisitions cannot lose their store files to a concurrent prune. * fix(artwork): rewrite vanished duplicates and sweep stale mime variants Write falls through to a real write when the liveness touch fails, and sweep retention now matches the recorded mime's extension so obsolete variants are reclaimed. * fix(artwork): index artwork_queue in dequeue order The previous leading retry_at range column forced a temp B-tree sort of the whole eligible set on every DequeueBatch; ordering the index by (priority DESC, enqueued_at) lets scans stop after the batch size. * fix(artwork): honor the orphan cutoff in the repository mock The mock's DeleteOrphans now applies createdBefore like the SQL implementation, and a new spec covers a freshly reacquired row surviving prune. * fix(artwork): reject malformed hashes in ImageStore operations Known-absent states carry an empty hash and malformed persisted hashes could panic path sharding or inject separators; Write/Open/Remove now return an error for anything but 16 lowercase hex chars. * fix(artwork): mock PutImage refreshes created_at like the SQL repository Prune specs now age fixtures directly instead of seeding stale timestamps through the upsert. * fix(artwork): store backing-file provenance per item, not per hash * feat(artwork): import blurhash encoder from #5797 * feat(artwork): add worker-side artwork resolvers * fix(artwork): propagate playlist tile failures and dedupe external step * feat(artwork): add acquisition processor Resolves one queue item end to end: hash/dedup, decode + 128px thumbnail blurhash, place bytes (store vs source file), and persist found/absent/ failed state for the worker (Task 4) to act on. * style(artwork): tighten processor comments to budget * feat(artwork): add acquisition worker service * feat(artwork): enqueue artwork resolution from scan and CRUD paths * feat(artwork): artwork backfill, fingerprint re-resolution and scheduled jobs * test(artwork): leak/soak coverage and deferred assertions * fix(artwork): propagate transient artist image errors to the worker callGetImage swallowed all agent errors, so an agent outage surfaced as ErrNotFound and the worker settled artist artwork as a definitive absent (and reset the breaker). Add an additive ArtistImageResult path that returns the underlying agent error on transient failure while keeping ArtistImage byte-identical for existing callers; the worker's artist external step uses it via fromArtistExternalResult. * fix(artwork): resolve full playlist source chain resolvePlaylist only built the generated grid, dropping the uploaded-image, sidecar and ExternalImageURL sources the old reader_playlist.go chain serves. Port the full chain before the grid fallback: uploaded (upload), sidecar (folder), and ExternalImageURL routed through extGate with the same extError semantics as the other external steps. Also rewires the artist external step onto ArtistImageResult. * fix(artwork): purge dangling queue rows and guard concurrent re-enqueues Queue rows for deleted entities failed forever (Get -> ErrNotFound -> failed -> capped retries, unbounded). Add ArtworkQueueRepository.PurgeDangling, called from Prune next to the item_artwork purge. Separately, the found/absent path unconditionally deleted the dequeued row, erasing a concurrent scan re-enqueue; switch to DeleteIfUnchanged, which deletes only while retry_at still matches the dequeued value (verified retry_at is the column an Enqueue upsert resets). * style(artwork): fix comment accuracy and budget; fingerprint ArtistImageFolder Correct the inverted workerDeps.extGate comment, trim over-budget doc comments, and add conf.Server.ArtistImageFolder to the resolution fingerprint so an image-folder change re-resolves artist artwork. * fix(artwork): treat missing local playlist cover as definitive, not transient A playlist ExternalImageURL pointing at a local file that fails to open was routed through extError, causing failed/48h-retry loops that burn a rate limiter token forever instead of falling through to the generated grid. * refactor(artwork): deduplicate purge loop, backfill table, and extGate alias * fix(artwork): cap resolved image reads A user-editable ExternalImageURL can point at an arbitrarily large endpoint; a fast server could make the worker buffer hundreds of MB inside the 5s HTTP timeout. Bound the read to a fixed 20MB cap (no config knob) via io.LimitReader and fail the item if it is exceeded. * fix(artwork): retry higher-priority external art after fallback hit With CoverArtPriority="external,cover.jpg", a transient external failure followed by a folder hit dropped the external error: the worker recorded found and deleted the queue row, so the configured higher-priority external art was never retried. Carry extError onto the fallback resolution and add an outcomeFoundStale that persists+serves the art but reschedules via MarkFailed, giving the external source another chance. When external later answers definitively-not-found, the hit is not stale and the row is deleted. * fix(artwork): treat playlist cover URL 404 as definitive miss The playlist ExternalImageURL step used sources.go's fromURL, which maps any non-200 to a generic error, so a stale URL returning 404/410 was classified transient: infinite backoff plus it counted toward the circuit breaker, blocking valid external work. Add a local fetch in resolve.go that maps 404/410 to model.ErrNotFound (definitive) while keeping other non-200s transient. sources.go is left untouched. * test(artwork): move soak test into the Ginkgo suite * test(artwork): make leak and permission tests pass on linux goleak now ignores notify's nonrecursive-tree goroutines (linux uses inotify, which spawns dispatch+internal instead of darwin's recursive dispatch), and the read-only-dir prune spec skips under root, where permission bits cannot make Remove fail. * fix(artwork): reject decompression-bomb dimensions before decoding * fix(artwork): keep fresh re-enqueues ahead of stale failure backoff * fix(artwork): include M3U external art flag in the config fingerprint * fix(artwork): resolve private playlists with an admin context * test(artwork): convert non-synctest timing tests to Ginkgo specs TestArtworkBackoffSchedule and TestArtworkWorkerRunNoLeak needed no real *testing.T (no synctest), so move them into worker_test.go as Ginkgo specs. TestArtworkBreakerHalfOpen stays plain since testing/synctest requires a real *testing.T, matching core/scrobbler's precedent. * fix(artwork): store backing-file provenance per item, not per hash * fix(artwork): apply image limits to playlist tile decoding decodeTile ran image.Decode on every sampled album's resolved bytes before processItem's maxImageBytes/maxImagePixels guards applied, letting an oversized or decompression-bomb tile fully decode unbounded. Enforce both caps inside decodeTile itself. * refactor(artwork): reuse auth.WithAdminUser and dedupe image cap guards * perf(artwork): fetch only IDs for backfill enumeration Backfill enumerated every album, artist, playlist and radio via GetAll and mapped out just the ID. GetAll materializes full entities (library joins, participant/stats/tags JSON, annotation, artwork hydration), so on a large library it loaded tens of thousands of heavy structs only to read one field each — spiking transient RSS to ~1GB during the one-time upgrade backfill, a memory risk on small NAS/Pi hardware. Add GetAllIDs to the album, artist, playlist and radio repositories: it reuses each repo's base row-set filter (library visibility, artist content join, playlist userFilter) but projects only id, skipping the heavy columns and post-processing. A per-repo parity test asserts GetAllIDs returns exactly the same id set as GetAll. Verified on a 727MB / 29k-artist production DB copy: peak RSS during backfill dropped from ~1012MB to ~89MB, file descriptors flat, same 36,138 items enqueued. * feat(artwork): promote worker concurrency and external rate to real configs The artwork worker's drain speed was governed by two hidden Dev flags, DevArtworkWorkerConcurrency and DevArtworkExternalRPS, both defaulting to 2. On a large library's one-time backfill the external rate limiter is the real ceiling: every art-less item waits on it before the (rate-limited) external lookup, so the drain crawls at ~RPS items/sec while local-art items are unaffected. Promote both to documented, supported options: ArtworkWorkerConcurrency (default 4) sets local-resolution parallelism, ArtworkExternalMaxRPS (default 2, 0 = unlimited) caps external-agent lookups to stay polite to Last.fm/Deezer/etc. Operators can now trade first-backfill speed against external-API rate limits. The old Dev names still map for backward compat. * fix(deezer): never return empty-image-id placeholder pictures * feat(agents): enumerate enabled image-retriever agents per capability * feat(artwork): worker fetches agent images directly with per-agent rate limits and breakers * fix(artwork): treat agent not-found as breaker success * feat(model): content-hash artwork id suffix and hydratable per-entity image state * feat(persistence): hydrate artwork hash and absence onto entity pages * feat(artwork): resolve media_file embedded art in the worker, invalidate on rescan * feat(artwork): broadcast refresh events when artwork lands * fix(artwork): broadcast refresh for stale-found artwork too * feat(artwork): state-backed serving path with provisional read-through * feat(server): serve artwork from persisted state with content-hash caching * feat(subsonic): content-hash coverArt ids, omit artwork on known-absent * refactor(artwork): delete the legacy reader chain, cache warmer, and provider image methods * feat(artwork): precache on acquisition, bump on upload/radio changes, manual re-resolve API * test(artwork): end-to-end coverage for the serving cutover * chore(artwork): generic 500 bodies on refresh endpoint, trim stale test comments * fix(artwork): request read-through must not reset the failure backoff The provisional read-through and dangling re-enqueue used Enqueue, whose upsert resets retry_at, so any browse of an unresolved entity that was backing off after an external failure made it immediately eligible again — defeating the exponential backoff during a provider outage. Add EnqueueBump, which raises priority but leaves an existing row's retry_at intact, and route the serving path through it. Scan and manual re-resolve keep Enqueue's reset (a detected change wants immediate retry). * fix(artwork): keep an eligible track's cover requestable when its album is absent An embedded-eligible track with no resolved item_artwork row inherited the album's ImageAbsent, so when the album resolved absent (e.g. CoverArtPriority without 'embedded') the track's coverArt was omitted permanently — the client never requested it, so the lazy mediafile path never resolved it — even though the serving path would extract and serve the track's own embedded art. Hydration now never copies the album's absence onto an eligible-but-unresolved track. * fix(artwork): validate each agent image URL before picking the largest bestImageURL selected the largest by size and only then parsed it, so a malformed largest URL (e.g. a bad percent-escape) returned nil and shadowed a valid smaller candidate, contradicting the documented skip-unparseable behavior. Parse per candidate and compare sizes only among URLs that parse. * fix(artwork): fall back to disc art, not the album, for multi-disc tracks serveMediaFile delegated an absent/ineligible track straight to AlbumCoverArtID, skipping the disc-specific lookup that MediaFile.CoverArtID (and the deleted legacy reader) use. On multi-disc albums with per-disc images that served the album cover instead of the configured disc artwork. Delegate through DiscCoverArtID. * fix(artwork): enqueue uploaded artwork only after the filename is persisted SetImage cleared state and enqueued the bump before the caller stored the new filename, so a worker drain in that window could resolve against the old (already deleted) file and settle absent, leaving the upload unused until a later scan. Move the invalidate+enqueue into EnqueueArtwork, which each caller now invokes after the entity Put. * fix(artwork): keep multi-disc tracks requestable when the album is absent Round-1's hydration fix still copied the album's known-absent onto a non-eligible (or own-absent) track, but MediaFile.CoverArtID routes a multi-disc track to disc art, which resolves provisionally and is never known-absent. Marking it absent made Subsonic omit coverArt so clients never requested a valid disc image. Only mark a single-disc track absent, and only when its own art won't resolve. * fix(artwork): serve a local playlist ExternalImageURL as a file-backed reference A local ExternalImageURL was resolved through the external step and labelled external, so placeBytes copied it into the content-addressed store and dropped its path/mtime — replacing the file never tripped the staleness check. Classify local references as file-backed (resolved in place, even on the request path) and keep store-backed behaviour only for http(s) URLs. * fix(artwork): requeue playlist cover when its track set changes A generated-grid cover went stale after track mutations: nothing re-resolved the playlist's artwork, and the request path deliberately never rebuilds the grid, so serveEntity kept returning the old grid hash indefinitely. Enqueue pl artwork from refreshCounters (the choke point for every track-set change); no clear, so the old cover keeps serving until the worker rebuilds. * fix(artwork): open library-backed artwork through its on-disk root A library configured with a file:// path stored absRoot as the raw URI, so Abs produced strings like file:/music/cover.jpg that os.Open/os.Stat reject — folder, upload and embedded art were treated as dangling on every request, looping forever. Normalize a file:// path to its parsed OS path (the same root os.DirFS uses); non-local schemes are left unchanged (out of scope, per the artwork-musicfs TODO). * fix(artwork): only use disc resolution for multi-disc albums DiscCoverArtID returns a dc- id for any track with DiscNumber>0, so serveDisc ran the full DiscArtPriority chain even for single-disc albums, where a stray disc*/ embedded image could shadow higher-priority album art. Gate disc resolution on the album having more than one disc, matching the legacy reader; single-disc tracks serve album art directly. * fix(artwork): invalidate artwork when an uploaded image is deleted Deleting an artist/radio/playlist upload cleared the filename but left the found item_artwork row and its hash, so lists kept advertising the deleted cover's hash-suffixed immutable URL and clients could display it indefinitely. Call EnqueueArtwork after the delete-side Put, symmetric with upload, so the state is cleared and re-resolved to the next source (or absent). * fix(artwork): restore synthetic-artist guard and unicode normalization in agent lookups Moving agent calls into the worker bypassed two behaviors of the aggregate provider: Agents.GetArtistImages' guard for Unknown/Various Artists (a direct retriever call could assign an unrelated image to a synthetic artist), and auxAlbum/auxArtist.Name's DevPreserveUnicodeInExternalCalls normalization (records with typographic quotes/dashes missed exact-name searches). Re-apply both before enumerating retrievers. * fix(artwork): enforce entity visibility on the Subsonic getCoverArt path serveEntity reads persisted item_artwork by id, bypassing the library and private- playlist filters that the legacy entity-load applied. On the authenticated Subsonic path a user could fetch artwork for an inaccessible album or someone else's private playlist by guessing an id. getCoverArt now resolves the underlying entity through the request-scoped (filtered) repositories and serves the placeholder when it is not visible, so existence isn't leaked and the always-an-image invariant holds. The public share (JWT-authorized) and Jellyfin (admin) paths are intentionally untouched. * fix(artwork): version the artwork ETag with the served representation The ETag was the pixel hash of the original image, so a CoverArtQuality or EnableWebPEncoding change altered the resized bytes without changing the ETag — revalidating clients got a spurious 304 and kept the old encoding. Resized responses now carry a representation ETag (hash + size + square + encode settings) used for the ETag header and If-None-Match, while the immutable decision stays on the pixel hash (URLs remain pixel-identity per the spec, so hash-suffixed clients keep zero-request caching). Full-size originals fall back to the pixel hash as before. * fix(artwork): don't stamp the album hash onto multi-disc tracks The hydration fallback assigned a found album hash to every fallback track, but a multi-disc track's CoverArtID emits a dc- id served from disc-specific art whose hash is unknown at hydration time. Advertising dc-..._ gave clients a content- version that never changes when the disc image does, breaking id-based refresh. Only stamp the album hash for single-disc tracks (DiscNumber == 0); multi-disc tracks stay unhashed and rely on the correct ETag returned by the served response. * fix(artwork): enqueue new empty playlists by id, and refresh on absent outcomes Two worker/enqueue fixes from review: - playlistRepository.Put assigned the generated id to the caller's Playlist but passed the stale copy (empty id) to refreshCounters, enqueueing a pl|"" row the worker failed until the daily dangling purge while the real playlist went unresolved. Set the id on the copy before enqueueing. - The drain refresh batch only included found/foundStale, so a cover removed by a scan (found -> absent) never notified clients, leaving the old immutable image displayed. Broadcast absent outcomes too; precache still only warms found/foundStale. * fix(artwork): honor disabled per-track art at serve time; use nanosecond mtime provenance Two serving-correctness fixes from review: - serveMediaFile served a persisted mf embedded image even after EnableMediaFileCoverArt was turned off (the setting isn't in the config fingerprint, so found rows aren't reprocessed). Direct mf- URLs now honor the setting at serve time and fall back to disc/album art. - The file-backed staleness check compared whole-second mtimes, so a same-second content replacement (two writes in one second, or timestamp-preserving tools) could serve different bytes under the old hash + immutable policy. RefMtime is now unix-nanoseconds (no schema change; int64 column), detecting sub-second changes where the filesystem records them. * fix(artwork): preserve the drive when normalizing Windows file:// library paths url.Parse puts the volume of file://C:/Music in Host, not Path, so localOSRoot dropped it and returned /Music — os.Open/os.Stat then failed and folder/embedded art on Windows looped as dangling. Rejoin the host volume, matching core/storage/local's newLocalStorage. * fix(artwork): clamp negative sizes to full-size; convert imghttp test to Ginkgo - A negative size (Subsonic size / Jellyfin maxwidth accept signed ints) reached resizeStaticImage, where the square path builds image.NewNRGBA(Rect(0,0,size,size)) — a giant rectangle that panics/OOMs. Clamp size<0 to 0 (full-size) at the Service entry. Positive sizes were already clamped to the original. - imghttp used a plain func Test with a table; convert to a Ginkgo DescribeTable with the suite entry point in imghttp_suite_test.go (AGENTS.md test-framework requirement). * test(artwork): use renamed ArtworkWorkerConcurrency in e2e tests * feat(artwork): carry blurhash through item image hydration * feat(nativeapi): expose artwork hash, absence and blurhash * feat(artwork): hydrate the parent album's artwork state onto tracks * test(artwork): add hydrateArtwork regression guard for AlbumImage wiring Drives hydrateArtwork itself (not applyItemImage directly) over tracks that take each of the loop's continue branches, so a future edit moving the AlbumImage fill below a continue would fail loudly instead of passing silently. * feat(jellyfin): version album and artist image tags by content hash * fix(jellyfin): trim primaryImageTag comment to why-only, within budget * feat(jellyfin): emit real blurhashes and drop the synthesized fallback * feat(ui): version cover art urls by content hash and skip absent art * feat(ui): add BlurHashCanvas placeholder component * fix(ui): clear stale blurhash pixels and assert the draw path in tests Clear the canvas before each decode attempt so a hash change that fails to decode doesn't leave the previous frame's pixels on screen once this wires into a list that recycles items. Also strengthen the specs to assert createImageData/putImageData were actually invoked (and with what), instead of only checking that a element exists. * feat(ui): show the blurhash while an album cover loads * fix(artwork): hydrate cursor streams via an id pre-pass The album, artist and playlist GetCursor built their own select and never called hydrateArtwork, so every Jellyfin list endpoint (all six stream via GetCursor) emitted entity-id image tags and no blurhash. Only GetAll hydrated, which is why Subsonic and the native API were unaffected. Each cursor now resolves its ordered/filtered/paginated id set with the cheap id-only GetAllIDs query, then streams those ids in chunks through the repo's existing GetAll, which already hydrates and applies the full select. Max/Offset are consumed by the pre-pass alone; the chunk query carries only the caller's filters, Sort and Order. This also removes a pre-existing deep-pagination cost: keeping OFFSET out of the joined query makes the pre-pass a covering index scan instead of paying the library and annotation joins for every skipped row. Benchmarked on a synthetic 100k-album DB with the real schema, page=500 at offset 90,000: 3.9ms via the id pre-pass, 52.5ms for the current shape, 192.7ms for a naive join. An unpaginated full stream costs ~24% more, which is the trade. GetAllIDs gains the annotation join whenever the caller's filters or sort reference an annotation column (same gate CountAll uses), otherwise Filters=IsFavorite and SortBy=PlayCount would fail in the pre-pass. The playlist pre-pass repeats GetAll's columns so ORDER BY keeps resolving to playlist.name rather than the joined user.name. * fix(jellyfin): hydrate artwork on the song cursor Jellyfin's listSongs streamed media files via GetCursor, which never hydrates artwork, so songs emitted entity-id image tags and no blurhash. media_file now uses the same id pre-pass as the other three cursors (album/artist/playlist), for consistency, but on a separate method, GetCursorWithArtwork: GetCursor itself must stay untouched, since it's also the scanner's hot path and the scanner never reads artwork. Measured on 1,000,000 tracks, the pre-pass over all ids costs +41.8 MB heap and +298 ms versus GetCursor's bounded +0.0 MB. The Jellyfin path is paginated, though, so in practice it only ever pre-passes a page's worth of ids, not the full library, and doesn't pay that cost. * feat(jellyfin): emit a song's own cover art when it differs from the album's Real Jellyfin fills ImageTags from each item's own images before falling back to the parent album, and Finamp checks imageTags.Primary before AlbumId. Our mapper read only the album's image, so a track with distinct embedded art silently showed the album cover. Emit exactly one entry under ImageBlurHashes.Primary: Go marshals map[string]string in sorted key order rather than insertion order, so a second entry could pair the wrong blurhash with the image imageId resolves to, and Finamp pins that pairing in its cache for 365 days. * test(persistence): scope the GetCursorWithArtwork full-stream spec to tie-free ids The fixture has title ties (e.g. three "Antenna" tracks), so the unscoped positional comparison against GetAll only passed because SQLite's tie order happened to coincide between the full scan and the pre-pass's id IN (...) fetch. Scope it to onlySongs like the sibling ordering specs already do. * refactor(artwork): route song own-art through primaryImageTag; align chunk size Cleanups surfaced by /simplify: the song mapper's own-art branch reimplemented primaryImageTag's tag+blurhash-map construction (and its one-entry invariant) — route it through the helper so that invariant lives in one place. Tie artworkChunkSize to a whole multiple of artworkBatchSize so a cursor page re-chunks into even hydration batches. Hoist a duplicated imageLoading && blurHash boolean in the album grid. * fix(ui): serve the placeholder for known-absent art instead of a broken icon getCoverArtUrl returned '' for an imageAbsent record, so rendered as the browser's broken-image icon on every absent cover. The server already serves a proper placeholder for absent art, so build the url and let it render. * feat(ui): show the blurhash as the loading placeholder across cover surfaces Add a shared CoverImage component (useImageUrl blob cache + blurhash + fade) and render the blurhash while a cover loads on the list thumbnails (CoverArtAvatar, radio) and the artist/album/playlist detail pages. The detail pages now go through CoverImage instead of a plain CardMedia, so their images come from the in-memory blob cache and survive React remounts without re-fetching. BlurHashCanvas gains an optional style prop. * refactor(ui): unify list cover surfaces onto the shared CoverImage component Route the album grid, CoverArtAvatar (artist/playlist lists) and the radio list's cover field through CoverImage instead of each carrying its own useImageUrl + blurhash-overlay wiring. CoverImage gains a default object-fit: cover. Radio keeps its uploaded-image gate and the generic radio placeholder for stations with no art. * fix(ui): address CoverImage review findings Restructure CoverImage so the size/shape lives on the root and the blurhash + image are absolute fills: the mounts only once its blob is ready, so an unresolved cover never flashes a broken . Add a fit prop (default cover) so album/playlist detail keep their letterbox instead of being cropped by a hardcoded object-fit. Remove the orphaned coverLoading styles and an unused subsonic import; add a CoverImage unit test. * perf(ui): only refetch already-loaded records on SSE refresh The artwork worker broadcasts a RefreshResource event per resolved chunk, carrying every id in the chunk. useResourceRefresh was doing a getMany for all of them, so any open list/detail page fetched hundreds of artists it was not displaying. Filter the event ids to records already in the store; the rest load fresh (with their new artwork) when navigated to. * refactor(artwork): scale worker concurrency with CPU count ArtworkWorkerConcurrency now defaults to max(2, NumCPU()/2) instead of a fixed 4, mirroring MaxOpenConns: local resolution scales with the host but stays at half the SQLite pool so it never starves the scanner/UI. External RPS stays a fixed 2 — it gates third-party API calls and is bounded by their tolerance, not the host, so it must not scale with CPUs. Also drop the DevArtworkWorkerConcurrency/DevArtworkExternalRPS deprecated aliases: those names were never released, so there is nothing to migrate. * feat(artwork): re-queue an absent cover when its page is viewed serveEntity now schedules a Bump recheck for an entity whose art was recorded absent, so viewing a missing cover re-triggers resolution (e.g. after an external source that was down during the scan comes back), matching the request-time bump that already covers never-resolved entities. Throttled by attempted_at against requestRecheckAge (1h) so repeatedly opening a genuinely-absent page can't hammer external services. EnqueueBump preserves an existing failed-state backoff via MAX(priority,...) and inserts a fresh, immediately-eligible recheck for a settled-absent row (whose queue row was already deleted). * refactor(artwork): move ImageUploadService to artwork.Uploader Relocate the image-upload service from core to core/artwork as artwork.Uploader, co-locating it with the resolver/worker/serving that own the artwork state it invalidates. MaxImageUploadSize moves too — its only callers are the two image-upload handlers — which lets core/image_upload.go be deleted entirely. Extract the shared "clear resolved state + re-queue at Bump" invalidation into artwork.Refresh and fold nativeapi's refreshArtwork handler onto it, removing the duplicated DeleteForItem+Enqueue block that had drifted into three places. The wire provider moves from core's set to artwork's; the playlists.ImageUploadService binding moves to the top-level injector so core/artwork stays unaware of playlists. Behavior is unchanged. * refactor(artwork): thread model.Kind through the artwork API Entity-level artwork queries now take a typed model.Kind instead of a bare prefix string. GetItemArtwork, DeleteForItem(s), GetInfoForItems, EnqueueStaleAbsent, hydrateItemImages, enqueueBackfillKind and artwork.Refresh convert to the prefix string only at the two real boundaries: the SQL item_kind column (kind.Prefix() inside each repo) and external string inputs (a new model.ParseKind for the nativeapi URL param, which also validates it). The Backfill/stale-absent kind slices, the resolve.go dispatch switch, and the kind→resource / kind→table lookup maps now use the Kind vars directly. The queue lifecycle methods (MarkFailed/Delete*) keep string kinds — they operate on a dequeued item's raw ItemKind column, which stays a string field, always populated via kind.Prefix(). Removes every bare "al"/"ar"/… prefix literal from non-test code (27 -> 0); behavior is unchanged. * tune(artwork): drop backoff base from 5m to 15s The exponential retry (base × 4^attempts, cap 48h) started at 5 minutes, so a single transient failure — a timeout under load, an external blip — parked a cover for 5 minutes even though a retry seconds later would have resolved it. Start at 15s instead: transient failures recover almost immediately (15s → 1m → 4m → 16m …), while persistent failures still escalate to the 48h cap (now at the 8th attempt instead of the 5th). * tune(artwork): 5s backoff base + 12h give-up, drop the cap Retry backoff now starts at 5s (was 15s) so a transient failure recovers on essentially the next drain, and jitter widens to ±40% so a wave of correlated failures doesn't re-clump into one poll. Add a 12h give-up budget measured from enqueued_at: once the next backoff would land past it, the worker stops retrying instead of grinding at a cap forever. A bare failure settles absent (handed to the 24h stale-absent sweep, and still recoverable on a page view); a found-stale keeps its already-served art. The budget bounds the tail, so the separate 48h backoffCap is removed. * fix(lastfm): match album.getInfo on name+artist only, not MBID Last.fm's album.getInfo by MBID is unreliable: a correct MBID can return a different album, or none. Observed with black midi's "7-eleven" (whose correct MBID returned a FLEETWOOD release) and both missing The Chats albums (one MBID 404s, the other resolves to a different self-titled release). The worker then recorded covers absent — or would fetch the wrong art — even though the correct cover is on Last.fm by name+artist. Stop passing the MBID to album.getInfo; query by name+artist only, which also drops the now-dead error-6 MBID-retry fallback. The low-level client keeps its MBID support for other callers; only the album lookup changes. * fix(lastfm): return agents.ErrNotFound on error 6 (not found) Last.fm returns error 6 for a missing artist/album — a definitive negative — but the agent returned the raw *lastFMError, so the artwork worker treated every not-found as a real fault: it counted toward the per-source circuit breaker (5 in a row opens it, fast-failing all Last.fm calls including valid ones) and was retried as a transient error instead of settling absent. On a first scan of a library with many artists Last.fm lacks, this stalled valid cover lookups and left entities churning in backoff. Translate error 6 to the shared agents.ErrNotFound at the agent boundary (callAlbumGetInfo / callArtistGetInfo), matching how the Deezer agent maps its client's not-found, and log it at Debug instead of Error — which also removes the not-found log spam. * feat(artwork): log external image-lookup failures at debug The worker's res.reader==nil && extError branch returned outcomeFailed with no log, so a failing external cover lookup (agent error, dead image URL, download timeout) was undiagnosable. Log the agent, entity, and underlying error at the fetch site where it's in hand — this surfaced a Last.fm album.getInfo returning an image URL that itself 404s. * fix(artwork): treat a 404/410 image URL as not-found, not a transient fault An agent (notably Last.fm's album.getInfo) can advertise a cover URL that is itself dead — a 404. sources.go's fromURL returned a generic error for any non-200, so a dead URL was treated as a transient failure: it churned in backoff and counted toward the circuit breaker, stalling valid lookups. Map 404/410 to model.ErrNotFound in fromURL so a dead URL settles absent, and collapse the near-identical fetchPlaylistImageURL (which already did this for M3U covers) into it. * feat(artwork): make artwork re-resolution targeted, not blunt Two gaps in when the pipeline re-resolves artwork: The recheck job only requeued absent-state rows (hash=''), so an entity that was never processed — added between scans, or on a server with the scanner disabled — had no periodic safety net and stayed without artwork indefinitely. Add EnqueueMissing(kind): a SQL set-difference enqueueing entities with no item_artwork row at Recheck priority (ON CONFLICT DO NOTHING, so it never disturbs a queued row). Run it once at startup and hourly alongside the stale-absent recheck. Rename staleAbsentKinds -> recheckKinds accordingly. Conversely, the config fingerprint included consts.Version, which embeds the git SHA and so changed on every build, re-enqueueing every entity in the library (~34k here) and re-querying external agents at the configured RPS for anything without local art. Replace it with an explicit artworkEpoch constant, bumped deliberately when resolution semantics change. The cases that motivated the version input — absent art becoming available — are already covered by the stale-absent and missing-row rechecks; only a corrected wrong-pick needs the epoch. A test guards against reintroducing the version. * test(artwork): restore resolution edge-case e2e coverage The serving cutover removed the album/disc/artist/mediafile/playlist/radio e2e specs that documented the folder-selection rules and guarded the #5376/#5456/ #5451/#5457 regressions; nothing replaced them, so compareImageFiles and the parent-fallback logic were left untested. Restore them driving the real pipeline: a real scanner populates the folder graph from an in-memory library, the real Worker drains the queue, and the real Service serves. Folder-backed art is file-backed (served via os.Open, which the in-memory FS can't satisfy) so its selection is asserted on the persisted state row; store-backed and real-disk sources are asserted byte-for-byte. Single-disc disc resolution now serves album art directly, so only multi-disc disc scenarios are ported. * fix(artwork): run disc resolution for single-disc albums too ed4178a6 gated serveDisc on len(album.Discs) > 1, claiming parity with the legacy reader. The legacy reader has no such gate: artwork.go dispatches every dc- id to newDiscArtworkReader, whose Reader() walks DiscArtPriority unconditionally. The gate also lost art. For a single-disc album whose only image is disc1.jpg, the disc request skipped the chain and fell through to album art, which does not match CoverArtPriority — so tracks tagged disc 1 (whose CoverArtID is a dc- id) served nothing at all, where before they served disc1.jpg. A single disc can legitimately have its own cover, distinct from the album's, and DiscArtPriority is what expresses that preference. Drop the gate and restore the single-disc e2e scenarios that covered it. * fix(artwork): register the GIF decoder in core/artwork The deleted artwork.go carried blank imports for image/gif and x/image/webp. WebP came back via resize.go's gen2brain/webp, which self-registers, but GIF did not: core/artwork claims GIF support in mimeForFormat and extForMime while relying on an unrelated server package to have imported the decoder. The guard lives in the e2e suite because that test binary has no other image/gif importer; a spec in core/artwork would pass regardless, since animation_test.go imports the package non-blank. * fix(artwork): never record absent after a local I/O failure Local sources swallowed their open errors, so a stale NFS/SMB mount was indistinguishable from "this entity has no artwork": the chain returned no reader, processItem took the absent branch, and the upsert replaced a good content hash with the empty string. Clients then saw a placeholder until the 1h request recheck or the 24h stale-absent sweep, and the orphaned bytes became eligible for the next prune. A candidate the resolver knows about — a file in the folder listing, a track's own audio file — failing to open is not evidence of absence, so it now forces a retry the same way an external agent error does. * fix(artwork): keep served art when the retry budget runs out Exhausting the 12h budget called writeAbsent unconditionally, so an entity whose art was already resolved and serving lost it to a long upstream outage: the hash went empty, clients fell back to the placeholder, and the now-unreferenced bytes were freed by the next prune even though nothing about the image had changed. Exhaustion means the source stayed unreachable, not that the cover disappeared, so absent is now recorded only when there is nothing to keep. * fix(artwork): restart the retry budget on re-enqueue The conflict clause updated only priority and retry_at, so a row that already existed kept its original attempts and enqueued_at. The worker measures the 12h give-up budget from enqueued_at, so any row that had been pending across a longer gap — a server left off, an upgrade, a laptop asleep — gave up on its very first attempt and settled absent. The manual re-resolve endpoint is the sharpest case: it clears artwork state and re-queues, but inherited the old row's spent window, so a deliberate retry got one shot. A fresh request now gets a fresh budget, with the mock updated to match. * fix(artwork): keep not-found distinct from artwork-absent GetOrPlaceholder folded model.ErrNotFound in with ErrUnavailable, so an id matching no entity returned 200 and the placeholder PNG. That made the ErrorDataNotFound branch in getCoverArt unreachable: Subsonic went from error 70 to a successful placeholder, and Jellyfin's Primary image endpoint from 404 to 200. Neither is ours to change. An entity with no art and an id with no entity are different answers; only the first is a placeholder. * fix(artwork): make the prune sweep cancellable Sweep walked the whole store with no context, and RunPrune holds the prune write lock for its full duration. In-flight acquisitions park on the read lock, drain's WaitGroup never returns, and Run never reaches its ctx.Err() check — so a SIGTERM during a daily prune over a large store on slow storage waits out the container's grace period and dies mid-remove. * fix(artwork): hydrate the tracks reached through a playlist loadTracks and the playlist-track cursor were the only entity-page paths that never hydrated artwork state, so a song reached through a playlist behaved differently from the same song in the songs list: Subsonic emitted a hashless coverArt id, which imghttp downgrades to no-cache, and advertised art even for known-absent albums; Jellyfin emitted AlbumPrimaryImageTag as the bare album id — a tag that never changes when the cover does — and no blurhash at all. The media-file hydration moves next to the other hydration helpers so both paths share one implementation rather than growing a third. * feat(ui): cross-fade the cover over its blurhash The blurhash unmounted the moment the blob arrived, so the placeholder vanished a frame before the image painted. The image now mounts transparent and fades in over the blurhash, which stays behind it until the fade completes. A blob already cached when the instance mounts skips the fade, so a remount does not re-animate. * fix(ui): retire the blurhash on a timer, not transitionend Under prefers-reduced-motion the img rule sets transition:none, so toggling opacity fires no transitionend and the handler that unmounts the blurhash never ran. The placeholder stayed mounted for the life of the component — visible in the letterbox bars wherever the cover is rendered with fit="contain", and a live canvas per tile everywhere else. One duration constant now drives both the CSS transition and the timer, so they cannot drift. * fix(artwork): stop advertising a hash for bytes that won't be served Hydration stamped the album's hash and blurhash onto an embedded-eligible track that had no state row yet. Serving takes provisionalEmbedded for exactly that case and returns the track's own embedded image, so the id carried a content-version belonging to a different picture: every such request fell back to no-cache instead of immutable, and a client keying its cover cache on the blurhash paired the album's with the track's art. AlbumCoverArtID had the mirror-image problem, building the album id from the track's own ItemImage. It happened to work only because hydration overwrote ImageHash in precisely the fallback cases; a track with its own resolved art would have stamped that hash onto the album's id. * perf(artwork): precache from the bytes just acquired Warming the resize cache re-read the two rows and the file the acquisition had just written, so every acquired image cost two extra queries and a second full read of a file whose bytes were still in memory. processItem now hands back what it persisted and precache warms from that, under the same cache key the serving path computes. Resolving the admin user also moves behind the empty-queue check: it is needed only to resolve private playlists, so an idle server no longer runs a user lookup on every poll. * perf(artwork): keep the worker pool fed across a drain The pool was fed from a batch sized to the pool itself (2x concurrency) with a WaitGroup barrier before the next dequeue, so one item burning its external timeout idled every other slot until it finished. The legacy cache warmer had no such barrier: it streamed through a pipeline of 4. Dequeuing well past the pool keeps the slots fed for the whole pass at no extra cost, since DequeueBatch does not mark rows taken and was already one query per pass. Acquiring a slot now also observes cancellation, so a larger batch cannot delay shutdown. * perf(artwork): drain local and external artwork in separate pools A first backfill enqueues artists before albums at a single priority, so the drain took them in that order. Artists resolve through a rate-limited agent, and gate() waits for its permit while holding a worker slot, so the whole pool sat asleep in the limiter with every album queued behind it. Measured on a 96k-track library (29,115 artists to 6,949 albums, 4:1): zero albums resolved in seven minutes, and roughly 3.3 hours before the first album cover would have appeared. Splitting the drain gives each class its own slots: albums now finish in under eight minutes while artists trickle at the same 2/s they were always limited to. The two budgets are carved out of MaxOpenConns so a second pool cannot take connections the scanner and the UI need. Dequeue filters by kind, and the drain index leads with item_kind so each pool seeks to its own work instead of scanning past the other's backlog. * fix(artwork): treat an unreadable upload as a failure, not a miss resolveLocalFile swallowed every os.Open error, so uploads, playlist sidecars, a local M3U image and the artist image folder still had the bug that was fixed for folder and embedded sources: a permission or transient I/O error on a file that exists read as "no image here". The worker then settled the item absent and dropped its queue row. Uploads outrank every other source, so an unreadable one now stops the chain rather than letting a lower-priority image be persisted in its place. A genuinely missing file stays a clean miss. Reported by Codex on #5847. * fix(artwork): make the pool-split worker test race-clean CI runs the suite under -race, which the local `make test` does not, so this only showed up there: 230 specs passed and the detector still failed the run. The drain-pools spec started Run and never waited for it, so pool goroutines outlived the spec and raced the config snapshot Ginkgo restores on cleanup. It now cancels, unparks the blocked lookups and waits for Run to return. Two test doubles also had to become concurrency-safe, since the spec is the first to resolve several artists at once: fakeImageAgent's call counters, and MockAlbumRepo.GetAll, which records the last query options on a read path. MockDataStore's lazy accessors get the same treatment — only MediaFile was guarded before, and two pools now reach them concurrently. ArtworkQueue takes an unlocked helper for its internal Artwork call, since repoMu is not reentrant. * perf(artwork): stop reading and hashing disc art on every request The resize-cache key was the content hash, which cannot be computed without reading the file, so a warm cache never prevented the I/O: every sized disc request read up to 20MB and hashed it before the lookup. The legacy reader keyed on the id and the album's mtime and touched the file only on a miss. Disc art has no state row and therefore no stored hash, so the key is that same identity — id, album mtime, DiscArtPriority — and the selection chain now runs only when the cache misses. Full-size requests stream the source instead of buffering and hashing it. This matters more now that single-disc albums keep running the disc chain, which puts every disc-tagged track without embedded art on this path. * fix(artwork): key disc art on folder image changes, not just the album The identity cache key used album.UpdatedAt alone, which a replaced disc image does not necessarily move — the sized response would then serve the old image indefinitely. The legacy reader folded ImportedAt and the folder's ImagesUpdatedAt into its key for exactly this reason, and loadAlbumFoldersPaths already returns that timestamp; the disc reader was discarding it. * fix(artwork): return undispatched items when a drain is cancelled claim() reserves the whole batch before dispatch, but the cancellation path returned without releasing what it had not yet started, leaving those items in the in-flight set permanently — no later drain could claim them again. Harmless until the batch grew past the pool size; now a cancel strands up to a full batch. The e2e harness cancels mid-drain after every acquire, so it surfaced there first: one spec timed out waiting for an item that had been claimed and abandoned, and the suite went from 59s to 87s on CI. * fix(jellyfin): make a track's own cover reachable for Jellyfin clients Media files are never enqueued — the scanner drops their state without queueing them and the recheck kinds exclude them — so an unresolved track keeps an empty ImageHash and SongToBaseItem always fell through to AlbumPrimaryImageTag. Finamp then asks for the album image, nothing ever requests mf-, and the read-through that resolves the track never fires: the own-cover branch was unreachable for Jellyfin-only users. An eligible, unresolved, not-known-absent track now advertises its id as the Primary tag, which is what makes the client ask. The request serves the embedded art and queues the track so the worker persists a real hash. No blurhash is sent, since none exists yet and a fake would be cached against that tag forever. Serving gains the album fallback that made this safe to advertise: an eligible track whose frame will not extract now falls back the way CoverArtID does instead of answering with a placeholder. Reported by Codex on #5847. * fix(artwork): treat an unreadable artist-folder image as a failure Third and last site in this class: findImageInFolder logged and skipped an image the glob had already matched, so a permissions or mount failure during the artist-folder traversal read as "no image here" and let processItem settle the artist absent, discarding any artwork already resolved. A matched-but-unreadable file now propagates through fromArtistFolder and lands as localError, the same as album folder art, embedded art and uploads. A folder with no match stays a definitive miss. Also normalizes the e2e path assertions with filepath.ToSlash: the stored SourcePath is OS-native, so the forward-slash suffixes failed all 23 folder specs on Windows. Reported by Codex on #5847. * fix(artwork): give full-size disc art a real ETag Regression from 04d5a556. Keying the resize cache on identity meant the disc response no longer carried a content hash, and the full-size branch set no ETag either — so WriteImageHeaders fell back to the empty hash and emitted `ETag: ""` for every full-size disc image. Since ifNoneMatch compares the unquoted value, a client echoing that back matched, and got a 304 even after the image was replaced. The full-size branch now carries the same identity validator the sized branch uses, so it moves when the folder's images change. WriteImageHeaders is hardened against the class as well: an empty validator is no validator, so it is neither emitted nor matched. Reported by Codex on #5847. * test(artwork): inject the unreadable source instead of chmod os.Chmod cannot revoke read access on Windows — it only toggles the read-only attribute — so the findImageInFolder spec opened the file happily and failed there, and the upload spec passed for the wrong reason: outcomeFailed came from the 1-byte payload failing to decode, not from the source being unreadable. findImageInFolder takes an fs.FS, so the failure is now injected and the spec is filesystem-independent. The upload path goes through os.Open directly and has nothing to inject, so it skips on Windows rather than pretend to cover it. * fix(artwork): compare the pixel cap without multiplying Defence in depth rather than a live hole: the reported crafted PNG (0xffffffff square) never reaches the multiplication, because image/png rejects it at DecodeConfig, and the largest dimensions any supported format can declare — 2^30-1 for PNG, 16-bit for JPEG and GIF, 14-bit for WebP — cannot overflow the int64 product. decodeCapped is format-agnostic though, so the guard should not depend on a decoder's own limits staying where they are. Comparing by division holds for any dimensions a decoder might report, and non-positive ones are now rejected outright. * refactor(ui): rename cover artwork components * fix(ui): remove Artwork rendering gate Signed-off-by: Deluan * fix(cache): re-fetch when a cache entry outlives its data file fscache's Remove drops the in-memory entry, releases the lock, and only then unlinks - blocking until every outstanding reader closes. A Get landing in that window re-creates the file at the same path under a fresh entry, and the deferred unlink deletes those new bytes. The entry survives pointing at nothing, and since a present entry is treated as a hit, every later Get for that key returns ENOENT for the rest of the process's life. Only a restart, which rebuilds the map from disk, cleared it. Get now drops such an entry and retries once, so a vanished data file costs one re-fetch instead of poisoning the key permanently. This also covers a file disappearing for reasons unrelated to that race, such as external deletion or a restored backup. Specs cover an in-process entry, one adopted at startup, and the deferred-removal race itself. * fix(artwork): log why a sized cover fell back to the placeholder serveHash routed every non-cancel cache error into dangling(), which returns ErrUnavailable and is then rendered as a placeholder at 200 OK. A cache-layer fault was therefore indistinguishable from an album genuinely having no artwork, and left no trace: a broken resize cache silently served placeholders for a quarter of the library while the logs stayed clean. Log the error before falling back, so the cause is recoverable from the logs. * fix(artwork): precache the cover variant the UI actually requests precache built its resizedItem without setting square, so it warmed h-..false.. The list surfaces - album grid, artwork avatars, playlist and radio details - all request square covers, so the warmed entry was never read and every grid cover stayed a cold miss on first view. Set square on the precache item so the key matches the request path. The existing specs asserted the '.300.false.' key and were updated accordingly. * fix(cache): give a re-created cache entry its own file Create re-opened the path with O_TRUNC, which shrinks the file out from under an older stream that may still be serving readers. stream.Reader then hits EOF from the OS at the new, shorter length while the broadcaster still reports the original size, so Wait() reports 'more data exists' and the reader retries forever - a tight pread loop that burns a core and never releases its handle, which in turn blocks Stream.Remove() indefinitely. Unlink first and create with O_EXCL so the new entry gets a fresh inode. Existing readers keep their descriptor on the old inode, see its full contents, and reach a clean EOF. Note this does not address the deferred unlink deleting the re-created file, which is handled separately by the re-fetch in fileCache.Get. * fix(cache): keep the in-place truncate on windows Unlinking before re-creating fixes the premature-EOF spin on unix, but Windows refuses to remove a file another handle still has open and returns a sharing violation. Because Create surfaces that error, every cache miss on a path with a live reader would have failed outright - worse than the spin it was meant to fix. Split the create behind a build tag: unix unlinks for a fresh inode, Windows keeps truncating in place and stays exposed to the spin, which is the behaviour it already had. The unix-only spec is skipped there. * fix(artwork): shape the blurhash placeholder to the artwork's aspect ratio The UI decoded every blurhash into a 32x32 bitmap and stretched it to fill its container, so a non-square cover showed a full-box blur that collapsed into a letterboxed image the moment it loaded. On the album detail page the placeholder overhung the image by a third of the box height. A blurhash string carries no aspect ratio of its own, so the dimensions have to come from the server. artwork.width/height were already stored and read by nobody; they now surface on ItemImage as imageWidth/imageHeight, hydrated through the join that was already in place. Existing rows already carry them, so no migration or rescan is needed. A square request is padded rather than cropped, which aspect-fits the content inside the square the server returns. That made the grid a second instance of the same bug, so `square` now implies contain for the image as well as the placeholder, instead of the two renderers reading it differently. * feat(jellyfin): expose PrimaryImageAspectRatio Real Jellyfin carries width/height of the Primary image on BaseItemDto (MediaBrowser.Model/Dto/BaseItemDto.cs), attaching it only when the request's Fields asks for it (DtoService.cs ContainsField). The dimensions are now on model.ItemImage for the web UI's blurhash placeholder, so the adapter can report the same thing for free. Gated behind Fields to match, and omitted rather than defaulted when the item has no image or unknown dimensions: real Jellyfin falls back to a per-type default of 1 for music, but a wrong ratio mis-shapes a client's placeholder, and we only lack dimensions when the artwork is genuinely unresolved. primaryImageTag becomes primaryImage, returning the tag, blurhashes and ratio together, so the choice of which image is Primary is made once per mapper rather than the same ItemImage being threaded through two calls. ArtistToBaseItem and PlaylistToBaseItem take Fields now, like the album and song mappers already did. * refactor(model): give ItemImage an AspectRatio method The zero/absent guards around width/height are ItemImage's own invariant, not the Jellyfin adapter's. Moving them onto the model keeps one definition for every consumer, so a second one cannot quietly disagree about what an unknown ratio means. * fix(ui): anchor detail pages to the top when opened from a list React Router keeps the previous page's scroll offset, so opening an album from a scrolled list started the detail page mid-song-list. Artist pages had the same bug; it just shows less because the artist list is rarely long enough to scroll far. Keyed on the record id rather than mount, so detail-to-detail navigation (an album's artist link) resets too, and so the scroll waits for the record instead of firing against an empty page. * fix(ui): stop the Random album grid collapsing on every keystroke The grid was replaced by a spinner whenever the random list was loading, so each search keystroke collapsed it to spinner height and back. That blanket blanking is the flicker commit 9e559311a removed for every other list; random kept the exception so a re-roll would not flash the roll it is replacing. Blank on a seed change instead of on any load: a re-roll gets a new seed and still blanks, while a search keeps the seed and leaves the grid in place. The seed is tracked from empty rather than from the current value because a re-roll redirects and remounts the grid, which would otherwise look already-settled with the previous roll still on screen. Same rule for the pagination, which was hidden on the same condition. * refactor(artwork): move fingerprint property key const to `consts` package Signed-off-by: Deluan * refactor(tests): enhance database handling with resettable tables and truncation Signed-off-by: Deluan * refactor(artwork): narrow the prune lock and drop redundant in-flight tracking The prune read-lock wrapped all of processItem, including external fetches under their own timeout. Since a pending RWMutex writer blocks new readers, one prune arriving behind a slow provider stalled every subsequent item in both drain pools. Extract persist() so the lock covers only the window it protects: store placement plus the two row writes. The in-flight set guarded against a queue row appearing twice in one batch, but artwork_queue's primary key makes that impossible, drains are serial per pool, and the pools' kind lists are disjoint. Removing it also retires the cancellation unwind loop that existed only to release those claims. * fix(artwork): never settle absent for a kind no recheck job revisits The 12h retry budget hands a bare failure to the periodic stale-absent sweep, which is what makes the resulting absent row recoverable. Media files are deliberately excluded from that sweep -- they resolve embedded only, at scan or on view -- so exhausting the budget on a transient read error recorded a "this track has no cover" verdict that nothing would ever revisit. Settle absent only for kinds a recheck job covers. Without a row the track stays unresolved, so the next view re-enqueues it. * refactor(artwork): remove dead plumbing from the serving path artworkReader.LastUpdated had no callers: invalidation rides entirely on the cache key, so the interface member, the resizedItem field and its four assignments were vestigial. Reader's second return value was likewise discarded at all three call sites. resizedItem.Key duplicated representationTag's format string over identical inputs, where drift would serve a wrong-keyed entry under a right-looking validator; it now derives from it. newResizedItem had one caller and a doc comment claiming a sharing with worker.precache that never existed -- precache builds its own literal. Also unexport Prune, which no caller outside the package used while RunPrune documented itself as the only sanctioned path, drop a single-call placeholder wrapper, and delete five fakeFolderRepo fields no spec ever set. * refactor(artwork): drop queue repository methods only tests called MarkFailed and Delete had no production caller: the worker only ever uses MarkFailedIfUnchanged and DeleteIfUnchanged, which refuse to act on a row a concurrent scan re-enqueued. Keeping the unconditional pair meant the interface offered the racy variant under the more obvious name. MarkFailedIfUnchanged does not build on MarkFailed, so nothing in the implementation needed them either. The repository tests used them to put a row into a backed-off state; they now do that directly. * test(artwork): pin that a request never fetches or samples album art resolveItemLocal's guard against the remote ExternalImageURL fetch and the 2x2 grid had no coverage: deleting it left the whole suite green while putting synchronous network calls on the request path. The worker resolving the same playlist is asserted alongside, so the spec cannot pass by simply resolving nothing. * refactor(artwork): give the resolver a receiver and one capability field The resolve* chain walkers each took seven parameters -- ds, agents, ffmpeg, gate, localOnly -- while the package already had workerDeps bundling the same collaborators for processItem. They are now methods on a resolver. The external capability is one nilable field instead of three values that had to agree. Previously a local-only resolution passed agents=nil, gate=denyGate and localOnly=true, and only the localOnly check actually protected anything: the external branch dereferences agents in the loop header, before the gate closure runs, so denyGate could never fire. It is deleted. A nil ext now both marks the resolution local-only and removes the agents there were to dereference, and newLocalResolver takes no parameter that could supply one. The playlist tile loop hardcoded localOnly=false, safe only because an early return 22 lines above it made that unreachable; it now inherits the resolver's capability. * refactor(artwork): funnel every served representation through one path serveHash, serveBytes and serveDisc each hand-copied the same five steps -- test for full size, stream or build a resizedItem, call the cache, wrap with a validator -- with a different error policy bolted on. The ETag rule was restated at four sites and applied inconsistently. serveSource now states it once: full size streams open() directly, and an ETag is attached only when the bytes are resized or there is no hash to validate against. Each caller keeps just its own error policy, and serveBytes folds into its single caller. Two behavior changes fall out, both narrowing an aborted request's blast radius: serveDisc propagates context.Canceled instead of falling back to a full album resolution, and serveHash's full-size path propagates it instead of going dangling, which would have enqueued a re-resolution for a request nobody is waiting on. * style(artwork): use one log prefix, spelled the way the codebase does The package logged under three spellings of its own name -- "artwork: " lowercase, "Prune: " and one "Artwork: " -- and the lowercase ones carried lowercase message text, against 568 capitalized to 48 lowercase elsewhere. Prefixing itself is the convention here (Scanner:, API:, Watcher:) and it earns its place: DevLogSourceLine is off by default, so without it a line does not say which subsystem emitted it. So this normalizes the spelling rather than dropping the prefix. Error strings stay lowercase and unprefixed per Go convention. * refactor(artwork): give workerDeps only what the processor uses The bag carried cache, which processItem never reads and only the worker's precache uses, and carried agents/ffmpeg/gate solely to reconstruct a resolver on every queue item. cache and ffmpeg move to Worker, where precache actually uses them, and the resolver is built once in NewWorker. persist's hash parameter was redundant: decodeArtwork sets Hash and GetImage selects it, so art.Hash already holds it on both paths. The type itself now lives beside Worker, which owns it, rather than in the file of the function it is passed to. * refactor(artwork): make acquisition a processor with its own receiver workerDeps was a parameter bag threaded into two free functions that nothing outside the worker calls. It becomes the processor type, with processItem and persist as acquire and persist methods on it, and Worker holds one collaborator instead of reaching through a bag. Kept as a separate type rather than folding onto Worker: acquisition takes a queue item and returns bytes, while Worker.process settles the queue row around it. That boundary is what keeps retry policy out of the image pipeline, and what lets the acquisition specs build a three-field value instead of a Worker with drain pools, gates, a broker and a real on-disk cache. * refactor(artwork): collect the external gate contract in one file gateFunc, passthroughGate and isTransientExternal sat in agent_images.go while every implementation lived in worker.go: extGate, breaker, Worker.gate, gateFor. isTransientExternal even carries a comment saying it must stay consistent with breaker.record, which was in the other file -- a rule spanning two files with only a comment holding it together. Pure move into gate.go: no symbol added or removed. * refactor(artwork): put the whole playlist grid in playlist_cover.go decodeTile and assembleTiles were in resolve.go while the geometry they depend on -- rect, fillCenter, tileSize -- was in playlist_cover.go and used nowhere else, so one file held the grid's helpers and another its assembly. Pure move. * refactor(artwork): move resizedItem next to the interface it implements resizedItem is the only implementation of artworkReader, which is declared in image_cache.go, and it is used by the worker's precache as well as the serving path -- so worker.go was reaching into serving.go for a cache type. representationTag stays in serving.go, where the HTTP validator belongs. Pure move. * refactor(artwork): fold Refresh into housekeeping refresh.go was a 21-line file for one function that clears artwork state and enqueues -- the same thing Backfill, EnqueueStaleAbsentAll and EnqueueMissingAll already do next door. Pure move. * refactor(artwork): accumulate priority-chain state in one place Every source in the album and artist chains repeated the same five lines: stamp the accumulated external failure onto a hit, or OR the local fault into the running total on a miss. Each new source was a chance to forget the OR, which is how the local-I/O-settles-absent bug happened. chainState.try does both, so each case drops to three lines and the omission is no longer expressible. Semantics are unchanged: a hit still carries extErr only. Also drops localErr from resolvePlaylist, which declared it but never assigned it. * refactor(artwork): expose housekeeping through the Worker scheduleArtworkHousekeeping received a *artwork.Worker and then called CreateDataStore() for a second handle onto the state that Worker already owns, because Backfill, EnqueueStaleAbsentAll and EnqueueMissingAll were free functions taking a DataStore. They are now Worker methods over unexported implementations, the same shape prune/RunPrune already uses: one public path, and the specs keep calling the plain function with a mock store instead of standing up a Worker. Fingerprint is unexported too -- nothing outside the package used it. * refactor(artwork): name the service for its domain, not its role Every other core interface is named for what it is -- Playlists, Library, Scrobbler, MediaStreamer -- while this one was artwork.Service, the only X.Service in the tree. It becomes artwork.Artwork/NewArtwork, and serving.go follows the type to artwork.go. ProvideImageStore was likewise the only "func Provide" in the repo; it is GetImageStore now, matching GetImageCache beside it. cmd/wire_gen.go regenerated via make wire. * docs(artwork): give each Worker housekeeping method its own godoc The three methods shared one comment attached to Backfill, so godoc rendered the other two undocumented and the one it did show described the group rather than the call. Each now opens with its own name and says what that call does, including Backfill's bool return. * refactor(artwork): move fakeFolderRepo to artwork_suite_test.go Signed-off-by: Deluan * refactor(artwork): unexport artwork.HashImage function * fix(log): stop ShortDur eating significant trailing zeros TrimSuffix(s, "0s") was meant to turn "4h0m0s" into "4h", but it strips any trailing "0s"/"0m" -- so 10s logged as "1", 20s as "2", 1m30s as "1m3", 2h30m as "2h3", and a zero duration as the empty string. Every elapsed/duration field in the app was affected. The suffix now has to include the preceding unit, so only a whole zero-valued component is dropped. The existing table only covered values that dodge the bug (4m, 4h, 4m3s); added the ones that don't. * feat(artwork): prefix every log message and time the slow steps Prefix: 22 messages still logged unprefixed, so a line from this package was indistinguishable from any other subsystem's. All 40 now carry "Artwork: ", matching Scanner:/API:/Watcher: -- which earns its place because DevLogSourceLine is off by default. Timing on what can actually be slow: total per acquisition (on every exit, failures included), the read that also covers the provider download, hashing, decode+blurhash, resize, drain batch, precache, prune, backfill, and the external agent call -- with the rate-limiter wait counted separately, since a throttled agent and a slow one look identical from the drain. Debug coverage for states that were previously silent: dedup hit vs decode, settling absent, serving a lower-priority source after an external failure, retry scheduling with attempts and budget left, giving up when the budget runs out, breaker open/close per agent, provisional read-through, dangling state rows, and the mtime mismatch that makes art appear to vanish. outcome gained a String() so it reads as a name. * refactor(artwork): log decoded dimensions as fields, not a formatted string fmt.Sprintf ran on every newly-decoded image even with Debug off, since Go evaluates log arguments regardless of level. Separate width/height fields also query better than a "300x300" string. Correction to e0f1acd1a's message: it said "all 40" messages carry the prefix. The package has 68 log call sites and 62 distinct messages; 40 was only what the test suite happened to exercise. The sweep itself was complete -- zero unprefixed messages remain. * fix(subsonic): stop serving artwork for a deleted radio artworkAccessible checked every kind except radio, which fell through to the default "no per-user access control" branch and returned true without a lookup. Radio deletion removes only the entity row -- item_artwork and the uploaded file survive until the next prune -- so the old ra- id kept serving the removed radio's image for up to a day. This is a regression against master, where newRadioArtworkReader loaded the radio first and returned its error, so deletion took effect at once. Radios stay globally visible; only existence is checked. CreateMockedRadioRepo left Data nil, so its own Put panicked on first use; initialized it. Reported by Codex on #5847. * fix(artwork): stop serving artwork for entities that no longer exist Artwork state and its bytes outlive a deleted entity until the next prune (@daily), and the serving path consulted only item_artwork, so a Subsonic id or a signed public token kept serving a removed entity's image in the meantime. Master's readers loaded the entity first, so this was a regression. The check goes in serveHash, the one path that can hand back a found row's bytes: absent rows are already unavailable, and the provisional and disc paths load their entity to resolve at all. Doing it there instead of per-handler also settles who owns the invariant. Subsonic had worked around it with artworkAccessible, whose comment described the service "bypassing the library and private-playlist filters"; that workaround is now deleted, since the service resolves through the request-scoped repositories and enforces the filters itself. Because those repositories are ctx-scoped, each caller says what it wants by what it passes: Subsonic hands over the request context and so gets visibility as well as existence, while the public image route elevates like the Jellyfin one already did -- a token is the authorization there, and a visibility check would hide a shared private playlist, the very case shares exist for. Two supporting fixes. albumRepository.Exists and mediaFileRepository .Exists used the plain exists() helper, which applies no library filter, so they reported rows in libraries the caller cannot see; they now count through applyLibraryFilter as CountAll and artistRepository.Exists already do. Neither had a production caller. RadioRepository gained the Exists it lacked. Tests follow the layers: the service refuses a found row whose entity is gone, the repositories hide rows the caller may not see, and the handlers only assert the context they hand over. Jellyfin needed no change -- resolveArtworkID probes the entity tables, so a deleted item yields an empty artwork id -- but that protection was incidental and untested, so it is pinned now. * fix(playlist): rebuild the generated cover only when the tracks change The enqueue sat in refreshCounters, which Put also reaches for an ordinary metadata update, so renaming a playlist or editing its comment re-resolved the cover. The 2x2 grid samples albums with random(), so that silently handed the playlist a different cover for an edit that touched no tracks -- and refetched remote artwork to do it. It now happens where the track set actually changes: addTracks (which Put-with-tracks and updatePlaylist both funnel through) and renumber (reached from removeOrphans). Creation still enqueues even with no tracks, since an imported m3u can carry an ExternalImageURL. Reported by Codex on #5847. * style(artwork): trim verbose comments to the 1-2 line budget Comments only; no executable code changed. Verified by comparing the Go token stream of every touched file before and after: identical. Removes 375 of the 1104 comment lines this branch added, targeting content that belongs in a commit message or PR body rather than in the code: rejected alternatives ("DeleteIfUnchanged, not Delete", "Waking all beats routing by kind"), refactor history ("as the legacy reader did"), issue references (#5798, #5597, #5376), benchmark numbers (~400ms, ~16k allocs), and four persistence doc comments that duplicated the interface godoc in model/artwork.go verbatim. Comments predating this branch are left untouched. The ASCII fixture trees in the e2e suites are deliberately kept above the line budget: they diagram the fixture layout with its expected outcomes, and every pre-existing block in those files carries one. * test(artwork): make the artists-first backfill assertion non-vacuous The follow-up loop could never fail: once the first "ar" index is asserted to be 0, every non-"ar" element is necessarily at an index greater than 0. A sequence like ["ar", "al", "ar"] passed both assertions, which is exactly the interleaving the check exists to forbid. Assert the partition directly instead: nothing after the first non-artist call may be an artist. Verified by mutation — enqueueing artists a second time after albums now fails the spec. * refactor: use stdlib slices/maps and utils helpers in artwork code Mechanical cleanups, no behavior change: - 15 copies of the same id-extraction loop collapse to slice.Map (5 repo mocks, the scanner's track sweep, 4 wantIDs assertions) and slice.ToMap (6 index-by-id loops in the hydration specs). - disc.go built a map[string]bool purely to dedup folder ids and then walked it back into a slice; slice.Unique says that directly. - folders_artist.go's image filter is slice.Filter over model.IsImageFile. - mock_artwork_repo deleted from a map while ranging it; maps.DeleteFunc states the intent. - sort.Slice -> slices.SortFunc + cmp.Or; math.Min/Max -> builtin min/max; make+copy -> bytes.Clone; strings.Split -> SplitSeq on a per-request path; three-clause pixel loops -> for range. - Reuse utils.BaseName where a stem was recomputed by hand. Not at playlist_cover.go:27: that path is a full OS path and utils.BaseName uses path.Base, which does not split backslashes. - Drop a dead nil-guard in agents.go: getAgent returns a bare nil interface, and a type assertion on nil already yields ok == false. cmp.Or was rejected for the gate fallback (func types are not comparable, does not compile) and for ItemArtwork.AttemptedAt (cmp.Or compares time.Time with ==, which includes loc; IsZero does not). * refactor(persistence): collapse the four hydrateArtwork copies into one generic album/artist/playlist/radio each carried the same eight lines, differing only in element type and model.Kind. hydrateItems takes a ref callback yielding an item's id and the ItemImage to fill, which is all that varied. The len()==0 guards drop out: hydrateItemImages already short-circuits an empty id list, and both loops are no-ops on an empty slice. applyItemImage stays as-is; hydrateMediaFileArtwork and its own specs still use it. * perf(scanner): stop reprocessing album and artist artwork on every full scan A full scan re-imports every track, so the per-entity artwork enqueue in persistChanges fired for every album and artist in the library — measured as 100% of both on a live instance, and ~8.5k artists / ~16.7k Deezer fetches on a 96k-file library. Re-importing a track is no evidence the art changed: artist art has no track-content source at all, and the albums re-derived to identical hashes across three consecutive scans. Enqueue through EnqueueIfMissing on a full scan, which anti-joins item_artwork so only entities that never resolved are queued. Incremental scans keep the unconditional Enqueue, where a re-import does mean the file changed. Doing this at the enqueue site rather than skipping it wholesale keeps a first scan filling in covers as it walks, instead of stalling every cover until the scan ends. EnqueueMissing grows a priority argument and runs once at end of scan, so an entity phase 1 never saw still resolves, at Scan priority rather than dropping to Recheck behind the backfill. * refactor(artwork): derive blurhash components inside Encode Components had a single caller, which only ever fed it the bounds of the image it then passed to Encode. Exporting it gave callers two ways to get it wrong — components mismatched with the image, or out of the 1..9 range — in exchange for a knob nobody turned. Derive them from img.Bounds() at the top of Encode and unexport the helper. The counts must come from the pre-downscale bounds: downscale's integer rounding can shift the ratio across a component boundary, and the hash is a client-side cache key. Verified byte-identical over 18 hashes spanning 9 aspect ratios. The out-of-range validation goes with it, being unreachable once the counts are always derived. The aspect-ratio table now asserts through Encode's size flag, which encodes (x-1)+(y-1)*9. * refactor(ui): replace the blurhash package with a local decoder The UI pulled in the `blurhash` dependency for one function, `decode`, called from a single component. The decoder is ~80 lines of well-specified arithmetic, so carrying a dependency for it costs more in supply chain and bundle than it saves. Equivalence was proven against the package before removing it: 84 hashes — three real ones plus every component count from 1x1 to 9x9 — decoded at six sizes, compared byte for byte, plus parity on which malformed inputs throw. Those pixel values are now pinned in the spec, so drift from the reference algorithm fails. The punch parameter is dropped rather than reproduced: no caller passes one, and the package applies `punch | 1`, which silently turns a punch of 2 into 3. * refactor: simplify the artwork enqueue and blurhash paths Cleanup pass over the three preceding commits. Tabulate the cosine terms in the UI blurhash decoder instead of calling Math.cos per pixel per component: 248us -> 75us for a 32x32 decode, and an album grid mounts one decoder per tile. Output is unchanged, which the pinned pixel specs enforce. The Go encoder already tabulated the same terms. Drop the dead paths that deriving components inside Encode left behind: the zero-size guard in components, the post-downscale empty check, and the no-AC-factor branch, which cannot be reached now that the counts are always at least 1x9. The empty-image check moves ahead of the derivation, where it belongs. In the queue mock, look up item_artwork by its existing iaKey rather than scanning the map, and hold the lock across EnqueueIfMissing through a shared unlocked helper instead of releasing it mid-operation. Extract the duplicated drain-and-resolve block in the scanner specs into one helper. * fix(artwork): refresh songs when their album's artwork changes A track with no art of its own is served its album's, so hydration copies the album's hash onto the track record. When an album resolution changed, the worker broadcast only an `album` refresh, leaving song and now-playing surfaces holding the previous hash-suffixed URL until something else refetched them. Pair the album refresh with a song one. The dependent id list is unbounded — an album has arbitrarily many tracks and a drained batch arbitrarily many albums — so this refreshes the resource as a whole via the protocol's existing wildcard rather than enumerating ids. Reported by Codex on #5847. * feat(persistence): log SQLite result codes on failed statements SQLite reuses one message for errors that need different responses: "database is locked" is both SQLITE_BUSY, which busy_timeout retries, and SQLITE_BUSY_SNAPSHOT, which it can never retry because the transaction's read snapshot is already stale. Reading only the message, the two are indistinguishable, and a lock error seen in the wild could not be diagnosed without guessing which one it was. Add db.ErrorCodes to unwrap a sqlite3.Error and report its result and extended result codes, and include them in the SQL error log. The helper lives in db because that package already owns the driver, so persistence does not need to import it. Constraint, readonly and disk-full errors share messages the same way, so this applies to every failed statement, not just locks. * fix(ui): keep the Random grid blank while a refresh re-rolls Refresh bumps the list version, which changes the random seed and remounts the grid. useRollChanged tracked the seed on screen in a ref inside the grid, so the remount started it empty and adopted the new seed on the first render, while the refetch had not begun and the store still held the previous roll. The grid painted the old albums for the length of the request and swapped when the new roll arrived. Move the ref up to AlbumList, which a refresh does not remount, and pass it to the grid and the pagination. The seed on screen then survives the remount, so a refresh reads as a re-roll and blanks until the new roll lands. A search keystroke keeps the seed and still leaves the grid in place. * fix(ui): keep the album grid working outside the Random list Hoisting the shown-seed ref into AlbumList made the prop mandatory in practice: ArtistShow renders the same grid through ReferenceManyField and passes no seed tracking, so useRollChanged dereferenced undefined and the artist page died with "Cannot read properties of undefined (reading 'current')". Own a ref in the grid when none is passed. A caller with no roll to track then behaves as it did before, while the Random list keeps the ref that has to outlive the refresh remount. * refactor(config): make the artwork tuning options dev flags ArtworkWorkerConcurrency and ArtworkExternalMaxRPS become DevArtworkWorkerConcurrency and DevArtworkExternalMaxRPS, joining the other DevArtwork* flags. Their defaults should not need tuning, so they do not belong in the documented, user-facing option set. * refactor(artwork): name the hashed image store folder for how it is addressed The content-addressed store sat in artwork/store/, which did not distinguish it from the artist/, playlist/ and radio/ upload folders beside it — those hold artwork too. It is now artwork/hashed/, naming the one property that sets it apart, and the path comes from a consts entry rather than a bare literal, matching how the sibling folders are built. Deliberately not under cache/: that folder holds resizes that rebuild offline from a local source, while this one holds the only local copy of externally fetched images, whose re-fetch depends on a third party still serving them and whose bytes back the stored blurhash and dimensions. No migration: anything left in the old artwork/store/ is orphaned and re-resolved into the new location. * refactor(artwork): call the album e2e helpers by their real names album_test.go aliased expectAlbumFolderCover and expectAlbumAbsent to shorter local names, which cost a lookup to resolve and hid the prefix that distinguishes them from the artist and playlist helpers. * test(artwork): cover the album-root and artist-folder path arithmetic The unit specs for these helpers lived in the readers #5856 patched, both deleted here, leaving the album-root promotion and the artist-folder climb covered only end-to-end. A layout can show which image won but not why, so these pin the parts behind it: that the parent is fetched only when it could qualify as an album root, that a failed fetch propagates or degrades, and that commonDir keeps a shared name fragment from reading as a shared folder. Each spec was checked against a mutant: removing the library-root guard, the other-album audio check, the single-folder short circuit, the single-root climb, or commonDir's separator all turn one red. Master's remaining specs were dropped as redundant — they cover sources this branch already exercises under different names. * fix(ui): stop artwork refreshes reloading the whole page Resolving an album broadcast song:["*"], because a track with no art of its own is served its album's and the dependent ids are unbounded server-side. useResourceRefresh checked for that wildcard across every resource in the payload, not just the ones a component shows, so the album grid called refresh() — a full page reload — for every drained batch. Upgrading a large library reloaded the grid thousands of times. The client knows what the server cannot: which tracks are loaded, and their albumId. So the fan-out moves there, the wildcard check is scoped to watched resources, and the backend now names only what it resolved. The playlist views watch playlistTrack too: their rows carry albumId but are keyed by playlist entry, so a song refresh never reached them — previously masked by the wildcard's page reload. Signed-off-by: Deluan * fix(ui): stop the album grid blanking its covers on refresh Cover takes its height from react-measure, which reports nothing until it re-measures. That was harmless while the cover class sat on the img itself, which has intrinsic size; it now sits on the Artwork root, whose img fills it absolutely and so lends no height. A refresh remounts the tiles, and for the frame before measurement lands the box collapsed to zero and every cover vanished. Give the box its own aspect ratio as a floor. The measured height still wins once it arrives. * refactor(artwork): drop the unused Bump/wake path from the worker Worker.Bump had no production callers. Every real bump writes the queue row through the repository instead: artwork.Refresh (the refresh endpoint and the uploader), service.enqueue (read-through and stale-absent views), and radio_repository. None of them wake a pool. Bump was also the only sender to drainPool.wake, so the select case it fed was unreachable outside tests, and runPool read as if a bump were picked up promptly when it always waited for the 5s poll. The e2e and unit suites used Bump only as a driver, never as the subject, so they now enqueue with EnqueueBump and exercise the path production takes. EnqueueBump rather than Refresh because Refresh also clears the state row, which would change semantics for the re-bump-after-state and dedup specs. Both harnesses start Run after enqueueing, so a fresh pool drains on its first iteration and the wake never affected them: core/artwork/e2e averaged 3.26s before and 3.30s after over 5 runs, within noise. Signed-off-by: Deluan * refactor(artwork): unexport what nothing outside the package calls PlaceholderFor had no callers at all. Its comment offered it to callers that must not consult persisted state, but no such caller was ever written, so it is removed rather than unexported. entityExists and encode83 are only reached from inside their own packages; their tests are package-internal (artwork) or never touch them (blurhash_test). ImageStore stays exported: server/subsonic/e2e constructs one to wire up the worker, and that suite cannot move into package artwork. Signed-off-by: Deluan * refactor(persistence): name the backoff-preserving enqueue for what it does EnqueueBump was named for a priority, but its behaviour is preserving an existing row's retry_at; the priority comes from the item. Its one caller passes ArtworkPriorityScan, which read like a bug at the call site and is not. The three DO NOTHING inserts each repeated the INSERT prefix, the column list and the conflict clause. They now share insertIfNotQueued, with the CTE that EnqueueIfMissing needs passed as a prefix, and the column list lives in one slice used by both squirrel and the raw SQL. EnqueueIfMissing had no test against real SQL, only a mock mirroring the anti-join by hand, so the rewritten statement had nothing verifying it. Two specs now cover it: items with a state row are skipped, and an already-queued row keeps its priority. Signed-off-by: Deluan * test(thumbhash): vendor the reference and generate golden vectors * test(thumbhash): add a faithful reference port as the differential oracle * fix(thumbhash): re-condition alpha fixture and assert solid.png header alpha.png varied only alpha over a constant color, so compositing atop the average canceled L/P/Q exactly like solid.png, leaving it just as float-noise unstable and silently dropping alpha-path coverage. Vary R/G/B with position too so alpha.png carries real signal and reproduces byte-exactly, then replace the blanket solid.png skip with a precise assertion on its well-conditioned header bytes and quantized-zero scales. Also apply a range-over-int modernizer hint in reference_test.go. * test(thumbhash): dither the fixtures so no DCT coefficient is degenerate The gradient fixtures are smooth analytic ramps, and alpha.png's alpha channel varied along x only. Both make whole families of DCT coefficients mathematically zero: 13 of alpha.png's 38 AC nibbles, and at least one in every other ramp fixture. A zero coefficient normalizes to exactly the 0.5 midpoint, i.e. 15*f = 7.5, so which of nibble 7 or 8 it quantizes to is decided by ~1e-16 of float rounding noise. Those nibbles are therefore unstable across any two summation orders -- the vendored JS and the Go port already disagreed on solid.png for this reason -- and they carry no signal, so a bug that transposed two of them would be invisible. A deterministic +/-4 dither, plus an alpha ramp that varies in x and y, leaves every coefficient at least 4.8e-5 from the tie boundary: ~1e9 times the observed inter- implementation noise. solid.png and tiny.png regenerate byte-identical and keep their existing carve-outs. * feat(thumbhash): add a two-pass separable ThumbHash encoder Encode replaces the reference's four w*h scratch arrays (~320 KB at 100x100) and its 40 re-reads of every pixel with a single pixel pass that folds each row into per-frequency sums, then a second fold over rows. The transform drops from O(terms * pixels) to O(nx * pixels + terms * h), nx <= 7. Verified against the vendored JS goldens and, differentially, against the literal Go port on 500 randomized images. On the fixtures the two implementations' AC coefficients agree to 8e-15; the separable inner loop reassociates the additions, so exact agreement holds only where a coefficient is not sitting on a quantization tie, which is why the fixtures are now dithered. * test(thumbhash): fuzz the opaque path and drop an ill-conditioned golden The 500-image randomized differential filled every byte randomly, so hasAlpha (avgA < w*h) was true for all 500 images: the 7x7 no-alpha layout, terms(7,7), nx=7 and the `if hasAlpha` false branch were never fuzzed. Force full opacity on alternating iterations, which splits the run 250/250 with the seed and iteration count unchanged. All 500 still match the reference port. tiny.png is 1x1, so it has no non-zero AC content: 12 of its 37 AC coefficients sit exactly on the round(15*f) = .5 tie and 24 are within 1e-12. It passed only because a single pixel admits no summation reassociation. Give it the same header-only carve-out solid.png already had, in both test files. The four well-conditioned fixtures keep strict full byte equality. Also document the divergence class on Encode itself rather than only in test comments, add a sub-image regression spec, and use the max builtin over math.Max. The toNRGBA Rect.Min gate turns out to protect nothing — SubImage re-slices Pix so Pix[0] is the Rect.Min pixel and the loops read it correctly either way — so its comment, which claimed the opposite, is corrected. The gate is kept for now; removing it is a separate call. * test(thumbhash): benchmark against blurhash at the pipeline input size * feat(artwork): persist a thumbhash alongside the blurhash The artwork table is content-addressed, so this costs one row per unique image rather than per entity. Measured on a 25k-image library: +0.6MB on a 727MB DB. The column is added to the existing add_artwork_tables migration rather than a new one, since #5847 has not been released and the table it extends ships in that same PR. * feat(artwork): hydrate and expose thumbHash on every entity ItemArtworkInfo.Image() is the single projection every hydration branch goes through, so adding the field there covers albums, artists, playlists and both the own-art and inherited media-file branches. * feat(artwork): encode blurhash and thumbhash from one 100px thumbnail thumbnailSize drops 128 -> 100 so a single CatmullRom scale feeds both encoders; thumbhash hard-rejects anything larger and a second downscale would cost more than shrinking the shared one. decodeArtwork on a 1000x1000 JPEG, before -> after: 15.53ms -> 15.45ms/op, 5878122 -> 5014796 B/op, 37 -> 75 allocs/op Time is a wash because the JPEG decode dominates; the 863KB drop is the smaller thumbnail more than paying for thumbhash's added work. This rewrites every blurhash value, so it must land before #5847 reaches a release: Finamp keys its cover cache and download dedup on that value. * feat(ui): use thumbhash for the cover loading placeholder Adds a local decoder rather than the thumbhash npm package, matching the local blurhash decoder it replaces. Pixels are pinned against evanw/thumbhash's reference decoder via the vendored copy already in the encoder's testdata. Unlike a blurhash, a thumbhash carries its own approximate aspect, so a record with no dimensions now falls back to that instead of to a square. The blurHash API field stays: Jellyfin clients and third-party native clients still consume it. * perf(artwork): hand both hash encoders one NRGBA thumbnail makeThumbnail emitted a premultiplied *image.RGBA, so thumbhash allocated and un-premultiplied a full copy on every image while blurhash paid nothing. It now emits *image.NRGBA, which thumbhash wants as-is, and blurhash reads that type directly, premultiplying per pixel to keep its output identical. thumbhash.Encode at the pipeline's 100x100 input: 134200 -> 95300 ns/op, 56188 -> 15163 B/op, 37 -> 35 allocs/op decodeArtwork on a 1000x1000 JPEG: 5014796 -> 4973800 B/op, exactly the conversion that is gone The scaler costs the same into either destination (7.33ms vs 7.34ms measured), so no time is traded for this. * test(artwork): drop the decodeArtwork benchmark It existed to capture the before/after baseline for the 128->100 thumbnail change, which it has served. As an ongoing guard it is misleading: a 1000x1000 JPEG decode is ~97% of the 15ms it measures, so the two encoders it would be reached for are ~2.6% of the signal. It reported a phantom 2.8% regression for the NRGBA thumbnail change that isolating the scaler disproved. The per-encoder benchmarks in blurhash/ and thumbhash/ cover the part that actually changes. * test(artwork): benchmark both hash encoders from one file benchImage and BenchmarkEncodeAtInputSize existed in both encoder packages, and blurhash's copy had drifted into a third inline duplicate once both benches moved to NRGBA input. The head-to-head now lives in core/artwork, which already imports both encoders and can pin the input to the real thumbnailSize constant. The gradient builder moves to tests, which both packages already import via their suite bootstrap. thumbhash keeps a shipped-vs-reference benchmark, since referenceEncode is test-only and cannot be reached from core/artwork. * test(thumbhash): drop the shipped-vs-reference benchmark The two-pass rewrite's advantage over the naive port is already recorded in its commit message; carrying the benchmark to re-derive it has no ongoing use. reference_test.go stays: besides the benchmark baseline it holds the differential oracle the golden-vector and randomised specs assert against. * test(thumbhash): assert against reference goldens, drop the Go port The reference port existed to be a differential oracle, but its packing half was a verbatim copy of production pack(), so it was not as independent as it looked. Only the 500-image randomised spec needed it; the six PNG fixtures cannot reach random sizes, aspects, or both coefficient layouts. gen_generated.mjs now emits 300 vectors from evanw's actual JS. Their pixels are a pure function of their index, so Go rebuilds them byte-for-byte and only the hashes are committed (16KB). The oracle is now the reference itself rather than a hand transcription of it. Also from the cleanup pass: - maxCX/maxCY scanned every term to recover a bound that is just the widest coefficient region - tests.GradientImage duplicated generateGradientImage three files away - BenchmarkHashEncodersAtInputSize was also BenchmarkHashEncoders/*/100x100 - processor had two internal test files with no rule for which gets a new spec - three blurhash specs differing only by alpha became a DescribeTable - the shared mock's GetInfoForItems projection had drifted from the real query * refactor(model): keep the blurhash off native JSON and fold its specs Nothing on the native API consumes blurHash: the web UI reads thumbHash, and Jellyfin's mappers take the Go field directly rather than through this serialization. It was shipping ~40 unused bytes on every row of every list response, on a surface that accretes clients once published. The JSON specs were five marshal-and-check-keys blocks over the same struct; they collapse to one populated case, one bare case, and a guard that the blurhash stays off the wire. * refactor(persistence): simplify the artwork dangling-row purge helper purgeDangling took a bound executeSQL method value and a table name only because its two callers live on unrelated types. Neither parameter was load-bearing: executeSQL is a value receiver on sqlRepository that never reads tableName, and both callers already hold an sqlRepository whose tableName is the table being purged. Taking that struct collapses both parameters into the receiver. itemArtworkSQL wrapped sqlRepository without adding a single method, so the items field becomes a plain sqlRepository. danglingItemArtworkKinds is also read by EnqueueAllMissing, which enqueues entities that have no artwork row yet - neither a purge nor anything specific to item_artwork. Renamed to artworkOwnerTables to describe both of its uses. * fix(artwork): keep sweeping past a store file that cannot be removed Sweep returned the os.Remove error straight out of WalkDir, so a single unremovable file abandoned the rest of the walk. Every later orphan, stale mime variant and abandoned temp file then survived until the next prune, which would abort at the same place. Warn and carry on instead. The orphan-file loop in prune already had this resilience; Sweep is where it belongs, since it is the only step that reaches stray files and superseded variants. * refactor(artwork): let the sweep reclaim orphan files instead of prune Deleting unreferenced artwork rows took five round trips: snapshot the orphan hashes, fetch their mimes, delete, re-fetch to see which rows actually went, then remove each file. The last four exist only to work out which files to delete - but the sweep that runs moments later already derives exactly that from the database, since GetAllMimes is read after the delete and Sweep removes any file whose hash has no row, under the same mtime guard. The sweep is also the more thorough of the two: it reclaims stale mime variants of an orphan hash, which the per-hash removal never looked at. Delete the rows in one predicate-only statement and let the sweep follow. The orphan predicate now runs once per prune instead of once for the snapshot plus once per 200-hash delete chunk, and with no hash list to bind there is nothing left to chunk. GetOrphanHashes and GetImages had no other caller and are gone. Dropping them also removes the mock's OrphanHashes lever, which let a spec declare a hash orphaned while item_artwork still referenced it - a state the SQL repository could never produce, since both predicates were always identical. * refactor(model): name the artwork mime lookup for what it is keyed by GetAllMimes reads as a collection of mime types; it returns a hash -> mime index over every stored artwork. GetMimeByHash names the key, matching the convention the other map-returning repository methods already follow (CountBySuffix, CountByClient). * refactor(artwork): split the repository's deletes from its purges by name Six removal methods across the two artwork repositories used Delete and Purge interchangeably, so neither prefix told you anything. There is a real distinction underneath: Delete methods are caller-directed and take the rows to remove, while Purge methods are garbage collection - the repository finds rows whose referent is gone by joining against the owning table, and returns a count because the caller cannot know one. The signatures already followed that split; only the names did not. DeleteOrphans was the odd one out, taking a grace cutoff rather than a target and returning a count, so it becomes PurgeOrphans. PurgeDanglingItemArtwork loses the Artwork it repeats from its own interface and keeps the Items that says which of that interface's two tables it means. DeleteForItem was DeleteForItems with one id - squirrel emits = for a scalar and IN for a slice against the same predicate - so its one caller passes a slice instead. Dangling and Orphan stay distinct: dangling is a row whose entity is gone, orphan is an image no state row references. * refactor(artwork): drop the orphaned store remover, aggregate sweep failures Review follow-ups to the prune rework. ImageStore.Remove lost its only production caller when the sweep took over orphan-file reclamation. It carried its own copy of the mtime guard that now lives solely in Sweep, so the two could drift, and it stood as an invitation to grow a second reclaim path. Its specs either duplicated Sweep's own coverage or tested nothing else, so they go with it; the one unrelated test that used it to delete a file calls os.Remove directly. Sweep warned once per file it could not remove, which is fine for a stray permission bit and useless for a store mounted read-only - a large library would emit one line per aged file on every prune, and prune would still report success. Count the failures instead and warn once, with the count and the last error, so a systemic failure is legible without drowning the log. Also: the orphan log line counts rows, not files, and now says so; and the blocked-removal spec asserts its two fixtures really do land in different shards, since the assertion is vacuous if they collide. * fix(ui): show the placeholder when a refresh swaps in an uncached cover A cover whose hash changed under a mounted grid went blank for the whole fetch, instead of falling back to its thumbhash. Artwork decided 'this blob was already cached, skip the placeholder' once at mount and never revisited it, so every later url for that record inherited the answer. The fix has to live in useImageUrl. imgUrl is state cleared in an effect, so on the render where url changes it still holds the previous url's blob - any caller inspecting it reads the new url as already cached. Only the hook can see cache.get(url) for the url actually being rendered, so it now reports fromCache and resyncs its state during render rather than one render later. Two shapes were tried and rejected against a live server before this one: a ref recomputed per url, which a render-phase resync discards without rolling back the ref, and the same tracking in Artwork state, which still samples imgUrl before React re-runs the component. Verified on a running instance with cover-art responses held open: the thumbhash now covers the whole window and stays under the image until the fade ends. * fix(ui): keep the placeholder for a cover whose fetch failed useImageUrl caches a failed fetch as an entry with no blob, so treating every cached entry as instantly painted suppressed the placeholder on remount: the cover became an empty box instead of falling back to its thumbhash. Only a cached blob counts as instant. Caught by Codex on the previous commit, which introduced fromCache with the looser predicate. * feat(artwork): capture each image's dominant colour A flat colour is the one placeholder a client can paint with no decoding at all, so it can cover the frames before a thumbhash canvas even renders. Extract it from the same 100px thumbnail the two hash encoders already share, and expose it as dominantColor on the native API. Dominant means presence, not salience: the largest cluster wins, so a white sleeve reports white. That is what a placeholder needs. An accent colour is the opposite question and is deliberately not answered here - it depends on the client's theme, and by the time a client needs one it has the cover pixels and can compute whatever suits it. Bins at 4 bits per channel, then merges perceptually-near bins in Oklab before picking the winner: a gradient splits across adjacent bins and would otherwise lose to a smaller flat region. Measured at 246us per image on the weakest target hardware we care about (Celeron N5105), against ~15.8 images/s for the pipeline around it. The column goes into the feature's original migration rather than a new one: computing it later would mean re-decoding every image in the library. * fix(artwork): ignore image candidates that cannot be fetched bestImageURL only rejected URLs that url.Parse itself refuses, which is almost nothing: relative paths and any scheme parse cleanly. A candidate like images/big.jpg or ftp://host/big.jpg therefore won on size, and since the function returns a single URL whose failure ends that agent's turn, the agent's valid smaller images were never tried. Two details made it easy to hit rather than theoretical. Size is frequently 0 for every candidate, and only a strictly larger one replaces the first, so an unfetchable entry in first position sticks. And these strings come straight from plugins, where a relative path is an ordinary mistake. Accept only absolute http/https URLs with a host. * revert(ui): move detail-page scroll handling to its own branch Reverts 40153bd43. Scroll position carrying over between pages is stock React Router behaviour rather than something this branch introduced, so the fix does not belong in an artwork PR that is otherwise ready for review. The work continues on fix/scroll-restoration, off master, where it grew into per-route restoration: a new page starts at the top and going back returns to where you were, which the scroll-to-top could not do. * fix(ui): fade cover art consistently, and shorten the fade to 150ms The mounts with its blob URL already set, so onLoad often fires in the same frame the element is inserted. The opacity:0 start state never gets painted, the transition has no value to animate from, and the cover pops in instead of fading. Only tiles whose decode happened to straddle a paint boundary actually faded, which made a grid load look ragged: 4 to 6 of 18 covers faded, varying between runs. Deferring the decoded flag by two animation frames guarantees the hidden state is painted first. Measured on a live grid afterwards, 17 of 18 covers fade, the 18th having a cached blob and taking the intentional instant path, and the tail after the last byte arrives drops from ~440ms to ~163ms. The duration also drops from 500ms to 150ms. That 500ms mirrored master's fade, but master paints no placeholder behind the image and needs a slow blend to cover a blank tile. Here a thumbhash already fills the gap, so a long crossfade only delays the grid settling, measured at 145-190ms slower to full opacity than master. --------- Signed-off-by: Deluan --- adapters/deezer/deezer.go | 13 +- adapters/deezer/deezer_test.go | 48 ++ adapters/lastfm/agent.go | 34 +- adapters/lastfm/agent_test.go | 32 +- cmd/pls.go | 4 +- cmd/root.go | 66 ++ cmd/scan.go | 4 +- cmd/wire_gen.go | 109 ++- cmd/wire_injectors.go | 8 + conf/configuration.go | 8 + consts/consts.go | 10 + core/agents/agents.go | 36 + core/agents/agents_test.go | 72 ++ core/artwork/agent_images.go | 100 +++ core/artwork/agent_images_test.go | 262 ++++++ core/artwork/artwork.go | 422 +++++++-- core/artwork/artwork_internal_test.go | 629 -------------- core/artwork/artwork_suite_test.go | 48 +- core/artwork/artwork_test.go | 522 ++++++++++- core/artwork/benchmark_e2e_test.go | 189 ---- core/artwork/benchmark_helpers_test.go | 9 + core/artwork/blurhash/blurhash.go | 191 ++++ core/artwork/blurhash/blurhash_suite_test.go | 17 + core/artwork/blurhash/blurhash_test.go | 137 +++ core/artwork/cache_warmer.go | 162 ---- core/artwork/cache_warmer_test.go | 245 ------ core/artwork/{reader_disc.go => disc.go} | 128 ++- .../{reader_disc_test.go => disc_test.go} | 0 core/artwork/dominant/dominant.go | 127 +++ core/artwork/dominant/dominant_suite_test.go | 17 + core/artwork/dominant/dominant_test.go | 91 ++ core/artwork/e2e/acquire_serve_test.go | 326 +++++++ core/artwork/e2e/album_test.go | 217 ++--- core/artwork/e2e/artist_test.go | 113 +-- core/artwork/e2e/disc_test.go | 128 +-- core/artwork/e2e/e2e_suite_test.go | 83 ++ core/artwork/e2e/helpers_test.go | 184 ---- core/artwork/e2e/mediafile_test.go | 71 +- core/artwork/e2e/playlist_test.go | 133 ++- core/artwork/e2e/radio_test.go | 29 +- core/artwork/e2e/resolution_harness_test.go | 365 ++++++++ core/artwork/e2e/suite_test.go | 120 --- core/artwork/e2e/testdata/embedded_art.mp3 | Bin 64223 -> 0 bytes .../{reader_album.go => folders_album.go} | 90 +- core/artwork/folders_album_test.go | 214 +++++ core/artwork/folders_artist.go | 210 +++++ core/artwork/folders_artist_paths_test.go | 117 +++ core/artwork/folders_artist_test.go | 47 + core/artwork/gate.go | 120 +++ core/artwork/hash_encoder_bench_test.go | 51 ++ core/artwork/housekeeping.go | 129 +++ core/artwork/housekeeping_test.go | 261 ++++++ core/artwork/image_cache.go | 62 +- core/artwork/image_store.go | 155 ++++ core/artwork/image_store_test.go | 228 +++++ core/artwork/library_fs.go | 22 +- core/artwork/library_fs_test.go | 7 + core/artwork/playlist_cover.go | 116 +++ core/artwork/processor.go | 311 +++++++ core/artwork/processor_test.go | 495 +++++++++++ core/artwork/prune.go | 63 ++ core/artwork/prune_test.go | 239 +++++ core/artwork/reader_album_test.go | 481 ----------- core/artwork/reader_artist.go | 359 -------- core/artwork/reader_artist_test.go | 761 ---------------- core/artwork/reader_mediafile.go | 82 -- core/artwork/reader_playlist.go | 269 ------ core/artwork/reader_radio.go | 40 - core/artwork/reader_radio_test.go | 84 -- core/artwork/reader_resized.go | 209 ----- core/artwork/reader_resized_test.go | 176 ---- core/artwork/resize.go | 144 ++++ core/artwork/resolve.go | 452 ++++++++++ core/artwork/resolve_test.go | 632 ++++++++++++++ core/artwork/sources.go | 72 +- core/artwork/thumbhash/testdata/alpha.png | Bin 0 -> 24912 bytes .../thumbhash/testdata/gen_fixtures.mjs | 90 ++ .../thumbhash/testdata/gen_generated.mjs | 28 + .../artwork/thumbhash/testdata/gen_golden.mjs | 37 + .../artwork/thumbhash/testdata/generated.json | 1 + core/artwork/thumbhash/testdata/golden.json | 8 + core/artwork/thumbhash/testdata/landscape.png | Bin 0 -> 20198 bytes core/artwork/thumbhash/testdata/portrait.png | Bin 0 -> 20102 bytes core/artwork/thumbhash/testdata/solid.png | Bin 0 -> 209 bytes core/artwork/thumbhash/testdata/square.png | Bin 0 -> 33071 bytes core/artwork/thumbhash/testdata/thumbhash.js | 288 +++++++ core/artwork/thumbhash/testdata/tiny.png | Bin 0 -> 70 bytes core/artwork/thumbhash/thumbhash.go | 257 ++++++ .../artwork/thumbhash/thumbhash_suite_test.go | 17 + core/artwork/thumbhash/thumbhash_test.go | 194 +++++ core/{image_upload.go => artwork/uploader.go} | 54 +- .../uploader_test.go} | 67 +- core/artwork/wire_providers.go | 4 +- core/artwork/worker.go | 318 +++++++ core/artwork/worker_soak_test.go | 146 ++++ core/artwork/worker_test.go | 813 ++++++++++++++++++ core/artwork/worker_timing_test.go | 76 ++ core/external/provider.go | 85 +- core/external/provider_albumimage_test.go | 365 -------- core/external/provider_artistimage_test.go | 426 --------- core/playlists/import_test.go | 44 +- core/playlists/playlists.go | 17 +- core/playlists/playlists_test.go | 38 +- core/playlists/rest_adapter_test.go | 4 +- core/wire_providers.go | 2 - db/db.go | 12 + .../20260722023032_add_artwork_tables.sql | 46 + go.mod | 2 +- log/formatters.go | 11 +- log/formatters_test.go | 6 + model/album.go | 2 + model/artist.go | 2 + model/artwork.go | 144 ++++ model/artwork_id.go | 82 +- model/artwork_id_test.go | 63 +- model/artwork_test.go | 73 ++ model/datastore.go | 2 + model/mediafile.go | 16 +- model/playlist.go | 2 + model/radio.go | 4 + model/radio_test.go | 5 +- persistence/album_repository.go | 36 +- persistence/album_repository_test.go | 28 + persistence/artist_repository.go | 37 +- persistence/artist_repository_test.go | 12 + persistence/artwork_hydration.go | 181 ++++ persistence/artwork_hydration_test.go | 773 +++++++++++++++++ persistence/artwork_queue_repository.go | 149 ++++ persistence/artwork_queue_repository_test.go | 284 ++++++ persistence/artwork_repository.go | 176 ++++ persistence/artwork_repository_test.go | 248 ++++++ persistence/e2e/e2e_suite_test.go | 5 +- persistence/mediafile_repository.go | 43 +- persistence/mediafile_repository_test.go | 36 + persistence/persistence.go | 8 + persistence/playlist_repository.go | 66 +- persistence/playlist_repository_test.go | 88 ++ persistence/playlist_track_repository.go | 5 +- persistence/radio_repository.go | 45 +- persistence/radio_repository_test.go | 33 + persistence/sql_base_repository.go | 13 +- scanner/controller.go | 9 +- scanner/controller_test.go | 3 +- scanner/phase_1_folders.go | 51 +- scanner/phase_4_playlists.go | 11 +- scanner/phase_4_playlists_test.go | 28 +- scanner/scanner.go | 32 +- scanner/scanner_benchmark_test.go | 5 +- scanner/scanner_multilibrary_test.go | 5 +- scanner/scanner_selective_test.go | 5 +- scanner/scanner_test.go | 94 +- server/imghttp/headers.go | 67 ++ server/imghttp/headers_test.go | 117 +++ server/imghttp/imghttp_suite_test.go | 17 + server/jellyfin/dto/blurhash.go | 36 - server/jellyfin/dto/blurhash_test.go | 27 - server/jellyfin/dto/dto.go | 15 +- server/jellyfin/dto/mappers.go | 137 ++- server/jellyfin/dto/mappers_test.go | 283 +++++- server/jellyfin/e2e/e2e_suite_test.go | 11 +- server/jellyfin/e2e/playlists_test.go | 22 +- server/jellyfin/images.go | 29 +- server/jellyfin/images_test.go | 54 +- server/jellyfin/items.go | 13 +- server/jellyfin/similar.go | 2 +- server/nativeapi/artists.go | 12 +- server/nativeapi/artwork.go | 42 + server/nativeapi/artwork_test.go | 95 ++ server/nativeapi/image_upload.go | 4 +- server/nativeapi/native_api.go | 6 +- server/nativeapi/radios.go | 12 +- server/public/handle_images.go | 16 +- server/public/handle_images_test.go | 63 ++ server/subsonic/browsing.go | 18 +- server/subsonic/browsing_test.go | 54 ++ server/subsonic/e2e/e2e_suite_test.go | 22 +- server/subsonic/e2e/subsonic_artwork_test.go | 328 +++++++ .../e2e/subsonic_multilibrary_test.go | 5 +- .../e2e/subsonic_sonic_similarity_test.go | 3 +- server/subsonic/helpers.go | 41 +- server/subsonic/helpers_test.go | 76 ++ server/subsonic/media_retrieval.go | 13 +- server/subsonic/media_retrieval_test.go | 88 +- server/subsonic/playlists.go | 2 +- server/subsonic/playlists_test.go | 13 + server/subsonic/radio.go | 4 +- server/subsonic/radio_test.go | 19 +- server/subsonic/searching.go | 12 +- server/subsonic/searching_test.go | 30 + tests/harness/harness.go | 47 +- tests/mock_album_repo.go | 14 + tests/mock_artist_repo.go | 9 + tests/mock_artwork_queue_repo.go | 258 ++++++ tests/mock_artwork_repo.go | 175 ++++ tests/mock_data_store.go | 78 +- tests/mock_mediafile_repo.go | 12 + tests/mock_playlist_repo.go | 9 + tests/mock_playlist_track_repo.go | 8 + tests/mock_radio_repository.go | 11 +- ui/src/album/AlbumDetails.jsx | 39 +- ui/src/album/AlbumGridView.jsx | 43 +- ui/src/album/AlbumGridView.test.jsx | 18 + ui/src/album/AlbumList.jsx | 18 +- ui/src/album/AlbumTableView.jsx | 6 +- ui/src/album/useRollChanged.jsx | 13 + ui/src/album/useRollChanged.test.jsx | 73 ++ ui/src/artist/ArtistList.jsx | 4 +- ui/src/artist/ArtistSimpleList.jsx | 4 +- ui/src/artist/DesktopArtistDetails.jsx | 47 +- ui/src/artist/MobileArtistDetails.jsx | 49 +- ui/src/common/Artwork.jsx | 127 +++ ui/src/common/Artwork.test.jsx | 232 +++++ ui/src/common/ArtworkAvatar.jsx | 40 + ui/src/common/CoverArtAvatar.jsx | 52 -- ui/src/common/ThumbHashCanvas.jsx | 75 ++ ui/src/common/ThumbHashCanvas.test.jsx | 95 ++ ui/src/common/index.js | 2 +- ui/src/common/useImageUrl.js | 14 +- ui/src/common/useImageUrl.test.js | 3 + ui/src/common/useResourceRefresh.jsx | 48 +- ui/src/common/useResourceRefresh.test.js | 185 +++- ui/src/playlist/PlaylistDetails.jsx | 49 +- ui/src/playlist/PlaylistList.jsx | 4 +- ui/src/playlist/PlaylistShow.jsx | 2 +- ui/src/playlist/PlaylistSongs.jsx | 3 +- ui/src/radio/RadioList.jsx | 32 +- ui/src/subsonic/index.js | 14 +- ui/src/subsonic/index.test.js | 41 + ui/src/utils/thumbhash.js | 138 +++ ui/src/utils/thumbhash.test.js | 66 ++ utils/cache/file_caches.go | 9 + utils/cache/file_caches_test.go | 87 ++ utils/cache/spread_fs.go | 2 +- utils/cache/spread_fs_test.go | 36 + utils/cache/spread_fs_unix.go | 18 + utils/cache/spread_fs_windows.go | 15 + 236 files changed, 16349 insertions(+), 6350 deletions(-) create mode 100644 core/artwork/agent_images.go create mode 100644 core/artwork/agent_images_test.go delete mode 100644 core/artwork/artwork_internal_test.go delete mode 100644 core/artwork/benchmark_e2e_test.go create mode 100644 core/artwork/blurhash/blurhash.go create mode 100644 core/artwork/blurhash/blurhash_suite_test.go create mode 100644 core/artwork/blurhash/blurhash_test.go delete mode 100644 core/artwork/cache_warmer.go delete mode 100644 core/artwork/cache_warmer_test.go rename core/artwork/{reader_disc.go => disc.go} (60%) rename core/artwork/{reader_disc_test.go => disc_test.go} (100%) create mode 100644 core/artwork/dominant/dominant.go create mode 100644 core/artwork/dominant/dominant_suite_test.go create mode 100644 core/artwork/dominant/dominant_test.go create mode 100644 core/artwork/e2e/acquire_serve_test.go create mode 100644 core/artwork/e2e/e2e_suite_test.go delete mode 100644 core/artwork/e2e/helpers_test.go create mode 100644 core/artwork/e2e/resolution_harness_test.go delete mode 100644 core/artwork/e2e/suite_test.go delete mode 100644 core/artwork/e2e/testdata/embedded_art.mp3 rename core/artwork/{reader_album.go => folders_album.go} (63%) create mode 100644 core/artwork/folders_album_test.go create mode 100644 core/artwork/folders_artist.go create mode 100644 core/artwork/folders_artist_paths_test.go create mode 100644 core/artwork/folders_artist_test.go create mode 100644 core/artwork/gate.go create mode 100644 core/artwork/hash_encoder_bench_test.go create mode 100644 core/artwork/housekeeping.go create mode 100644 core/artwork/housekeeping_test.go create mode 100644 core/artwork/image_store.go create mode 100644 core/artwork/image_store_test.go create mode 100644 core/artwork/playlist_cover.go create mode 100644 core/artwork/processor.go create mode 100644 core/artwork/processor_test.go create mode 100644 core/artwork/prune.go create mode 100644 core/artwork/prune_test.go delete mode 100644 core/artwork/reader_album_test.go delete mode 100644 core/artwork/reader_artist.go delete mode 100644 core/artwork/reader_artist_test.go delete mode 100644 core/artwork/reader_mediafile.go delete mode 100644 core/artwork/reader_playlist.go delete mode 100644 core/artwork/reader_radio.go delete mode 100644 core/artwork/reader_radio_test.go delete mode 100644 core/artwork/reader_resized.go delete mode 100644 core/artwork/reader_resized_test.go create mode 100644 core/artwork/resize.go create mode 100644 core/artwork/resolve.go create mode 100644 core/artwork/resolve_test.go create mode 100644 core/artwork/thumbhash/testdata/alpha.png create mode 100644 core/artwork/thumbhash/testdata/gen_fixtures.mjs create mode 100644 core/artwork/thumbhash/testdata/gen_generated.mjs create mode 100644 core/artwork/thumbhash/testdata/gen_golden.mjs create mode 100644 core/artwork/thumbhash/testdata/generated.json create mode 100644 core/artwork/thumbhash/testdata/golden.json create mode 100644 core/artwork/thumbhash/testdata/landscape.png create mode 100644 core/artwork/thumbhash/testdata/portrait.png create mode 100644 core/artwork/thumbhash/testdata/solid.png create mode 100644 core/artwork/thumbhash/testdata/square.png create mode 100644 core/artwork/thumbhash/testdata/thumbhash.js create mode 100644 core/artwork/thumbhash/testdata/tiny.png create mode 100644 core/artwork/thumbhash/thumbhash.go create mode 100644 core/artwork/thumbhash/thumbhash_suite_test.go create mode 100644 core/artwork/thumbhash/thumbhash_test.go rename core/{image_upload.go => artwork/uploader.go} (55%) rename core/{image_upload_test.go => artwork/uploader_test.go} (60%) create mode 100644 core/artwork/worker.go create mode 100644 core/artwork/worker_soak_test.go create mode 100644 core/artwork/worker_test.go create mode 100644 core/artwork/worker_timing_test.go delete mode 100644 core/external/provider_albumimage_test.go delete mode 100644 core/external/provider_artistimage_test.go create mode 100644 db/migrations/20260722023032_add_artwork_tables.sql create mode 100644 model/artwork.go create mode 100644 model/artwork_test.go create mode 100644 persistence/artwork_hydration.go create mode 100644 persistence/artwork_hydration_test.go create mode 100644 persistence/artwork_queue_repository.go create mode 100644 persistence/artwork_queue_repository_test.go create mode 100644 persistence/artwork_repository.go create mode 100644 persistence/artwork_repository_test.go create mode 100644 server/imghttp/headers.go create mode 100644 server/imghttp/headers_test.go create mode 100644 server/imghttp/imghttp_suite_test.go delete mode 100644 server/jellyfin/dto/blurhash.go delete mode 100644 server/jellyfin/dto/blurhash_test.go create mode 100644 server/nativeapi/artwork.go create mode 100644 server/nativeapi/artwork_test.go create mode 100644 server/subsonic/e2e/subsonic_artwork_test.go create mode 100644 tests/mock_artwork_queue_repo.go create mode 100644 tests/mock_artwork_repo.go create mode 100644 ui/src/album/AlbumGridView.test.jsx create mode 100644 ui/src/album/useRollChanged.jsx create mode 100644 ui/src/album/useRollChanged.test.jsx create mode 100644 ui/src/common/Artwork.jsx create mode 100644 ui/src/common/Artwork.test.jsx create mode 100644 ui/src/common/ArtworkAvatar.jsx delete mode 100644 ui/src/common/CoverArtAvatar.jsx create mode 100644 ui/src/common/ThumbHashCanvas.jsx create mode 100644 ui/src/common/ThumbHashCanvas.test.jsx create mode 100644 ui/src/utils/thumbhash.js create mode 100644 ui/src/utils/thumbhash.test.js create mode 100644 utils/cache/spread_fs_unix.go create mode 100644 utils/cache/spread_fs_windows.go diff --git a/adapters/deezer/deezer.go b/adapters/deezer/deezer.go index d8e832cf1..742b8b1a5 100644 --- a/adapters/deezer/deezer.go +++ b/adapters/deezer/deezer.go @@ -70,16 +70,27 @@ func (s *deezerAgent) GetArtistImages(ctx context.Context, _, name, _ string) ([ {artist.PictureSmall, deezerApiPictureSmallSize}, } for _, imgData := range possibleImages { - if imgData.URL != "" { + if imgData.URL != "" && !isPlaceholderPicture(imgData.URL) { res = append(res, agents.ExternalImage{ URL: imgData.URL, Size: imgData.Size, }) } } + if len(res) == 0 { + return nil, agents.ErrNotFound + } return res, nil } +// deezerEmptyPicturePath is Deezer's empty-image-id path shape for artists with no picture +// (…/images/artist//1000x1000-…), which serves a generic silhouette on any CDN host. +const deezerEmptyPicturePath = "/images/artist//" + +func isPlaceholderPicture(url string) bool { + return strings.Contains(url, deezerEmptyPicturePath) +} + func (s *deezerAgent) searchArtist(ctx context.Context, name string) (*Artist, error) { artists, err := s.client.searchArtists(ctx, name, deezerArtistSearchLimit) if errors.Is(err, ErrNotFound) || len(artists) == 0 { diff --git a/adapters/deezer/deezer_test.go b/adapters/deezer/deezer_test.go index f478af115..360db1f13 100644 --- a/adapters/deezer/deezer_test.go +++ b/adapters/deezer/deezer_test.go @@ -94,6 +94,54 @@ var _ = Describe("deezerAgent", func() { }) }) + Describe("GetArtistImages", func() { + var agent *deezerAgent + var httpClient *fakeHttpClient + + BeforeEach(func() { + httpClient = &fakeHttpClient{} + agent = &deezerAgent{ + dataStore: &tests.MockDataStore{}, + client: newClient(httpClient), + } + }) + + It("returns the real images when the artist has a picture", func() { + httpClient.mock("https://api.deezer.com/search/artist", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{"data":[ + {"id":412,"name":"Queen","nb_fan":12744378, + "picture_xl":"https://cdn-images.dzcdn.net/images/artist/abc/1000x1000-000000-80-0-0.jpg", + "picture_big":"https://cdn-images.dzcdn.net/images/artist/abc/500x500-000000-80-0-0.jpg"} + ],"total":1}`)), + }) + + images, err := agent.GetArtistImages(ctx, "", "Queen", "") + + Expect(err).ToNot(HaveOccurred()) + Expect(images).To(HaveLen(2)) + Expect(images[0].URL).To(ContainSubstring("1000x1000")) + }) + + It("returns ErrNotFound when the artist only has empty-id placeholder pictures", func() { + httpClient.mock("https://api.deezer.com/search/artist", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{"data":[ + {"id":412,"name":"Queen","nb_fan":12744378, + "picture_xl":"https://cdn-images.dzcdn.net/images/artist//1000x1000-000000-80-0-0.jpg", + "picture_big":"https://cdn-images.dzcdn.net/images/artist//500x500-000000-80-0-0.jpg", + "picture_medium":"https://cdn-images.dzcdn.net/images/artist//250x250-000000-80-0-0.jpg", + "picture_small":"https://cdn-images.dzcdn.net/images/artist//56x56-000000-80-0-0.jpg"} + ],"total":1}`)), + }) + + images, err := agent.GetArtistImages(ctx, "", "Queen", "") + + Expect(err).To(MatchError(agents.ErrNotFound)) + Expect(images).To(BeEmpty()) + }) + }) + Describe("GetArtistBiography - Language Fallback", func() { var agent *deezerAgent var httpClient *langAwareHttpClient diff --git a/adapters/lastfm/agent.go b/adapters/lastfm/agent.go index eb8f3d36e..f967595e3 100644 --- a/adapters/lastfm/agent.go +++ b/adapters/lastfm/agent.go @@ -93,7 +93,7 @@ func (l *lastfmAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid strin var resp agents.AlbumInfo for _, lang := range l.languages { var err error - a, err = l.callAlbumGetInfo(ctx, name, artist, mbid, lang) + a, err = l.callAlbumGetInfo(ctx, name, artist, lang) if err != nil { return nil, err } @@ -114,7 +114,7 @@ func (l *lastfmAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid strin } func (l *lastfmAgent) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) { - a, err := l.callAlbumGetInfo(ctx, name, artist, mbid, l.languages[0]) + a, err := l.callAlbumGetInfo(ctx, name, artist, l.languages[0]) if err != nil { return nil, err } @@ -286,22 +286,18 @@ func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string) return res, nil } -func (l *lastfmAgent) callAlbumGetInfo(ctx context.Context, name, artist, mbid string, lang string) (*Album, error) { - a, err := l.client.albumGetInfo(ctx, name, artist, mbid, lang) - var lfErr *lastFMError - isLastFMError := errors.As(err, &lfErr) - - if mbid != "" && (isLastFMError && lfErr.Code == 6) { - log.Debug(ctx, "LastFM/album.getInfo could not find album by mbid, trying again", "album", name, "mbid", mbid) - return l.callAlbumGetInfo(ctx, name, artist, "", lang) - } - +// callAlbumGetInfo matches on name+artist only. Last.fm's album.getInfo by MBID is unreliable — +// a correct MBID can return a different album (or none) — so the MBID is deliberately not passed. +func (l *lastfmAgent) callAlbumGetInfo(ctx context.Context, name, artist, lang string) (*Album, error) { + a, err := l.client.albumGetInfo(ctx, name, artist, "", lang) if err != nil { - if isLastFMError && lfErr.Code == 6 { - log.Debug(ctx, "Album not found", "album", name, "mbid", mbid, err) - } else { - log.Error(ctx, "Error calling LastFM/album.getInfo", "album", name, "mbid", mbid, err) + if lfErr, ok := errors.AsType[*lastFMError](err); ok && lfErr.Code == 6 { + // A not-found is a definitive absence, not a fault: return the shared sentinel so the + // artwork worker's breaker/transient checks don't retry it, and log it at Debug. + log.Debug(ctx, "Album not found in Last.fm", "album", name, "artist", artist) + return nil, agents.ErrNotFound } + log.Error(ctx, "Error calling LastFM/album.getInfo", "album", name, "artist", artist, err) return nil, err } return a, nil @@ -313,6 +309,12 @@ func (l *lastfmAgent) callArtistGetInfo(ctx context.Context, name string, lang s a, err := l.client.artistGetInfo(ctx, name, lang) if err != nil { + if lfErr, ok := errors.AsType[*lastFMError](err); ok && lfErr.Code == 6 { + // A not-found is a definitive absence, not a fault: return the shared sentinel so it + // doesn't trip the artwork worker's breaker, and log at Debug instead of Error. + log.Debug(ctx, "Artist not found in Last.fm", "artist", name) + return nil, agents.ErrNotFound + } log.Error(ctx, "Error calling LastFM/artist.getInfo", "artist", name, err) return nil, err } diff --git a/adapters/lastfm/agent_test.go b/adapters/lastfm/agent_test.go index 7e4e29294..94024b9ab 100644 --- a/adapters/lastfm/agent_test.go +++ b/adapters/lastfm/agent_test.go @@ -539,7 +539,10 @@ var _ = Describe("lastfmAgent", func() { URL: "https://www.last.fm/music/Cher/Believe", })) Expect(httpClient.RequestCount).To(Equal(1)) - Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("03c91c40-49a6-44a7-90e7-a700edf97a62")) + // MBID is deliberately not sent — album.getInfo matches on name+artist only. + Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty()) + Expect(httpClient.SavedRequest.URL.Query().Get("album")).To(Equal("Believe")) + Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("Cher")) }) It("returns empty images if no images are available", func() { @@ -558,7 +561,7 @@ var _ = Describe("lastfmAgent", func() { _, err := agent.GetAlbumInfo(ctx, "123", "U2", "mbid-1234") Expect(err).To(HaveOccurred()) Expect(httpClient.RequestCount).To(Equal(1)) - Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234")) + Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty()) }) It("returns an error if Last.fm call returns an error", func() { @@ -566,23 +569,17 @@ var _ = Describe("lastfmAgent", func() { _, err := agent.GetAlbumInfo(ctx, "123", "U2", "mbid-1234") Expect(err).To(HaveOccurred()) Expect(httpClient.RequestCount).To(Equal(1)) - Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234")) + Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty()) }) - It("returns an error if Last.fm call returns an error 6 and mbid is empty", func() { + It("returns an error when Last.fm returns an error 6 (album not found)", func() { httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200} - _, err := agent.GetAlbumInfo(ctx, "123", "U2", "") + _, err := agent.GetAlbumInfo(ctx, "123", "U2", "mbid-1234") Expect(err).To(HaveOccurred()) + // A definitive not-found must satisfy the sentinel, or the artwork worker retries it. + Expect(errors.Is(err, agents.ErrNotFound)).To(BeTrue()) Expect(httpClient.RequestCount).To(Equal(1)) - }) - - Context("MBID non existent in Last.fm", func() { - It("calls again when last.fm returns an error 6", func() { - httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200} - _, _ = agent.GetAlbumInfo(ctx, "123", "U2", "mbid-1234") - Expect(httpClient.RequestCount).To(Equal(2)) - Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty()) - }) + Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty()) }) }) @@ -613,6 +610,13 @@ var _ = Describe("lastfmAgent", func() { Expect(images[0].URL).To(Equal("https://lastfm.freetls.fastly.net/i/u/ar0/818148bf682d429dc21b59a73ef6f68e.png")) }) + It("maps a Last.fm error 6 (artist not found) to the shared not-found sentinel", func() { + apiClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200} + _, err := agent.GetArtistImages(ctx, "123", "Nonexistent Artist", "") + // Not a fault: runs of missing artists must not trip the worker's circuit breaker. + Expect(errors.Is(err, agents.ErrNotFound)).To(BeTrue()) + }) + It("returns empty list if image is the ignored default image", func() { fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json") apiClient.Res = http.Response{Body: fApi, StatusCode: 200} diff --git a/cmd/pls.go b/cmd/pls.go index 95cbe4eec..184ca6fe7 100644 --- a/cmd/pls.go +++ b/cmd/pls.go @@ -12,7 +12,7 @@ import ( "strings" "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -260,7 +260,7 @@ func runImport(ctx context.Context, files []string) { ctx = request.WithUser(ctx, *user) } - pls := playlists.NewPlaylists(ds, core.NewImageUploadService()) + pls := playlists.NewPlaylists(ds, artwork.NewUploader(ds)) for _, file := range files { absPath, err := filepath.Abs(file) diff --git a/cmd/root.go b/cmd/root.go index d9e766c32..ff1641bd3 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -11,6 +11,7 @@ import ( "github.com/go-chi/chi/v5/middleware" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -88,6 +89,9 @@ func runNavidrome(ctx context.Context) { g.Go(startInsightsCollector(ctx)) g.Go(scheduleDBAnalyzer(ctx)) g.Go(startPluginManager(ctx)) + artworkWorker := CreateArtworkWorker() + g.Go(startArtworkWorker(ctx, artworkWorker)) + g.Go(scheduleArtworkHousekeeping(ctx, artworkWorker)) g.Go(runInitialScan(ctx)) if conf.Server.Scanner.Enabled { g.Go(startScanWatcher(ctx)) @@ -344,6 +348,68 @@ func startPlaybackServer(ctx context.Context) func() error { } } +// startArtworkWorker starts the background artwork acquisition worker. It always +// runs; the queue is simply empty until something enqueues work into it. +func startArtworkWorker(ctx context.Context, worker *artwork.Worker) func() error { + return func() error { + log.Info(ctx, "Starting artwork worker") + return worker.Run(ctx) + } +} + +// scheduleArtworkHousekeeping runs the startup fingerprint backfill and registers the +// recurring stale-absent recheck and prune jobs. +func scheduleArtworkHousekeeping(ctx context.Context, worker *artwork.Worker) func() error { + return func() error { + schedulerInstance := scheduler.GetInstance() + + if _, err := schedulerInstance.Add(consts.ArtworkStaleAbsentRecheckSchedule, func() { + if err := worker.EnqueueStaleAbsentAll(ctx); err != nil { + log.Error(ctx, "Error enqueueing stale artwork rechecks", err) + } + if err := worker.EnqueueMissingAll(ctx); err != nil { + log.Error(ctx, "Error enqueueing missing artwork rechecks", err) + } + }); err != nil { + log.Error(ctx, "Error scheduling artwork stale-absent recheck", err) + } + + if _, err := schedulerInstance.Add(consts.ArtworkPruneSchedule, func() { + if err := worker.RunPrune(ctx); err != nil { + log.Error(ctx, "Error running artwork prune", err) + } + }); err != nil { + log.Error(ctx, "Error scheduling artwork prune", err) + } + + // Also run the missing-row recheck once at startup so a never-scanned entity is picked up + // immediately, not only on the next hourly tick (e.g. after enabling the feature). + if err := worker.EnqueueMissingAll(ctx); err != nil { + log.Error(ctx, "Error enqueueing missing artwork rechecks", err) + } + + backfilled, err := worker.Backfill(ctx) + if err != nil { + log.Error(ctx, "Error running artwork backfill", err) + return nil + } + if !backfilled { + return nil + } + log.Info(ctx, "Artwork backfill enqueued, scheduling a follow-up prune") + timer := time.NewTimer(consts.ArtworkPostBackfillPruneDelay) + defer timer.Stop() + select { + case <-timer.C: + if err := worker.RunPrune(ctx); err != nil { + log.Error(ctx, "Error running post-backfill artwork prune", err) + } + case <-ctx.Done(): + } + return nil + } +} + // startPluginManager starts the plugin manager, if configured. func startPluginManager(ctx context.Context) func() error { return func() error { diff --git a/cmd/scan.go b/cmd/scan.go index 320b401d4..08eccbf2a 100644 --- a/cmd/scan.go +++ b/cmd/scan.go @@ -9,7 +9,7 @@ import ( "os" "strings" - "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" @@ -82,7 +82,7 @@ func runScanner(ctx context.Context) { sqlDB := db.Db() defer db.Db().Close() ds := persistence.New(sqlDB) - pls := playlists.NewPlaylists(ds, core.NewImageUploadService()) + pls := playlists.NewPlaylists(ds, artwork.NewUploader(ds)) // Parse targets from command line or file var scanTargets []model.ScanTarget diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index c4a797ef4..4bdd48422 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -65,25 +65,18 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router { sqlDB := db.Db() dataStore := persistence.New(sqlDB) share := core.NewShare(dataStore) - imageUploadService := core.NewImageUploadService() - playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService) + uploader := artwork.NewUploader(dataStore) + playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader) insights := metrics.GetInstance(dataStore) - 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) - cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) - modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics) + modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics) watcher := scanner.GetWatcher(dataStore, modelScanner) + manager := plugins.GetManager(dataStore, broker, metricsMetrics) library := core.NewLibrary(dataStore, modelScanner, watcher, broker, manager) user := core.NewUser(dataStore, manager) maintenance := core.NewMaintenance(dataStore) - router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, imageUploadService) + router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, uploader) return router } @@ -91,23 +84,23 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { sqlDB := db.Db() dataStore := persistence.New(sqlDB) fileCache := artwork.GetImageCache() + imageStore := artwork.GetImageStore() fFmpeg := ffmpeg.New() + artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg) + transcodingCache := stream.GetTranscodingCache() + mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) + share := core.NewShare(dataStore) + archiver := core.NewArchiver(mediaStreamer, dataStore, share) + players := core.NewPlayers(dataStore) 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) - share := core.NewShare(dataStore) - archiver := core.NewArchiver(mediaStreamer, dataStore, share) - players := core.NewPlayers(dataStore) - cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) - imageUploadService := core.NewImageUploadService() - playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService) - modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics) + uploader := artwork.NewUploader(dataStore) + playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader) + modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics) playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager) playbackServer := playback.GetInstance(dataStore) lyricsLyrics := lyrics.NewLyrics(dataStore, manager) @@ -121,21 +114,22 @@ func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router { sqlDB := db.Db() dataStore := persistence.New(sqlDB) fileCache := artwork.GetImageCache() + imageStore := artwork.GetImageStore() 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) + artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg) transcodingCache := stream.GetTranscodingCache() mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg) players := core.NewPlayers(dataStore) + broker := events.GetBroker() + metricsMetrics := metrics.GetPrometheusInstance(dataStore) + manager := plugins.GetManager(dataStore, broker, metricsMetrics) playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager) - imageUploadService := core.NewImageUploadService() - playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService) + uploader := artwork.NewUploader(dataStore) + playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader) + agentsAgents := agents.GetAgents(dataStore, manager) + matcherMatcher := matcher.New(dataStore) + provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher) sonicSonic := sonic.New(dataStore, manager, matcherMatcher) lyricsLyrics := lyrics.NewLyrics(dataStore, manager) router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic, lyricsLyrics, broker) @@ -146,14 +140,9 @@ func CreatePublicRouter() *public.Router { sqlDB := db.Db() dataStore := persistence.New(sqlDB) fileCache := artwork.GetImageCache() + imageStore := artwork.GetImageStore() 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) + artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg) transcodingCache := stream.GetTranscodingCache() mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) share := core.NewShare(dataStore) @@ -193,38 +182,22 @@ func CreatePrometheus() metrics.Metrics { func CreateScanner(ctx context.Context) model.Scanner { sqlDB := db.Db() dataStore := persistence.New(sqlDB) - fileCache := artwork.GetImageCache() - fFmpeg := ffmpeg.New() broker := events.GetBroker() + uploader := artwork.NewUploader(dataStore) + playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader) 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) - cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) - imageUploadService := core.NewImageUploadService() - playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService) - modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics) + modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics) return modelScanner } func CreateScanWatcher(ctx context.Context) scanner.Watcher { sqlDB := db.Db() dataStore := persistence.New(sqlDB) - fileCache := artwork.GetImageCache() - fFmpeg := ffmpeg.New() broker := events.GetBroker() + uploader := artwork.NewUploader(dataStore) + playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader) 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) - cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) - imageUploadService := core.NewImageUploadService() - playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService) - modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics) + modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics) watcher := scanner.GetWatcher(dataStore, modelScanner) return watcher } @@ -236,6 +209,20 @@ func GetPlaybackServer() playback.PlaybackServer { return playbackServer } +func CreateArtworkWorker() *artwork.Worker { + sqlDB := db.Db() + dataStore := persistence.New(sqlDB) + imageStore := artwork.GetImageStore() + broker := events.GetBroker() + metricsMetrics := metrics.GetPrometheusInstance(dataStore) + manager := plugins.GetManager(dataStore, broker, metricsMetrics) + agentsAgents := agents.GetAgents(dataStore, manager) + fFmpeg := ffmpeg.New() + fileCache := artwork.GetImageCache() + worker := artwork.NewWorker(dataStore, imageStore, agentsAgents, fFmpeg, broker, fileCache) + return worker +} + func getPluginManager() *plugins.Manager { sqlDB := db.Db() dataStore := persistence.New(sqlDB) @@ -247,7 +234,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(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))) +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)), wire.Bind(new(playlists.ImageUploadService), new(artwork.Uploader))) func GetPluginManager(ctx context.Context) *plugins.Manager { manager := getPluginManager() diff --git a/cmd/wire_injectors.go b/cmd/wire_injectors.go index 94faa5af3..ae24b61fa 100644 --- a/cmd/wire_injectors.go +++ b/cmd/wire_injectors.go @@ -14,6 +14,7 @@ import ( "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playback" + "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/core/sonic" "github.com/navidrome/navidrome/db" @@ -56,6 +57,7 @@ var allProviders = wire.NewSet( wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)), + wire.Bind(new(playlists.ImageUploadService), new(artwork.Uploader)), ) func CreateDataStore() model.DataStore { @@ -136,6 +138,12 @@ func GetPlaybackServer() playback.PlaybackServer { )) } +func CreateArtworkWorker() *artwork.Worker { + panic(wire.Build( + allProviders, + )) +} + func getPluginManager() *plugins.Manager { panic(wire.Build( allProviders, diff --git a/conf/configuration.go b/conf/configuration.go index 9b49fb264..5122b15eb 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -144,6 +144,8 @@ type configOptions struct { DevArtworkThrottleBacklogLimit int DevArtworkThrottleBacklogTimeout time.Duration DevArtworkThrottleBuffered bool + DevArtworkWorkerConcurrency int + DevArtworkExternalMaxRPS int DevArtistInfoTimeToLive time.Duration DevAlbumInfoTimeToLive time.Duration DevExternalScanner bool @@ -1071,6 +1073,12 @@ func setViperDefaults() { viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit) viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout) viper.SetDefault("devartworkthrottlebuffered", true) + // Half the CPU count (min 2), so local resolution scales with the host but stays under the + // SQLite pool (MaxOpenConns) — leaving connections for the scanner, scrobbles and the UI. + viper.SetDefault("devartworkworkerconcurrency", max(2, runtime.NumCPU()/2)) + // External RPS gates outbound calls to third-party services (per service); it is bounded by + // their tolerance, not the host, so it stays a small constant regardless of CPU count. + viper.SetDefault("devartworkexternalmaxrps", 2) viper.SetDefault("devartistinfotimetolive", consts.ArtistInfoTimeToLive) viper.SetDefault("devalbuminfotimetolive", consts.AlbumInfoTimeToLive) viper.SetDefault("devexternalscanner", true) diff --git a/consts/consts.go b/consts/consts.go index e1a535c79..46906b8b5 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -24,6 +24,9 @@ const ( LastDBAnalyzeAttemptAtKey = "LastDBAnalyzeAttemptAt" DBAnalyzePendingKey = "DBAnalyzePending" DBAnalyzeFailureCountKey = "DBAnalyzeFailureCount" + // ArtConfFingerprintPropertyKey is the model.PropertyRepository key Backfill compares against + // to detect artwork-affecting config changes across restarts. + ArtConfFingerprintPropertyKey = "ArtConfFingerprint" UIAuthorizationHeader = "X-ND-Authorization" UIClientUniqueIDHeader = "X-ND-Client-Unique-Id" @@ -36,6 +39,10 @@ const ( DBAnalyzeCheckSchedule = "@every 30m" DBAnalyzeMaxAge = 24 * time.Hour + ArtworkStaleAbsentRecheckSchedule = "@every 1h" + ArtworkPruneSchedule = "@daily" + ArtworkPostBackfillPruneDelay = 10 * time.Minute + // 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 DefaultEncryptionKey = "just for obfuscation" @@ -80,6 +87,9 @@ const ( I18nFolder = "i18n" ScanIgnoreFile = ".ndignore" ArtworkFolder = "artwork" + // HashedArtworkFolder is a subtree of ArtworkFolder, kept apart from the name-addressed + // upload folders beside it so Prune's sweep never reaches them. + HashedArtworkFolder = "hashed" PlaceholderArtistArt = "artist-placeholder.webp" PlaceholderAlbumArt = "album-placeholder.webp" diff --git a/core/agents/agents.go b/core/agents/agents.go index ead6dacd0..348f7d4e7 100644 --- a/core/agents/agents.go +++ b/core/agents/agents.go @@ -124,6 +124,42 @@ func (a *Agents) AgentName() string { return "agents" } +// ArtistImageAgent pairs an enabled agent's name with its ArtistImageRetriever capability. +type ArtistImageAgent struct { + Name string + Retriever ArtistImageRetriever +} + +// AlbumImageAgent pairs an enabled agent's name with its AlbumImageRetriever capability. +type AlbumImageAgent struct { + Name string + Retriever AlbumImageRetriever +} + +// ArtistImageAgents returns the enabled agents implementing ArtistImageRetriever, +// in conf.Server.Agents order (same order the aggregate dispatch uses). +func (a *Agents) ArtistImageAgents() []ArtistImageAgent { + var result []ArtistImageAgent + for _, ea := range a.getEnabledAgentNames() { + if retriever, ok := a.getAgent(ea).(ArtistImageRetriever); ok { + result = append(result, ArtistImageAgent{Name: ea.name, Retriever: retriever}) + } + } + return result +} + +// AlbumImageAgents returns the enabled agents implementing AlbumImageRetriever, +// in conf.Server.Agents order (same order the aggregate dispatch uses). +func (a *Agents) AlbumImageAgents() []AlbumImageAgent { + var result []AlbumImageAgent + for _, ea := range a.getEnabledAgentNames() { + if retriever, ok := a.getAgent(ea).(AlbumImageRetriever); ok { + result = append(result, AlbumImageAgent{Name: ea.name, Retriever: retriever}) + } + } + return result +} + func (a *Agents) GetArtistMBID(ctx context.Context, id string, name string) (string, error) { switch id { case consts.UnknownArtistID: diff --git a/core/agents/agents_test.go b/core/agents/agents_test.go index 50285a084..e3087f995 100644 --- a/core/agents/agents_test.go +++ b/core/agents/agents_test.go @@ -362,6 +362,64 @@ var _ = Describe("Agents", func() { }) }) }) + + Describe("Image retriever enumeration", func() { + var ag *Agents + var artistImg, artistImg2 *testImageAgent + var albumImg, albumImg2 *testAlbumImageAgent + + BeforeEach(func() { + artistImg = &testImageAgent{Name: "artistImg"} + artistImg2 = &testImageAgent{Name: "artistImg2"} + albumImg = &testAlbumImageAgent{name: "albumImg"} + albumImg2 = &testAlbumImageAgent{name: "albumImg2"} + Register("artistImg", func(model.DataStore) Interface { return artistImg }) + Register("artistImg2", func(model.DataStore) Interface { return artistImg2 }) + Register("albumImg", func(model.DataStore) Interface { return albumImg }) + Register("albumImg2", func(model.DataStore) Interface { return albumImg2 }) + Register("noImages", func(model.DataStore) Interface { return &emptyAgent{} }) + }) + + Describe("ArtistImageAgents", func() { + It("returns only ArtistImageRetriever agents, named, in configured order", func() { + conf.Server.Agents = "artistImg,noImages,artistImg2" + ag = createAgents(ds, nil) + + result := ag.ArtistImageAgents() + Expect(result).To(HaveLen(2)) + Expect(result[0].Name).To(Equal("artistImg")) + Expect(result[0].Retriever).To(BeIdenticalTo(artistImg)) + Expect(result[1].Name).To(Equal("artistImg2")) + Expect(result[1].Retriever).To(BeIdenticalTo(artistImg2)) + }) + + It("is empty when external services are disabled", func() { + conf.Server.Agents = "" // what disableExternalServices() sets when EnableExternalServices=false + ag = createAgents(ds, nil) + Expect(ag.ArtistImageAgents()).To(BeEmpty()) + }) + }) + + Describe("AlbumImageAgents", func() { + It("returns only AlbumImageRetriever agents, named, in configured order", func() { + conf.Server.Agents = "albumImg,noImages,albumImg2" + ag = createAgents(ds, nil) + + result := ag.AlbumImageAgents() + Expect(result).To(HaveLen(2)) + Expect(result[0].Name).To(Equal("albumImg")) + Expect(result[0].Retriever).To(BeIdenticalTo(albumImg)) + Expect(result[1].Name).To(Equal("albumImg2")) + Expect(result[1].Retriever).To(BeIdenticalTo(albumImg2)) + }) + + It("is empty when external services are disabled", func() { + conf.Server.Agents = "" // what disableExternalServices() sets when EnableExternalServices=false + ag = createAgents(ds, nil) + Expect(ag.AlbumImageAgents()).To(BeEmpty()) + }) + }) + }) }) type mockAgent struct { @@ -497,3 +555,17 @@ func (t *testImageAgent) GetArtistImages(_ context.Context, id, name, mbid strin t.Args = []any{id, name, mbid} return t.Images, t.Err } + +type testAlbumImageAgent struct { + name string + Images []ExternalImage + Err error + Args []any +} + +func (t *testAlbumImageAgent) AgentName() string { return t.name } + +func (t *testAlbumImageAgent) GetAlbumImages(_ context.Context, name, artist, mbid string) ([]ExternalImage, error) { + t.Args = []any{name, artist, mbid} + return t.Images, t.Err +} diff --git a/core/artwork/agent_images.go b/core/artwork/agent_images.go new file mode 100644 index 000000000..c7032b62b --- /dev/null +++ b/core/artwork/agent_images.go @@ -0,0 +1,100 @@ +package artwork + +import ( + "context" + "io" + "net/url" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/str" +) + +// externalName mirrors the normalization the aggregate provider applies, so agent searches match. +func externalName(name string) string { + if conf.Server.DevPreserveUnicodeInExternalCalls { + return name + } + return str.Clear(name) +} + +// bestImageURL returns the largest fetchable image URL. Only one is returned and its failure ends +// the agent's turn, so an unfetchable candidate must never win: url.Parse alone accepts anything. +func bestImageURL(imgs []agents.ExternalImage) *url.URL { + var best *url.URL + var bestSize int + for i := range imgs { + if imgs[i].URL == "" { + continue + } + u, err := url.Parse(imgs[i].URL) + if err != nil || !u.IsAbs() || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + continue + } + if best == nil || imgs[i].Size > bestSize { + best, bestSize = u, imgs[i].Size + } + } + return best +} + +// fetchArtistImage tries each enabled artist-image agent in order. extErr is true only when no +// agent succeeded and at least one failed transiently. +func fetchArtistImage(ctx context.Context, ag *agents.Agents, gate gateFunc, ar model.Artist) (r io.ReadCloser, agentName string, extErr bool) { + // Synthetic artists would otherwise get an unrelated agent result assigned to them. + switch ar.ID { + case consts.UnknownArtistID, consts.VariousArtistsID: + return nil, "", false + } + name := externalName(ar.Name) + for _, a := range ag.ArtistImageAgents() { + reader, _, err := gate(a.Name, func() (io.ReadCloser, string, error) { + imgs, err := a.Retriever.GetArtistImages(ctx, ar.ID, name, ar.MbzArtistID) + if err != nil { + return nil, "", err + } + u := bestImageURL(imgs) + if u == nil { + return nil, "", agents.ErrNotFound + } + return fromURL(ctx, u) + }) + if reader != nil { + return reader, a.Name, false + } + if isTransientExternal(err) { + extErr = true + log.Debug(ctx, "Artwork: External artist-image lookup failed", "agent", a.Name, "artist", ar.Name, err) + } + } + return nil, "", extErr +} + +// fetchAlbumImage is the album counterpart of fetchArtistImage. +func fetchAlbumImage(ctx context.Context, ag *agents.Agents, gate gateFunc, al model.Album) (r io.ReadCloser, agentName string, extErr bool) { + name, artist := externalName(al.Name), externalName(al.AlbumArtist) + for _, a := range ag.AlbumImageAgents() { + reader, _, err := gate(a.Name, func() (io.ReadCloser, string, error) { + imgs, err := a.Retriever.GetAlbumImages(ctx, name, artist, al.MbzAlbumID) + if err != nil { + return nil, "", err + } + u := bestImageURL(imgs) + if u == nil { + return nil, "", agents.ErrNotFound + } + return fromURL(ctx, u) + }) + if reader != nil { + return reader, a.Name, false + } + if isTransientExternal(err) { + extErr = true + log.Debug(ctx, "Artwork: External album-image lookup failed", "agent", a.Name, "album", al.Name, err) + } + } + return nil, "", extErr +} diff --git a/core/artwork/agent_images_test.go b/core/artwork/agent_images_test.go new file mode 100644 index 000000000..716245c93 --- /dev/null +++ b/core/artwork/agent_images_test.go @@ -0,0 +1,262 @@ +package artwork + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/utils/str" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// fakeImageAgent is a built-in agent stub implementing both image retrievers; it +// records call counts so per-agent ordering and short-circuiting can be asserted. +type fakeImageAgent struct { + name string + imgs []agents.ExternalImage + err error + artistCalls int + albumCalls int + gotArtistName string + gotAlbumName string + // block, when set, holds every lookup until closed, standing in for a slow/rate-limited agent. + block chan struct{} + // mu guards the call counters: the worker resolves several items concurrently. + mu sync.Mutex +} + +func (f *fakeImageAgent) AgentName() string { return f.name } + +func (f *fakeImageAgent) GetArtistImages(_ context.Context, _, name, _ string) ([]agents.ExternalImage, error) { + if f.block != nil { + <-f.block + } + f.mu.Lock() + f.artistCalls++ + f.gotArtistName = name + f.mu.Unlock() + return f.imgs, f.err +} + +func (f *fakeImageAgent) GetAlbumImages(_ context.Context, name, _, _ string) ([]agents.ExternalImage, error) { + f.albumCalls++ + f.gotAlbumName = name + return f.imgs, f.err +} + +// imageAgents registers the fakes as built-in agents and enables them in order. The fakes +// ignore the DataStore, so reusing the process-wide GetAgents singleton across tests is safe. +func imageAgents(fakes ...*fakeImageAgent) *agents.Agents { + names := make([]string, 0, len(fakes)) + for _, f := range fakes { + fake := f + agents.Register(fake.name, func(model.DataStore) agents.Interface { return fake }) + names = append(names, fake.name) + } + conf.Server.Agents = strings.Join(names, ",") + return agents.GetAgents(&tests.MockDataStore{}, nil) +} + +var _ = Describe("agent images", func() { + var ( + ctx context.Context + srv *httptest.Server + ) + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + ctx = context.Background() + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("image-bytes")) + })) + DeferCleanup(srv.Close) + }) + + img := func(path string, size int) agents.ExternalImage { + return agents.ExternalImage{URL: srv.URL + path, Size: size} + } + + Describe("bestImageURL", func() { + It("picks the largest-Size URL and skips empty ones", func() { + u := bestImageURL([]agents.ExternalImage{ + {URL: "http://x/small", Size: 10}, + {URL: "", Size: 9999}, + {URL: "http://x/big", Size: 100}, + }) + Expect(u).ToNot(BeNil()) + Expect(u.String()).To(Equal("http://x/big")) + }) + + It("skips a malformed largest URL and falls back to a valid smaller one", func() { + u := bestImageURL([]agents.ExternalImage{ + {URL: "http://x/valid", Size: 10}, + {URL: "http://x/%zz", Size: 100}, // invalid percent-escape, largest + }) + Expect(u).ToNot(BeNil()) + Expect(u.String()).To(Equal("http://x/valid")) + }) + + It("returns nil when there is no non-empty URL", func() { + Expect(bestImageURL(nil)).To(BeNil()) + Expect(bestImageURL([]agents.ExternalImage{{URL: "", Size: 5}})).To(BeNil()) + }) + + // Plugins hand these over as free-form strings, and url.Parse accepts them all. An + // unfetchable candidate that wins here ends the agent's turn before its valid images run. + DescribeTable("skips a candidate that cannot be fetched", + func(badURL string) { + u := bestImageURL([]agents.ExternalImage{ + {URL: badURL, Size: 100}, // largest, and first + {URL: "https://cdn.example.com/ok.jpg", Size: 10}, + }) + Expect(u).ToNot(BeNil()) + Expect(u.String()).To(Equal("https://cdn.example.com/ok.jpg")) + }, + Entry("a relative path", "images/big.jpg"), + Entry("a root-relative path", "/images/big.jpg"), + Entry("a scheme we cannot fetch", "ftp://host/big.jpg"), + Entry("a scheme-relative URL", "//host/big.jpg"), + Entry("a URL with no host", "http:///big.jpg"), + ) + + // Size is often 0 for every candidate, and only a strictly larger one replaces the first, + // so an unfetchable entry in first position would otherwise stick. + It("skips an unfetchable first candidate when every Size is zero", func() { + u := bestImageURL([]agents.ExternalImage{ + {URL: "images/rel.jpg"}, + {URL: "https://cdn.example.com/ok.jpg"}, + }) + Expect(u).ToNot(BeNil()) + Expect(u.String()).To(Equal("https://cdn.example.com/ok.jpg")) + }) + + It("returns nil when no candidate is fetchable", func() { + Expect(bestImageURL([]agents.ExternalImage{ + {URL: "images/a.jpg", Size: 10}, + {URL: "ftp://host/b.jpg", Size: 20}, + })).To(BeNil()) + }) + }) + + Describe("fetchArtistImage", func() { + It("returns the first agent's image and its name", func() { + a := &fakeImageAgent{name: "agentA", imgs: []agents.ExternalImage{img("/a", 100)}} + ag := imageAgents(a) + + r, name, extErr := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1", Name: "Artist"}) + Expect(r).ToNot(BeNil()) + defer r.Close() + Expect(name).To(Equal("agentA")) + Expect(extErr).To(BeFalse()) + }) + + It("skips the external lookup for synthetic artists", func() { + a := &fakeImageAgent{name: "agentA", imgs: []agents.ExternalImage{img("/a", 100)}} + ag := imageAgents(a) + + for _, id := range []string{consts.UnknownArtistID, consts.VariousArtistsID} { + r, name, extErr := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: id, Name: "Various Artists"}) + Expect(r).To(BeNil()) + Expect(name).To(BeEmpty()) + Expect(extErr).To(BeFalse()) + } + Expect(a.artistCalls).To(Equal(0), "synthetic artists never reach the agents") + }) + + It("clears typographic characters from the query name unless preserving unicode", func() { + conf.Server.DevPreserveUnicodeInExternalCalls = false + a := &fakeImageAgent{name: "agentA"} + ag := imageAgents(a) + + _, _, _ = fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1", Name: "AC’DC"}) + Expect(a.gotArtistName).To(Equal(str.Clear("AC’DC"))) + }) + + It("falls through to a later agent, and its success beats the earlier error", func() { + a := &fakeImageAgent{name: "agentA", err: errBreakerOpen} + b := &fakeImageAgent{name: "agentB", imgs: []agents.ExternalImage{img("/b", 50)}} + ag := imageAgents(a, b) + + r, name, extErr := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"}) + Expect(r).ToNot(BeNil()) + defer r.Close() + Expect(name).To(Equal("agentB")) + Expect(extErr).To(BeFalse(), "a later hit clears an earlier agent's error") + Expect(a.artistCalls).To(Equal(1)) + Expect(b.artistCalls).To(Equal(1)) + }) + + It("reports a clean miss when every agent finds nothing", func() { + a := &fakeImageAgent{name: "agentA"} // no images, no error -> not found + b := &fakeImageAgent{name: "agentB", err: agents.ErrNotFound} + ag := imageAgents(a, b) + + r, name, extErr := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"}) + Expect(r).To(BeNil()) + Expect(name).To(BeEmpty()) + Expect(extErr).To(BeFalse(), "not-found is definitive, never a transient failure") + }) + + It("reports extErr when one agent fails transiently and the rest find nothing", func() { + a := &fakeImageAgent{name: "agentA", err: agents.ErrNotFound} + b := &fakeImageAgent{name: "agentB", err: context.DeadlineExceeded} + ag := imageAgents(a, b) + + r, _, extErr := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"}) + Expect(r).To(BeNil()) + Expect(extErr).To(BeTrue()) + }) + }) + + Describe("fetchAlbumImage", func() { + It("returns the winning agent's image and name", func() { + a := &fakeImageAgent{name: "agentA", imgs: []agents.ExternalImage{img("/a", 100)}} + ag := imageAgents(a) + + r, name, extErr := fetchAlbumImage(ctx, ag, passthroughGate, model.Album{Name: "Album", AlbumArtist: "Artist"}) + Expect(r).ToNot(BeNil()) + defer r.Close() + Expect(name).To(Equal("agentA")) + Expect(extErr).To(BeFalse()) + Expect(a.albumCalls).To(Equal(1)) + }) + + It("reports extErr when the only agent fails transiently", func() { + a := &fakeImageAgent{name: "agentA", err: context.DeadlineExceeded} + ag := imageAgents(a) + + r, _, extErr := fetchAlbumImage(ctx, ag, passthroughGate, model.Album{Name: "Album"}) + Expect(r).To(BeNil()) + Expect(extErr).To(BeTrue()) + }) + }) + + Describe("gate naming", func() { + It("invokes the gate once per agent, keyed by agent name", func() { + a := &fakeImageAgent{name: "agentA", err: context.DeadlineExceeded} + b := &fakeImageAgent{name: "agentB", imgs: []agents.ExternalImage{img("/b", 1)}} + ag := imageAgents(a, b) + + var gatedNames []string + gate := func(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { + gatedNames = append(gatedNames, name) + return f() + } + + r, _, _ := fetchArtistImage(ctx, ag, gate, model.Artist{ID: "ar1"}) + Expect(r).ToNot(BeNil()) + defer r.Close() + Expect(gatedNames).To(Equal([]string{"agentA", "agentB"})) + }) + }) +}) diff --git a/core/artwork/artwork.go b/core/artwork/artwork.go index b8c395c12..8ac8d9c9d 100644 --- a/core/artwork/artwork.go +++ b/core/artwork/artwork.go @@ -1,134 +1,394 @@ package artwork import ( + "bytes" "context" "errors" - _ "image/gif" + "fmt" "io" + "os" "time" + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/resources" "github.com/navidrome/navidrome/utils/cache" - _ "golang.org/x/image/webp" ) var ErrUnavailable = errors.New("artwork unavailable") +// errStaleSource means the backing file's mtime no longer matches RefMtime, so the stored hash may be stale. +var errStaleSource = errors.New("artwork: source file changed since resolution") + +// Image is one servable artwork response. +type Image struct { + io.ReadCloser + Hash string // pixel identity; "" for placeholders + ETag string // representation validator; "" means Hash applies (full-size original) + LastUpdated time.Time + Placeholder bool +} + +// representationTag varies with dimensions and encode settings, so a config change invalidates +// a revalidating client's cache even though the pixel hash is unchanged. +func representationTag(hash string, size int, square bool) string { + return fmt.Sprintf("%s.%d.%v.%s", hash, size, square, formatQualityTag()) +} + type Artwork interface { - Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (io.ReadCloser, time.Time, error) - GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error) + // Get returns ErrUnavailable when there is nothing to serve and model.ErrNotFound when + // the id resolves to nothing, so the caller can pick placeholder vs 404. + Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) + // GetOrPlaceholder accepts an artwork token or a raw entity id, falling back to the + // kind's placeholder image (never resized, Placeholder=true). + GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (*Image, error) } -func NewArtwork(ds model.DataStore, cache cache.FileCache, ffmpeg ffmpeg.FFmpeg, provider external.Provider) Artwork { - return &artwork{ds: ds, cache: cache, ffmpeg: ffmpeg, provider: provider} +func NewArtwork(ds model.DataStore, cache cache.FileCache, store *ImageStore, ffm ffmpeg.FFmpeg) Artwork { + return &service{ds: ds, cache: cache, store: store, ffmpeg: ffm} } -type artwork struct { - ds model.DataStore - cache cache.FileCache - ffmpeg ffmpeg.FFmpeg - provider external.Provider +// entityExists reports whether the entity an artwork id points at is still there: state rows +// outlive a deleted entity until the next prune, so a servable row is not evidence of its owner. +func entityExists(ctx context.Context, ds model.DataStore, artID model.ArtworkID) bool { + var found bool + var err error + switch artID.Kind { + case model.KindArtistArtwork: + found, err = ds.Artist(ctx).Exists(artID.ID) + case model.KindAlbumArtwork: + found, err = ds.Album(ctx).Exists(artID.ID) + case model.KindMediaFileArtwork: + found, err = ds.MediaFile(ctx).Exists(artID.ID) + case model.KindPlaylistArtwork: + found, err = ds.Playlist(ctx).Exists(artID.ID) + case model.KindRadioArtwork: + found, err = ds.Radio(ctx).Exists(artID.ID) + case model.KindDiscArtwork: + albumID, _, perr := model.ParseDiscArtworkID(artID.ID) + if perr != nil { + return false + } + found, err = ds.Album(ctx).Exists(albumID) + default: + return false + } + return err == nil && found } -type artworkReader interface { - cache.Item - LastUpdated() time.Time - Reader(ctx context.Context) (io.ReadCloser, string, error) +type service struct { + ds model.DataStore + cache cache.FileCache + store *ImageStore + ffmpeg ffmpeg.FFmpeg } -func (a *artwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (reader io.ReadCloser, lastUpdate time.Time, err error) { - artID, err := a.getArtworkId(ctx, id) +func (s *service) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (*Image, error) { + artID, err := s.parseArtworkID(ctx, id) + var img *Image if err == nil { - reader, lastUpdate, err = a.Get(ctx, artID, size, square) + img, err = s.Get(ctx, artID, size, square) } + // Only a resolvable entity with no art gets a placeholder; an unknown id must stay + // ErrNotFound so callers can still answer 404 / Subsonic error 70. if errors.Is(err, ErrUnavailable) { - if artID.Kind == model.KindArtistArtwork { - reader, _ = resources.FS().Open(consts.PlaceholderArtistArt) - } else { - reader, _ = resources.FS().Open(consts.PlaceholderAlbumArt) - } - return reader, consts.ServerStart, nil + return placeholderImage(artID.Kind), nil } - return reader, lastUpdate, err + return img, err } -func (a *artwork) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (reader io.ReadCloser, lastUpdate time.Time, err error) { - artReader, err := a.getArtworkReader(ctx, artID, size, square) - if err != nil { - return nil, time.Time{}, err +func (s *service) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) { + if artID.ID == "" { + return nil, ErrUnavailable } - - r, err := a.cache.Get(ctx, artReader) - if err != nil { - if !errors.Is(err, context.Canceled) && !errors.Is(err, ErrUnavailable) { - log.Error(ctx, "Error accessing image cache", "id", artID, "size", size, err) - } - return nil, time.Time{}, err + if size < 0 { + size = 0 // a negative size means full-size, not a giant (OOM) resize rectangle + } + switch artID.Kind { + case model.KindDiscArtwork: + return s.serveDisc(ctx, artID, size, square) + case model.KindMediaFileArtwork: + return s.serveMediaFile(ctx, artID, size, square) + default: + return s.serveEntity(ctx, artID, size, square) } - return r, artReader.LastUpdated(), nil } -type coverArtGetter interface { +// requestRecheckAge throttles view-triggered rechecks so reopening a genuinely-absent page can't +// hammer external services; below staleAbsentAge to catch younger absences. +const requestRecheckAge = time.Hour + +func (s *service) serveEntity(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) { + ia, err := s.ds.Artwork(ctx).GetItemArtwork(artID.Kind, artID.ID, model.ImageTypePrimary) + switch { + case errors.Is(err, model.ErrNotFound): + return s.provisional(ctx, artID, size, square) + case err != nil: + return nil, err + case ia.Hash == "": + // Inserts an immediately-eligible recheck for a settled absent row. + if time.Since(ia.AttemptedAt) > requestRecheckAge { + s.enqueue(ctx, artID, model.ArtworkPriorityBump) + } + return nil, ErrUnavailable + default: + return s.serveHash(ctx, artID, ia, size, square) + } +} + +// serveSource is the one place bytes become an Image. hash is the pixel identity ("" for disc art) +// and doubles as the full-size validator, so an ETag is only needed when resized or hash is "". +func (s *service) serveSource(ctx context.Context, key, hash string, lastUpdate time.Time, + size int, square bool, open func() (io.ReadCloser, error), +) (*Image, error) { + if size == 0 && !square { + rc, err := open() + if err != nil { + return nil, err + } + if rc == nil { + return nil, ErrUnavailable + } + img := &Image{ReadCloser: rc, Hash: hash, LastUpdated: lastUpdate} + if hash == "" { + img.ETag = representationTag(key, size, square) + } + return img, nil + } + stream, err := s.cache.Get(ctx, &resizedItem{ + hash: key, size: size, square: square, ffmpeg: s.ffmpeg, open: open, + }) + if err != nil { + return nil, err + } + return &Image{ReadCloser: stream, Hash: hash, ETag: representationTag(key, size, square), LastUpdated: lastUpdate}, nil +} + +// serveHash serves the bytes of a found state row. A mismatch/open error is dangling, but a +// cancelled request is not: it must not enqueue a re-resolution. +func (s *service) serveHash(ctx context.Context, artID model.ArtworkID, ia *model.ItemArtwork, size int, square bool) (*Image, error) { + // Only this path can hand back a deleted entity's bytes; the others load their entity anyway. + if !entityExists(ctx, s.ds, artID) { + return nil, ErrUnavailable + } + art, err := s.ds.Artwork(ctx).GetImage(ia.Hash) + if err != nil { + if errors.Is(err, model.ErrNotFound) { + return s.dangling(ctx, artID) + } + return nil, err + } + img, err := s.serveSource(ctx, ia.Hash, ia.Hash, ia.UpdatedAt, size, square, + func() (io.ReadCloser, error) { return openOriginal(ia, art.Mime, s.store) }) + if err != nil { + if errors.Is(err, context.Canceled) { + return nil, err + } + log.Warn(ctx, "Artwork: Could not serve image", "artID", artID, "size", size, err) + return s.dangling(ctx, artID) + } + return img, nil +} + +// openOriginal enforces the mtime invariant: bytes are never served under a hash they no longer match. +func openOriginal(ia *model.ItemArtwork, mime string, store *ImageStore) (io.ReadCloser, error) { + if isFileBacked(ia.Source) { + f, err := os.Open(ia.SourcePath) + if err != nil { + return nil, err + } + info, err := f.Stat() + if err != nil { + f.Close() + return nil, err + } + if ia.RefMtime != 0 && info.ModTime().UnixNano() != ia.RefMtime { + f.Close() + log.Debug("Artwork: Backing file changed since resolution", "path", ia.SourcePath, + "hash", ia.Hash, "resolvedMtime", ia.RefMtime, "currentMtime", info.ModTime().UnixNano()) + return nil, errStaleSource + } + return f, nil + } + // Store-backed bytes still carry the source's mtime, to detect edits to embedded art. + if ia.SourcePath != "" && ia.RefMtime != 0 { + info, err := os.Stat(ia.SourcePath) + if err != nil { + return nil, err + } + if info.ModTime().UnixNano() != ia.RefMtime { + log.Debug("Artwork: Source file changed since resolution", "path", ia.SourcePath, + "hash", ia.Hash, "resolvedMtime", ia.RefMtime, "currentMtime", info.ModTime().UnixNano()) + return nil, errStaleSource + } + } + return store.Open(ia.Hash, mime) +} + +// provisional serves local bytes for an entity with no state row, enqueuing the worker but +// never writing a state row itself. +func (s *service) provisional(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) { + item := model.ArtworkQueueItem{ItemKind: artID.Kind.Prefix(), ItemID: artID.ID, ImageType: model.ImageTypePrimary} + res, err := newLocalResolver(s.ds, s.ffmpeg).resolve(ctx, item) + if err != nil { + return nil, err + } + s.enqueue(ctx, artID, model.ArtworkPriorityBump) + log.Debug(ctx, "Artwork: Provisional read-through, no state row yet", "artID", artID, + "source", res.source, "hit", res.reader != nil) + return s.serveResolution(ctx, res, size, square) +} + +// serveResolution turns a local resolution's bytes into a servable Image (byte-hash only, no decode). +func (s *service) serveResolution(ctx context.Context, res resolution, size int, square bool) (*Image, error) { + if res.reader == nil { + return nil, ErrUnavailable + } + defer res.reader.Close() + data, err := readCapped(res.reader) + if err != nil { + return nil, ErrUnavailable + } + hash, err := hashImage(bytes.NewReader(data)) + if err != nil { + return nil, ErrUnavailable + } + // Keyed by the byte-hash, so the entry lines up with the worker's eventual store entry. + return s.serveSource(ctx, hash, hash, unixMtime(res.refMtime), size, square, + func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(data)), nil }) +} + +func (s *service) serveMediaFile(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) { + // The setting is not in the config fingerprint, so honor it at serve time: a direct mf- URL + // must fall back to disc/album instead of serving stale persisted embedded art. + if !conf.Server.EnableMediaFileCoverArt { + mf, err := s.ds.MediaFile(ctx).Get(artID.ID) + if err != nil { + return nil, err + } + return s.Get(ctx, mf.DiscCoverArtID(), size, square) + } + ia, err := s.ds.Artwork(ctx).GetItemArtwork(model.KindMediaFileArtwork, artID.ID, model.ImageTypePrimary) + switch { + case err == nil && ia.Hash != "": + return s.serveHash(ctx, artID, ia, size, square) + case err == nil: + // absent row: fall through + case errors.Is(err, model.ErrNotFound): + // no row: fall through + default: + return nil, err + } + noRow := errors.Is(err, model.ErrNotFound) + + mf, err := s.ds.MediaFile(ctx).Get(artID.ID) + if err != nil { + return nil, err + } + if noRow && conf.Server.EnableMediaFileCoverArt && mf.HasCoverArt { + return s.provisionalEmbedded(ctx, artID, *mf, size, square) + } + // Mirror MediaFile.CoverArtID: a track defers to its disc art, which falls back to the album. + return s.Get(ctx, mf.DiscCoverArtID(), size, square) +} + +// provisionalEmbedded serves a track's embedded art immediately, leaving the state row to the worker. +func (s *service) provisionalEmbedded(ctx context.Context, artID model.ArtworkID, mf model.MediaFile, size int, square bool) (*Image, error) { + lib, err := loadLibraryView(ctx, s.ds, mf.LibraryID) + if err != nil { + return nil, err + } + res, ok := resolveEmbedded(ctx, lib, s.ffmpeg, mf.Path) + s.enqueue(ctx, artID, model.ArtworkPriorityBump) + if !ok { + // Eligible but unextractable: fall back the way CoverArtID does, not to a placeholder. + return s.Get(ctx, mf.DiscCoverArtID(), size, square) + } + return s.serveResolution(ctx, res, size, square) +} + +// serveDisc reads disc art through with no state row and no enqueue, falling back to the album cover. +func (s *service) serveDisc(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) { + dr, err := newDiscArtworkReader(ctx, s.ds, artID) + if err != nil { + return nil, err + } + // Single-disc albums run the chain too: a disc can carry art distinct from the album cover. + selectImage := func() (io.ReadCloser, string, error) { + funcs := dr.fromDiscArtPriority(ctx, s.ffmpeg, conf.Server.DiscArtPriority) + return selectImageReader(ctx, artID, funcs...) + } + albumArtID := model.ArtworkID{Kind: model.KindAlbumArtwork, ID: dr.album.ID} + // Disc art has no state row, hence no content hash: keying on id, album mtime and + // DiscArtPriority lets a warm cache answer without running the chain or touching the disk. + key := fmt.Sprintf("%s|%d|%s", artID.ID, dr.cacheTime().UnixNano(), conf.Server.DiscArtPriority) + img, err := s.serveSource(ctx, key, "", dr.cacheTime(), size, square, + func() (io.ReadCloser, error) { rc, _, err := selectImage(); return rc, err }) + if err != nil { + if errors.Is(err, context.Canceled) { + return nil, err + } + return s.Get(ctx, albumArtID, size, square) + } + return img, nil +} + +// dangling enqueues a re-resolution and reports unavailable, leaving the state row untouched. +func (s *service) dangling(ctx context.Context, artID model.ArtworkID) (*Image, error) { + log.Debug(ctx, "Artwork: State row points at bytes we cannot serve, re-resolving", "artID", artID) + s.enqueue(ctx, artID, model.ArtworkPriorityScan) + return nil, ErrUnavailable +} + +func (s *service) enqueue(ctx context.Context, artID model.ArtworkID, priority int) { + err := s.ds.ArtworkQueue(ctx).EnqueuePreservingBackoff(model.ArtworkQueueItem{ + ItemKind: artID.Kind.Prefix(), + ItemID: artID.ID, + ImageType: model.ImageTypePrimary, + Priority: priority, + }) + if err != nil { + log.Warn(ctx, "Artwork: Could not enqueue re-resolution", "artID", artID, err) + } +} + +func placeholderImage(kind model.Kind) *Image { + path := consts.PlaceholderAlbumArt + if kind == model.KindArtistArtwork { + path = consts.PlaceholderArtistArt + } + r, _ := resources.FS().Open(path) + return &Image{ReadCloser: r, Placeholder: true} +} + +type coverArtIDGetter interface { CoverArtID() model.ArtworkID } -func (a *artwork) getArtworkId(ctx context.Context, id string) (model.ArtworkID, error) { +// parseArtworkID accepts an artwork token or a raw entity id, resolving the latter to its CoverArtID. +func (s *service) parseArtworkID(ctx context.Context, id string) (model.ArtworkID, error) { if id == "" { return model.ArtworkID{}, ErrUnavailable } - artID, err := model.ParseArtworkID(id) - if err == nil { + if artID, err := model.ParseArtworkID(id); err == nil { return artID, nil } - - log.Trace(ctx, "ArtworkID invalid. Trying to figure out kind based on the ID", "id", id) - entity, err := model.GetEntityByID(ctx, a.ds, id) + entity, err := model.GetEntityByID(ctx, s.ds, id) if err != nil { return model.ArtworkID{}, err } - if e, ok := entity.(coverArtGetter); ok { - artID = e.CoverArtID() + if e, ok := entity.(coverArtIDGetter); ok { + return e.CoverArtID(), nil } - switch e := entity.(type) { - case *model.Artist: - log.Trace(ctx, "ID is for an Artist", "id", id, "name", e.Name, "artist", e.Name) - case *model.Album: - log.Trace(ctx, "ID is for an Album", "id", id, "name", e.Name, "artist", e.AlbumArtist) - case *model.MediaFile: - log.Trace(ctx, "ID is for a MediaFile", "id", id, "title", e.Title, "album", e.Album) - case *model.Playlist: - log.Trace(ctx, "ID is for a Playlist", "id", id, "name", e.Name) - } - return artID, nil + return model.ArtworkID{}, model.ErrNotFound } -func (a *artwork) getArtworkReader(ctx context.Context, artID model.ArtworkID, size int, square bool) (artworkReader, error) { - var artReader artworkReader - var err error - if size > 0 || square { - artReader, err = resizedFromOriginal(ctx, a, artID, size, square) - } else { - switch artID.Kind { - case model.KindArtistArtwork: - artReader, err = newArtistArtworkReader(ctx, a, artID, a.provider) - case model.KindAlbumArtwork: - artReader, err = newAlbumArtworkReader(ctx, a, artID, a.provider) - case model.KindMediaFileArtwork: - artReader, err = newMediafileArtworkReader(ctx, a, artID) - case model.KindPlaylistArtwork: - artReader, err = newPlaylistArtworkReader(ctx, a, artID) - case model.KindDiscArtwork: - artReader, err = newDiscArtworkReader(ctx, a, artID) - case model.KindRadioArtwork: - artReader, err = newRadioArtworkReader(ctx, a, artID) - default: - return nil, ErrUnavailable - } +func unixMtime(mtime int64) time.Time { + if mtime <= 0 { + return time.Time{} } - return artReader, err + return time.Unix(0, mtime) // RefMtime is unix-nanoseconds } diff --git a/core/artwork/artwork_internal_test.go b/core/artwork/artwork_internal_test.go deleted file mode 100644 index 5bcd82f23..000000000 --- a/core/artwork/artwork_internal_test.go +++ /dev/null @@ -1,629 +0,0 @@ -package artwork - -import ( - "context" - "errors" - "image" - "image/jpeg" - "image/png" - "io" - "os" - "path/filepath" - "time" - - _ "github.com/gen2brain/webp" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Artwork", func() { - var aw *artwork - var ds model.DataStore - var ffmpeg *tests.MockFFmpeg - var folderRepo *fakeFolderRepo - ctx := log.NewContext(context.TODO()) - var alOnlyEmbed, alEmbedNotFound, alOnlyExternal, alExternalNotFound, alMultipleCovers, alSingleDisc model.Album - var arMultipleCovers model.Artist - var mfWithEmbed, mfAnotherWithEmbed, mfWithoutEmbed, mfCorruptedCover model.MediaFile - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.ImageCacheSize = "0" // Disable cache - conf.Server.CoverArtPriority = "folder.*, cover.*, embedded , front.*" - - folderRepo = &fakeFolderRepo{} - libRepo := &tests.MockLibraryRepo{} - repoRoot, _ := os.Getwd() - libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) - ds = &tests.MockDataStore{ - MockedTranscoding: &tests.MockTranscodingRepo{}, - MockedFolder: folderRepo, - MockedLibrary: libRepo, - } - // Paths use forward slashes because the scanner stores fs.FS-relative paths in the DB. - alOnlyEmbed = model.Album{ID: "222", Name: "Only embed", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}} - alEmbedNotFound = model.Album{ID: "333", Name: "Embed not found", EmbedArtPath: "tests/fixtures/NON_EXISTENT.mp3", FolderIDs: []string{"f1"}} - alOnlyExternal = model.Album{ID: "444", Name: "Only external", FolderIDs: []string{"f1"}, Discs: model.Discs{1: "", 2: ""}} - alExternalNotFound = model.Album{ID: "555", Name: "External not found", FolderIDs: []string{"f2"}} - alSingleDisc = model.Album{ID: "888", Name: "Single disc", FolderIDs: []string{"f1"}, Discs: model.Discs{1: ""}} - arMultipleCovers = model.Artist{ID: "777", Name: "All options"} - alMultipleCovers = model.Album{ - ID: "666", - Name: "All options", - EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", - FolderIDs: []string{"f1"}, - AlbumArtistID: "777", - } - mfWithEmbed = model.MediaFile{ID: "22", Path: "tests/fixtures/test.mp3", HasCoverArt: true, AlbumID: "222"} - mfAnotherWithEmbed = model.MediaFile{ID: "23", Path: "tests/fixtures/artist/an-album/test.mp3", HasCoverArt: true, AlbumID: "666"} - mfWithoutEmbed = model.MediaFile{ID: "44", Path: "tests/fixtures/test.ogg", AlbumID: "444"} - mfCorruptedCover = model.MediaFile{ID: "45", Path: "tests/fixtures/test.ogg", HasCoverArt: true, AlbumID: "444"} - - cache := GetImageCache() - ffmpeg = tests.NewMockFFmpeg("content from ffmpeg") - aw = NewArtwork(ds, cache, ffmpeg, nil).(*artwork) - }) - - Describe("albumArtworkReader", func() { - Context("ID not found", func() { - It("returns ErrNotFound if album is not in the DB", func() { - _, err := newAlbumArtworkReader(ctx, aw, model.MustParseArtworkID("al-NOT-FOUND"), nil) - Expect(err).To(MatchError(model.ErrNotFound)) - }) - }) - Context("Embed images", func() { - BeforeEach(func() { - folderRepo.result = nil - ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{ - alOnlyEmbed, - alEmbedNotFound, - }) - }) - It("returns embed cover", func() { - aw, err := newAlbumArtworkReader(ctx, aw, alOnlyEmbed.CoverArtID(), nil) - Expect(err).ToNot(HaveOccurred()) - _, path, err := aw.Reader(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal("tests/fixtures/artist/an-album/test.mp3")) - }) - It("returns ErrUnavailable if embed path is not available", func() { - ffmpeg.Error = errors.New("not available") - aw, err := newAlbumArtworkReader(ctx, aw, alEmbedNotFound.CoverArtID(), nil) - Expect(err).ToNot(HaveOccurred()) - _, _, err = aw.Reader(ctx) - Expect(err).To(MatchError(ErrUnavailable)) - }) - }) - Context("External images", func() { - BeforeEach(func() { - folderRepo.result = []model.Folder{} - ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{ - alOnlyExternal, - alExternalNotFound, - }) - }) - It("returns external cover", func() { - folderRepo.result = []model.Folder{{ - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"front.png"}, - }} - aw, err := newAlbumArtworkReader(ctx, aw, alOnlyExternal.CoverArtID(), nil) - Expect(err).ToNot(HaveOccurred()) - _, path, err := aw.Reader(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal("tests/fixtures/artist/an-album/front.png")) - }) - It("returns ErrUnavailable if external file is not available", func() { - folderRepo.result = []model.Folder{} - aw, err := newAlbumArtworkReader(ctx, aw, alExternalNotFound.CoverArtID(), nil) - Expect(err).ToNot(HaveOccurred()) - _, _, err = aw.Reader(ctx) - Expect(err).To(MatchError(ErrUnavailable)) - }) - }) - Context("Multiple covers", func() { - BeforeEach(func() { - folderRepo.result = []model.Folder{{ - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"cover.jpg", "front.png", "artist.png"}, - }} - ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{ - alMultipleCovers, - }) - }) - DescribeTable("CoverArtPriority", - func(priority string, expected string) { - conf.Server.CoverArtPriority = priority - aw, err := newAlbumArtworkReader(ctx, aw, alMultipleCovers.CoverArtID(), nil) - Expect(err).ToNot(HaveOccurred()) - _, path, err := aw.Reader(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal(expected)) - }, - Entry(nil, " folder.* , cover.*,embedded,front.*", "tests/fixtures/artist/an-album/cover.jpg"), - Entry(nil, "front.* , cover.*, embedded ,folder.*", "tests/fixtures/artist/an-album/front.png"), - Entry(nil, " embedded , front.* , cover.*,folder.*", "tests/fixtures/artist/an-album/test.mp3"), - ) - }) - Context("LastUpdated", func() { - // Regression test for #5377: LastUpdated feeds the HTTP Last-Modified header. - // It must return max(album.UpdatedAt, ImagesUpdatedAt) so browsers revalidate - // cached cover art when only the image file changes. - now := time.Now().Truncate(time.Second) - DescribeTable("returns the max of album.UpdatedAt and ImagesUpdatedAt", - func(albumUpdatedAt, imagesUpdatedAt, expected time.Time) { - album := model.Album{ID: "al1", UpdatedAt: albumUpdatedAt} - folderRepo.result = []model.Folder{{ImagesUpdatedAt: imagesUpdatedAt}} - ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{album}) - - ar, err := newAlbumArtworkReader(ctx, aw, album.CoverArtID(), nil) - Expect(err).ToNot(HaveOccurred()) - Expect(ar.LastUpdated()).To(Equal(expected)) - }, - Entry("album newer than images", now, now.Add(-1*time.Hour), now), - Entry("images newer than album", now.Add(-24*time.Hour), now.Add(-1*time.Hour), now.Add(-1*time.Hour)), - Entry("equal timestamps", now, now, now), - ) - }) - }) - Describe("discArtworkReader", func() { - Context("LastUpdated", func() { - // Regression test for #5377: same bug as albumArtworkReader — disc covers - // must also revalidate when the image file changes, not only when media files do. - now := time.Now().Truncate(time.Second) - DescribeTable("returns the max of album.UpdatedAt and ImagesUpdatedAt", - func(albumUpdatedAt, imagesUpdatedAt, expected time.Time) { - album := model.Album{ID: "al1", UpdatedAt: albumUpdatedAt} - folderRepo.result = []model.Folder{{ImagesUpdatedAt: imagesUpdatedAt}} - ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{album}) - ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "mf1", AlbumID: "al1", DiscNumber: 1, Path: "tests/fixtures/test.mp3"}, - }) - - artID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID("al1", 1), nil) - dr, err := newDiscArtworkReader(ctx, aw, artID) - Expect(err).ToNot(HaveOccurred()) - Expect(dr.LastUpdated()).To(Equal(expected)) - }, - Entry("album newer than images", now, now.Add(-1*time.Hour), now), - Entry("images newer than album", now.Add(-24*time.Hour), now.Add(-1*time.Hour), now.Add(-1*time.Hour)), - Entry("equal timestamps", now, now, now), - ) - }) - }) - Describe("artistArtworkReader", func() { - Context("Multiple covers", func() { - BeforeEach(func() { - repoRoot, err := os.Getwd() - Expect(err).ToNot(HaveOccurred()) - folderRepo.result = []model.Folder{{ - ID: "f1", - LibraryPath: testFileLibPath(repoRoot), - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"artist.png"}, - }} - ds.Artist(ctx).(*tests.MockArtistRepo).SetData(model.Artists{ - arMultipleCovers, - }) - ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{ - alMultipleCovers, - }) - ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - mfAnotherWithEmbed, - }) - }) - DescribeTable("ArtistArtPriority", - func(priority string, expected string) { - conf.Server.ArtistArtPriority = priority - aw, err := newArtistArtworkReader(ctx, aw, arMultipleCovers.CoverArtID(), nil) - Expect(err).ToNot(HaveOccurred()) - _, path, err := aw.Reader(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(filepath.ToSlash(path)).To(HaveSuffix(expected)) - }, - Entry(nil, " folder.* , artist.*,album/artist.*", "tests/fixtures/artist/artist.jpg"), - Entry(nil, "album/artist.*, folder.*,artist.*", "tests/fixtures/artist/an-album/artist.png"), - ) - }) - }) - Describe("mediafileArtworkReader", func() { - Context("ID not found", func() { - It("returns ErrNotFound if mediafile is not in the DB", func() { - _, err := newMediafileArtworkReader(ctx, aw, model.MustParseArtworkID("mf-NOT-FOUND")) - Expect(err).To(MatchError(model.ErrNotFound)) - }) - }) - Context("Embed images", func() { - BeforeEach(func() { - folderRepo.result = []model.Folder{{ - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"front.png"}, - }} - ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{ - alOnlyEmbed, - alOnlyExternal, - alSingleDisc, - }) - ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - mfWithEmbed, - mfWithoutEmbed, - mfCorruptedCover, - }) - }) - It("returns embed cover", func() { - aw, err := newMediafileArtworkReader(ctx, aw, mfWithEmbed.CoverArtID()) - Expect(err).ToNot(HaveOccurred()) - _, path, err := aw.Reader(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal("tests/fixtures/test.mp3")) - }) - It("returns embed cover if successfully extracted by ffmpeg", func() { - aw, err := newMediafileArtworkReader(ctx, aw, mfCorruptedCover.CoverArtID()) - Expect(err).ToNot(HaveOccurred()) - r, path, err := aw.Reader(ctx) - Expect(err).ToNot(HaveOccurred()) - data, _ := io.ReadAll(r) - Expect(data).ToNot(BeEmpty()) - Expect(path).To(Equal("tests/fixtures/test.ogg")) - }) - It("returns album cover if cannot read embed artwork", func() { - // Force fromTag to fail - mfCorruptedCover.Path = "tests/fixtures/DOES_NOT_EXIST.ogg" - Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Put(&mfCorruptedCover)).To(Succeed()) - // Simulate ffmpeg error - ffmpeg.Error = errors.New("not available") - - aw, err := newMediafileArtworkReader(ctx, aw, mfCorruptedCover.CoverArtID()) - Expect(err).ToNot(HaveOccurred()) - _, path, err := aw.Reader(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal("al-444_0")) - }) - It("returns album cover if media file has no cover art", func() { - aw, err := newMediafileArtworkReader(ctx, aw, model.MustParseArtworkID("mf-"+mfWithoutEmbed.ID)) - Expect(err).ToNot(HaveOccurred()) - _, path, err := aw.Reader(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal("al-444_0")) - }) - It("falls back to disc cover art when media file has a disc number on a multi-disc album", func() { - mfWithDisc := model.MediaFile{ID: "46", Path: "tests/fixtures/test.ogg", AlbumID: "444", DiscNumber: 2} - Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Put(&mfWithDisc)).To(Succeed()) - - aw, err := newMediafileArtworkReader(ctx, aw, model.MustParseArtworkID("mf-"+mfWithDisc.ID)) - Expect(err).ToNot(HaveOccurred()) - _, path, err := aw.Reader(ctx) - Expect(err).ToNot(HaveOccurred()) - // Should fall back to disc art, which itself falls back to album art - Expect(path).To(Equal("dc-444:2_0")) - }) - It("falls back to album cover art for single-disc albums even with a disc number", func() { - mfOnSingleDisc := model.MediaFile{ID: "47", Path: "tests/fixtures/test.ogg", AlbumID: "888", DiscNumber: 1} - Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Put(&mfOnSingleDisc)).To(Succeed()) - - aw, err := newMediafileArtworkReader(ctx, aw, model.MustParseArtworkID("mf-"+mfOnSingleDisc.ID)) - Expect(err).ToNot(HaveOccurred()) - _, path, err := aw.Reader(ctx) - Expect(err).ToNot(HaveOccurred()) - // Single-disc album should skip disc art and go straight to album art - Expect(path).To(Equal("al-888_0")) - }) - }) - }) - Describe("playlistArtworkReader", func() { - Describe("findPlaylistSidecarPath", func() { - It("discovers sidecar image next to playlist file", func() { - tmpDir := GinkgoT().TempDir() - plsPath := filepath.Join(tmpDir, "MyPlaylist.m3u") - imgPath := filepath.Join(tmpDir, "MyPlaylist.jpg") - Expect(os.WriteFile(plsPath, []byte("#EXTM3U\n"), 0600)).To(Succeed()) - Expect(os.WriteFile(imgPath, []byte("fake image"), 0600)).To(Succeed()) - - result := findPlaylistSidecarPath(GinkgoT().Context(), plsPath) - Expect(result).To(Equal(imgPath)) - }) - - It("returns empty string when no sidecar image exists", func() { - tmpDir := GinkgoT().TempDir() - plsPath := filepath.Join(tmpDir, "MyPlaylist.m3u") - Expect(os.WriteFile(plsPath, []byte("#EXTM3U\n"), 0600)).To(Succeed()) - - result := findPlaylistSidecarPath(GinkgoT().Context(), plsPath) - Expect(result).To(BeEmpty()) - }) - - It("returns empty string when playlist has no path", func() { - result := findPlaylistSidecarPath(GinkgoT().Context(), "") - Expect(result).To(BeEmpty()) - }) - - It("finds sidecar with different case base name", func() { - tmpDir := GinkgoT().TempDir() - plsPath := filepath.Join(tmpDir, "myplaylist.m3u") - imgPath := filepath.Join(tmpDir, "MyPlaylist.jpg") - Expect(os.WriteFile(plsPath, []byte("#EXTM3U\n"), 0600)).To(Succeed()) - Expect(os.WriteFile(imgPath, []byte("fake image"), 0600)).To(Succeed()) - - result := findPlaylistSidecarPath(GinkgoT().Context(), plsPath) - Expect(result).To(Equal(imgPath)) - }) - }) - - Describe("fromPlaylistExternalImage", func() { - It("opens local path from ExternalImageURL", func() { - tmpDir := GinkgoT().TempDir() - imgPath := filepath.Join(tmpDir, "cover.jpg") - Expect(os.WriteFile(imgPath, []byte("external image data"), 0600)).To(Succeed()) - - reader := &playlistArtworkReader{ - pl: model.Playlist{ExternalImageURL: imgPath}, - } - r, path, err := reader.fromPlaylistExternalImage(ctx)() - Expect(err).ToNot(HaveOccurred()) - Expect(r).ToNot(BeNil()) - Expect(path).To(Equal(imgPath)) - data, _ := io.ReadAll(r) - Expect(string(data)).To(Equal("external image data")) - r.Close() - }) - - It("returns nil when ExternalImageURL is empty", func() { - reader := &playlistArtworkReader{ - pl: model.Playlist{ExternalImageURL: ""}, - } - r, path, err := reader.fromPlaylistExternalImage(ctx)() - Expect(err).ToNot(HaveOccurred()) - Expect(r).To(BeNil()) - Expect(path).To(BeEmpty()) - }) - - It("returns error when local file does not exist", func() { - reader := &playlistArtworkReader{ - pl: model.Playlist{ExternalImageURL: "/non/existent/path/cover.jpg"}, - } - r, _, err := reader.fromPlaylistExternalImage(ctx)() - Expect(err).To(HaveOccurred()) - Expect(r).To(BeNil()) - }) - - It("skips HTTP URL when EnableM3UExternalAlbumArt is false", func() { - conf.Server.EnableM3UExternalAlbumArt = false - - reader := &playlistArtworkReader{ - pl: model.Playlist{ExternalImageURL: "https://example.com/cover.jpg"}, - } - r, path, err := reader.fromPlaylistExternalImage(ctx)() - Expect(err).ToNot(HaveOccurred()) - Expect(r).To(BeNil()) - Expect(path).To(BeEmpty()) - }) - - It("still opens local path when EnableM3UExternalAlbumArt is false", func() { - conf.Server.EnableM3UExternalAlbumArt = false - - tmpDir := GinkgoT().TempDir() - imgPath := filepath.Join(tmpDir, "cover.jpg") - Expect(os.WriteFile(imgPath, []byte("local image"), 0600)).To(Succeed()) - - reader := &playlistArtworkReader{ - pl: model.Playlist{ExternalImageURL: imgPath}, - } - r, path, err := reader.fromPlaylistExternalImage(ctx)() - Expect(err).ToNot(HaveOccurred()) - Expect(r).ToNot(BeNil()) - Expect(path).To(Equal(imgPath)) - r.Close() - }) - }) - }) - - Describe("resizedArtworkReader", func() { - BeforeEach(func() { - folderRepo.result = []model.Folder{{ - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"cover.jpg", "front.png"}, - }} - ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{ - alMultipleCovers, - }) - }) - When("Square is false", func() { - It("returns PNG if original image is a PNG", func() { - conf.Server.CoverArtPriority = "front.png" - r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false) - Expect(err).ToNot(HaveOccurred()) - - img, format, err := image.Decode(r) - Expect(err).ToNot(HaveOccurred()) - Expect(format).To(Equal("png")) - Expect(img.Bounds().Size().X).To(Equal(15)) - Expect(img.Bounds().Size().Y).To(Equal(15)) - }) - It("returns JPEG if original image is not a PNG", func() { - conf.Server.CoverArtPriority = "cover.jpg" - r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200, false) - Expect(err).ToNot(HaveOccurred()) - - img, format, err := image.Decode(r) - Expect(format).To(Equal("jpeg")) - Expect(err).ToNot(HaveOccurred()) - Expect(img.Bounds().Size().X).To(Equal(200)) - Expect(img.Bounds().Size().Y).To(Equal(200)) - }) - }) - When("When square is true", func() { - var alCover model.Album - - DescribeTable("resize", - func(srcFormat string, expectedFormat string, landscape bool, size int) { - coverFileName := "cover." + srcFormat - dirName := createImage(srcFormat, landscape, size) - alCover = model.Album{ - ID: "444", - Name: "Only external", - FolderIDs: []string{"tmp"}, - } - folderRepo.result = []model.Folder{{ImageFiles: []string{coverFileName}}} - rootLibRepo := &tests.MockLibraryRepo{} - rootLibRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(dirName)}}) - ds.(*tests.MockDataStore).MockedLibrary = rootLibRepo - ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{ - alCover, - }) - - conf.Server.CoverArtPriority = coverFileName - r, _, err := aw.Get(context.Background(), alCover.CoverArtID(), size, true) - Expect(err).ToNot(HaveOccurred()) - - img, format, err := image.Decode(r) - Expect(err).ToNot(HaveOccurred()) - Expect(format).To(Equal(expectedFormat)) - Expect(img.Bounds().Size().X).To(Equal(size)) - Expect(img.Bounds().Size().Y).To(Equal(size)) - }, - Entry("portrait png image", "png", "png", false, 200), - Entry("landscape png image", "png", "png", true, 200), - Entry("portrait jpg image", "jpg", "png", false, 200), - Entry("landscape jpg image", "jpg", "png", true, 200), - ) - }) - When("EnableWebPEncoding is true and square is false", func() { - BeforeEach(func() { - conf.Server.EnableWebPEncoding = true - }) - It("returns WebP even if original image is a PNG", func() { - conf.Server.CoverArtPriority = "front.png" - r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false) - Expect(err).ToNot(HaveOccurred()) - - img, format, err := image.Decode(r) - Expect(err).ToNot(HaveOccurred()) - Expect(format).To(Equal("webp")) - Expect(img.Bounds().Size().X).To(Equal(15)) - Expect(img.Bounds().Size().Y).To(Equal(15)) - }) - It("returns WebP if original image is not a PNG", func() { - conf.Server.CoverArtPriority = "cover.jpg" - r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200, false) - Expect(err).ToNot(HaveOccurred()) - - img, format, err := image.Decode(r) - Expect(format).To(Equal("webp")) - Expect(err).ToNot(HaveOccurred()) - Expect(img.Bounds().Size().X).To(Equal(200)) - Expect(img.Bounds().Size().Y).To(Equal(200)) - }) - }) - When("EnableWebPEncoding is false and square is false", func() { - BeforeEach(func() { - conf.Server.EnableWebPEncoding = false - }) - It("returns PNG if original image is a PNG", func() { - conf.Server.CoverArtPriority = "front.png" - r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15, false) - Expect(err).ToNot(HaveOccurred()) - - img, format, err := image.Decode(r) - Expect(err).ToNot(HaveOccurred()) - Expect(format).To(Equal("png")) - Expect(img.Bounds().Size().X).To(Equal(15)) - Expect(img.Bounds().Size().Y).To(Equal(15)) - }) - It("returns JPEG if original image is a JPG", func() { - conf.Server.CoverArtPriority = "cover.jpg" - r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200, false) - Expect(err).ToNot(HaveOccurred()) - - img, format, err := image.Decode(r) - Expect(err).ToNot(HaveOccurred()) - Expect(format).To(Equal("jpeg")) - Expect(img.Bounds().Size().X).To(Equal(200)) - Expect(img.Bounds().Size().Y).To(Equal(200)) - }) - }) - When("EnableWebPEncoding is false and square is true", func() { - var alCover model.Album - - BeforeEach(func() { - conf.Server.EnableWebPEncoding = false - }) - It("returns PNG for square mode", func() { - dirName := createImage("png", false, 200) - alCover = model.Album{ - ID: "444", - Name: "Only external", - FolderIDs: []string{"tmp"}, - } - folderRepo.result = []model.Folder{{ImageFiles: []string{"cover.png"}}} - rootLibRepo := &tests.MockLibraryRepo{} - rootLibRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(dirName)}}) - ds.(*tests.MockDataStore).MockedLibrary = rootLibRepo - ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{alCover}) - - conf.Server.CoverArtPriority = "cover.png" - r, _, err := aw.Get(context.Background(), alCover.CoverArtID(), 200, true) - Expect(err).ToNot(HaveOccurred()) - - img, format, err := image.Decode(r) - Expect(err).ToNot(HaveOccurred()) - Expect(format).To(Equal("png")) - Expect(img.Bounds().Size().X).To(Equal(200)) - Expect(img.Bounds().Size().Y).To(Equal(200)) - }) - }) - When("Requested size is larger than original", func() { - It("clamps size to original dimensions", func() { - conf.Server.CoverArtPriority = "front.png" - // front.png is 16x16, requesting 99999 should return at original size - r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 99999, false) - Expect(err).ToNot(HaveOccurred()) - - img, _, err := image.Decode(r) - Expect(err).ToNot(HaveOccurred()) - // Should be clamped to original size (16), not 99999 - Expect(img.Bounds().Size().X).To(Equal(16)) - Expect(img.Bounds().Size().Y).To(Equal(16)) - }) - - It("clamps square size to original dimensions", func() { - conf.Server.CoverArtPriority = "front.png" - // front.png is 16x16, requesting 99999 with square should return 16x16 square - r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 99999, true) - Expect(err).ToNot(HaveOccurred()) - - img, _, err := image.Decode(r) - Expect(err).ToNot(HaveOccurred()) - // Should be clamped to original size (16), not 99999 - Expect(img.Bounds().Size().X).To(Equal(16)) - Expect(img.Bounds().Size().Y).To(Equal(16)) - }) - }) - }) -}) - -func createImage(format string, landscape bool, size int) string { - var img image.Image - - if landscape { - img = image.NewRGBA(image.Rect(0, 0, size, size/2)) - } else { - img = image.NewRGBA(image.Rect(0, 0, size/2, size)) - } - - tmpDir := GinkgoT().TempDir() - f, _ := os.Create(filepath.Join(tmpDir, "cover."+format)) - defer f.Close() - switch format { - case "png": - _ = png.Encode(f, img) - case "jpg": - _ = jpeg.Encode(f, img, &jpeg.Options{Quality: 75}) - } - - return tmpDir -} diff --git a/core/artwork/artwork_suite_test.go b/core/artwork/artwork_suite_test.go index d42d7f3e4..1ea82b7fa 100644 --- a/core/artwork/artwork_suite_test.go +++ b/core/artwork/artwork_suite_test.go @@ -11,13 +11,26 @@ import ( "github.com/navidrome/navidrome/core/storage" "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/metadata" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "go.uber.org/goleak" ) func TestArtwork(t *testing.T) { + // Runs unconditionally: the two leaks below are pre-existing and out of this + // package's control, so they're ignored by exact top-function instead. + defer goleak.VerifyNone(t, + goleak.IgnoreTopFunction("github.com/onsi/ginkgo/v2/internal/interrupt_handler.(*InterruptHandler).registerForInterrupts.func2"), + // notify's own init() starts a singleton tree the moment it's imported (via + // core/storage/local or plugins); recursive on darwin, nonrecursive on linux. + goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*recursiveTree).dispatch"), + goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*nonrecursiveTree).dispatch"), + goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*nonrecursiveTree).internal"), + ) + tests.Init(t, false) log.SetLevel(log.LevelFatal) RegisterFailHandler(Fail) @@ -25,7 +38,6 @@ func TestArtwork(t *testing.T) { } // osDirFS wraps os.DirFS as a storage.MusicFS for integration tests. -// ReadTags is not used by albumArtworkReader, so it is left as a stub. type osDirFS struct{ fs.FS } func (o osDirFS) ReadTags(...string) (map[string]metadata.Info, error) { return nil, nil } @@ -69,3 +81,37 @@ func (s *osDirStorage) FS() (storage.MusicFS, error) { } return osDirFS{os.DirFS(s.root)}, nil } + +// fakeFolderRepo covers the three FolderRepository methods the resolvers reach for. The zero value +// answers as an unremarkable library does; the fields drive the album-root lookup and its failures. +type fakeFolderRepo struct { + model.FolderRepository + result []model.Folder + err error + parentResult *model.Folder + getErr error + getCallCount int + // hasOtherAudio is returned by HasAudioOutsideFolders (the album-root check). + // False means the parent qualifies as an album root. + hasOtherAudio bool + otherAudioErr error +} + +func (f *fakeFolderRepo) GetAll(...model.QueryOptions) ([]model.Folder, error) { + return f.result, f.err +} + +func (f *fakeFolderRepo) HasAudioOutsideFolders(model.Folder, []string) (bool, error) { + return f.hasOtherAudio, f.otherAudioErr +} + +func (f *fakeFolderRepo) Get(string) (*model.Folder, error) { + f.getCallCount++ + if f.getErr != nil { + return nil, f.getErr + } + if f.parentResult != nil { + return f.parentResult, nil + } + return nil, model.ErrNotFound +} diff --git a/core/artwork/artwork_test.go b/core/artwork/artwork_test.go index adddd0dc3..c6b848101 100644 --- a/core/artwork/artwork_test.go +++ b/core/artwork/artwork_test.go @@ -1,57 +1,523 @@ -package artwork_test +package artwork import ( + "bytes" "context" + "image" "io" + "os" + "path/filepath" + "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/resources" "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/utils/cache" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) var _ = Describe("Artwork", func() { - var aw artwork.Artwork - var ds model.DataStore - var ffmpeg *tests.MockFFmpeg + var ( + ctx context.Context + ds *tests.MockDataStore + artRepo *tests.MockArtworkRepo + queueRepo *tests.MockArtworkQueueRepo + albumRepo *tests.MockAlbumRepo + mfRepo *tests.MockMediaFileRepo + folderRepo *fakeFolderRepo + libRepo *tests.MockLibraryRepo + ffm *tests.MockFFmpeg + store *ImageStore + imgCache cache.FileCache + svc Artwork + repoRoot string + coverBytes []byte + seedEntity func(kind, id string) + ) + + primaryKey := func(kind, id string) string { return kind + "|" + id + "|" + model.ImageTypePrimary } + + seedFoundStore := func(kind, id string, imgBytes []byte) string { + hash, err := hashImage(bytes.NewReader(imgBytes)) + Expect(err).ToNot(HaveOccurred()) + Expect(store.Write(hash, "image/jpeg", bytes.NewReader(imgBytes))).To(Succeed()) + Expect(artRepo.PutImage(&model.Artwork{Hash: hash, Mime: "image/jpeg"})).To(Succeed()) + Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: kind, ItemID: id, Hash: hash, Source: "external"})).To(Succeed()) + seedEntity(kind, id) + return hash + } + + // Without its owning entity, a state row is not served at all. + seedEntity = func(kind, id string) { + GinkgoHelper() + switch kind { + case "al": + Expect(albumRepo.Put(&model.Album{ID: id, Name: "Album"})).To(Succeed()) + case "mf": + Expect(mfRepo.Put(&model.MediaFile{ID: id})).To(Succeed()) + } + } + + readAll := func(img *Image) []byte { + GinkgoHelper() + defer img.Close() + data, err := io.ReadAll(img) + Expect(err).ToNot(HaveOccurred()) + return data + } BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) - conf.Server.ImageCacheSize = "0" // Disable cache - cache := artwork.GetImageCache() - ffmpeg = tests.NewMockFFmpeg("content from ffmpeg") - aw = artwork.NewArtwork(ds, cache, ffmpeg, nil) + ctx = context.Background() + var err error + repoRoot, err = os.Getwd() + Expect(err).ToNot(HaveOccurred()) + coverBytes, err = os.ReadFile(filepath.Join(repoRoot, "tests/fixtures/artist/an-album/cover.jpg")) + Expect(err).ToNot(HaveOccurred()) + + conf.Server.EnableWebPEncoding = false + conf.Server.CoverArtQuality = 75 + conf.Server.CoverArtPriority = "cover.*" + conf.Server.DiscArtPriority = "cover.*" + conf.Server.CacheFolder = conf.NewDir(GinkgoT().TempDir()) + + artRepo = tests.CreateMockArtworkRepo() + queueRepo = tests.CreateMockArtworkQueueRepo() + albumRepo = tests.CreateMockAlbumRepo() + mfRepo = tests.CreateMockMediaFileRepo() + folderRepo = &fakeFolderRepo{} + libRepo = &tests.MockLibraryRepo{} + libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) + ds = &tests.MockDataStore{ + MockedArtwork: artRepo, + MockedArtworkQueue: queueRepo, + MockedAlbum: albumRepo, + MockedMediaFile: mfRepo, + MockedFolder: folderRepo, + MockedLibrary: libRepo, + } + ffm = tests.NewMockFFmpeg("") + store = NewImageStore(GinkgoT().TempDir()) + imgCache = cache.NewFileCache("ServingTest", "100MB", "images", 0, + func(ctx context.Context, arg cache.Item) (io.Reader, error) { + return arg.(artworkReader).Reader(ctx) + }) + Eventually(func() bool { return imgCache.Available(ctx) }).Should(BeTrue()) + svc = NewArtwork(ds, imgCache, store, ffm) }) - Context("GetOrPlaceholder", func() { - Context("Empty ID", func() { - It("returns placeholder if album is not in the DB", func() { - r, _, err := aw.GetOrPlaceholder(context.Background(), "", 0, false) - Expect(err).ToNot(HaveOccurred()) + Describe("found state", func() { + It("serves a store-backed found image sized (cache miss resizes, second call is a cache hit)", func() { + seedFoundStore("al", "al1", coverBytes) - ph, err := resources.FS().Open(consts.PlaceholderAlbumArt) - Expect(err).ToNot(HaveOccurred()) - phBytes, err := io.ReadAll(ph) - Expect(err).ToNot(HaveOccurred()) + img, err := svc.Get(ctx, model.MustParseArtworkID("al-al1"), 100, false) + Expect(err).ToNot(HaveOccurred()) + // A resized response versions its ETag with the encode settings, not the pixel hash. + Expect(img.ETag).To(Equal(representationTag(img.Hash, 100, false))) + Expect(img.ETag).ToNot(Equal(img.Hash)) + resized := readAll(img) + cfg, _, err := image.DecodeConfig(bytes.NewReader(resized)) + Expect(err).ToNot(HaveOccurred()) + Expect(cfg.Width).To(Equal(100)) - result, err := io.ReadAll(r) - Expect(err).ToNot(HaveOccurred()) + // Deleting the store file proves the warm entry serves without touching the original. + hash, _ := hashImage(bytes.NewReader(coverBytes)) + Expect(os.Remove(store.path(hash, "image/jpeg"))).To(Succeed()) + Eventually(func(g Gomega) { + img2, err := svc.Get(ctx, model.MustParseArtworkID("al-al1"), 100, false) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(readAll(img2)).To(Equal(resized)) + }).Should(Succeed()) + }) - Expect(result).To(Equal(phBytes)) - }) + It("treats a negative size as a full-size request, not a giant resize", func() { + seedFoundStore("al", "alneg", coverBytes) + + img, err := svc.Get(ctx, model.MustParseArtworkID("al-alneg"), -2000000000, false) + Expect(err).ToNot(HaveOccurred()) + Expect(readAll(img)).To(Equal(coverBytes), "original bytes, no resize (would OOM)") + }) + + It("streams a file-backed found image at full size", func() { + dir := GinkgoT().TempDir() + imgPath := filepath.Join(dir, "cover.jpg") + Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed()) + mtime := fileMtime(imgPath) + Expect(artRepo.PutImage(&model.Artwork{Hash: "aaaaaaaaaaaaaaaa", Mime: "image/jpeg"})).To(Succeed()) + seedEntity("al", "al2") + Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ + ItemKind: "al", ItemID: "al2", Hash: "aaaaaaaaaaaaaaaa", + Source: "folder", SourcePath: imgPath, RefMtime: mtime, + })).To(Succeed()) + + img, err := svc.Get(ctx, model.MustParseArtworkID("al-al2"), 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(readAll(img)).To(Equal(coverBytes)) + }) + + It("treats a full-size mtime mismatch as dangling: unavailable, re-enqueued at Scan, state untouched", func() { + dir := GinkgoT().TempDir() + imgPath := filepath.Join(dir, "cover.jpg") + Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed()) + Expect(artRepo.PutImage(&model.Artwork{Hash: "bbbbbbbbbbbbbbbb", Mime: "image/jpeg"})).To(Succeed()) + seedEntity("al", "al3") + Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ + ItemKind: "al", ItemID: "al3", Hash: "bbbbbbbbbbbbbbbb", + Source: "folder", SourcePath: imgPath, RefMtime: fileMtime(imgPath) + 999, + })).To(Succeed()) + + _, err := svc.Get(ctx, model.MustParseArtworkID("al-al3"), 0, false) + Expect(err).To(MatchError(ErrUnavailable)) + Expect(queueRepo.Data[primaryKey("al", "al3")].Priority).To(Equal(model.ArtworkPriorityScan)) + ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al3", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Hash).To(Equal("bbbbbbbbbbbbbbbb")) + }) + + It("enforces the mtime rule on the sized (loader) path too", func() { + dir := GinkgoT().TempDir() + imgPath := filepath.Join(dir, "cover.jpg") + Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed()) + Expect(artRepo.PutImage(&model.Artwork{Hash: "cccccccccccccccc", Mime: "image/jpeg"})).To(Succeed()) + seedEntity("al", "al3b") + Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ + ItemKind: "al", ItemID: "al3b", Hash: "cccccccccccccccc", + Source: "folder", SourcePath: imgPath, RefMtime: fileMtime(imgPath) + 999, + })).To(Succeed()) + + _, err := svc.Get(ctx, model.MustParseArtworkID("al-al3b"), 100, false) + Expect(err).To(MatchError(ErrUnavailable)) + Expect(queueRepo.Data[primaryKey("al", "al3b")].Priority).To(Equal(model.ArtworkPriorityScan)) + }) + + // State rows outlive a deleted entity until the next prune. + It("refuses to serve a found row whose entity is gone", func() { + hash := seedFoundStore("al", "alzz", coverBytes) + Expect(hash).ToNot(BeEmpty()) + albumRepo.SetData(model.Albums{}) // the album is deleted; its artwork row survives + + _, err := svc.Get(ctx, model.MustParseArtworkID("al-alzz"), 0, false) + Expect(err).To(MatchError(ErrUnavailable)) + }) + + It("does not re-enqueue a recently-attempted absent state", func() { + Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ + ItemKind: "al", ItemID: "al4", AttemptedAt: time.Now(), + })).To(Succeed()) + + _, err := svc.Get(ctx, model.MustParseArtworkID("al-al4"), 0, false) + Expect(err).To(MatchError(ErrUnavailable)) + Expect(queueRepo.Data).To(BeEmpty()) + }) + + It("promotes a stale absent state at Bump priority on view", func() { + Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ + ItemKind: "al", ItemID: "al4b", AttemptedAt: time.Now().Add(-2 * requestRecheckAge), + })).To(Succeed()) + + _, err := svc.Get(ctx, model.MustParseArtworkID("al-al4b"), 0, false) + Expect(err).To(MatchError(ErrUnavailable)) + Expect(queueRepo.Data[primaryKey("al", "al4b")].Priority).To(Equal(model.ArtworkPriorityBump)) + ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al4b", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Hash).To(BeEmpty()) }) }) - Context("Get", func() { - Context("Empty ID", func() { - It("returns an ErrUnavailable error", func() { - _, _, err := aw.Get(context.Background(), model.ArtworkID{}, 0, false) - Expect(err).To(MatchError(artwork.ErrUnavailable)) - }) + + Describe("provisional read-through", func() { + It("serves local folder art, enqueues a Bump, and writes no state row", func() { + folderRepo.result = []model.Folder{{Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}}} + albumRepo.SetData(model.Albums{{ID: "al5", Name: "Album", FolderIDs: []string{"f1"}}}) + + img, err := svc.Get(ctx, model.MustParseArtworkID("al-al5"), 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(readAll(img)).To(Equal(coverBytes)) + + Expect(queueRepo.Data[primaryKey("al", "al5")].Priority).To(Equal(model.ArtworkPriorityBump)) + _, err = artRepo.GetItemArtwork(model.KindAlbumArtwork, "al5", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("returns ErrUnavailable and enqueues a Bump when nothing local resolves", func() { + folderRepo.result = nil + albumRepo.SetData(model.Albums{{ID: "al6", Name: "Album"}}) + + _, err := svc.Get(ctx, model.MustParseArtworkID("al-al6"), 0, false) + Expect(err).To(MatchError(ErrUnavailable)) + Expect(queueRepo.Data[primaryKey("al", "al6")].Priority).To(Equal(model.ArtworkPriorityBump)) + _, err = artRepo.GetItemArtwork(model.KindAlbumArtwork, "al6", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + }) + + Describe("media file", func() { + It("serves a track's own found art", func() { + seedFoundStore("mf", "mf1", coverBytes) + + img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf1"), 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(readAll(img)).To(Equal(coverBytes)) + }) + + It("ignores a resolved mf row and delegates to the album when per-track art is disabled", func() { + conf.Server.EnableMediaFileCoverArt = false + seedFoundStore("mf", "mf7", []byte("stale embedded track art")) + seedFoundStore("al", "albz", coverBytes) + mfRepo.SetData(model.MediaFiles{{ID: "mf7", AlbumID: "albz"}}) + + img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf7"), 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(readAll(img)).To(Equal(coverBytes), "album art, not the persisted embedded art") + }) + + It("delegates to the album when the track's state is absent", func() { + seedFoundStore("al", "albm", coverBytes) + Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "mf", ItemID: "mf2"})).To(Succeed()) + mfRepo.SetData(model.MediaFiles{{ID: "mf2", AlbumID: "albm"}}) + + img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf2"), 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(readAll(img)).To(Equal(coverBytes)) + _, mfEnq := queueRepo.Data[primaryKey("mf", "mf2")] + Expect(mfEnq).To(BeFalse()) + }) + + It("delegates to the album (no enqueue) when the track is not embedded-eligible", func() { + conf.Server.EnableMediaFileCoverArt = true + seedFoundStore("al", "albn", coverBytes) + mfRepo.SetData(model.MediaFiles{{ID: "mf3", AlbumID: "albn", HasCoverArt: false}}) + + img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf3"), 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(readAll(img)).To(Equal(coverBytes)) + _, mfEnq := queueRepo.Data[primaryKey("mf", "mf3")] + Expect(mfEnq).To(BeFalse()) + }) + + It("extracts embedded art provisionally and enqueues the track when eligible", func() { + conf.Server.EnableMediaFileCoverArt = true + mfRepo.SetData(model.MediaFiles{{ + ID: "mf4", AlbumID: "albo", HasCoverArt: true, + Path: "tests/fixtures/artist/an-album/test.mp3", LibraryID: 0, + }}) + + img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf4"), 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(len(readAll(img))).To(BeNumerically(">", 0)) + Expect(queueRepo.Data[primaryKey("mf", "mf4")].Priority).To(Equal(model.ArtworkPriorityBump)) + _, err = artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mf4", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("delegates a multi-disc track to its disc art, not straight to the album", func() { + folderRepo.result = []model.Folder{{Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}}} + albumRepo.SetData(model.Albums{{ID: "aldd", Name: "Album", FolderIDs: []string{"f1"}, Discs: model.Discs{1: "One", 2: "Two"}}}) + seedFoundStore("al", "aldd", []byte("album-art-distinct")) // album's own found art differs + mfRepo.SetData(model.MediaFiles{{ID: "mf5", AlbumID: "aldd", DiscNumber: 1, HasCoverArt: false}}) + + img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf5"), 0, false) + Expect(err).ToNot(HaveOccurred()) + // The disc-folder image, not the album's own art: proof it routed through serveDisc. + Expect(readAll(img)).To(Equal(coverBytes)) + }) + + It("falls back to the album when an eligible track's embedded art will not extract", func() { + conf.Server.EnableMediaFileCoverArt = true + // HasCoverArt is set, but the file is not audio, so nothing extracts. + mfRepo.SetData(model.MediaFiles{{ + ID: "mfbad", AlbumID: "albad", LibraryID: 0, HasCoverArt: true, + Path: "tests/fixtures/artist/an-album/front.png", + }}) + albumRepo.SetData(model.Albums{{ID: "albad", Name: "Album"}}) + seedFoundStore("al", "albad", coverBytes) + + img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mfbad"), 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(readAll(img)).To(Equal(coverBytes), "a placeholder here would be worse than the album cover") + }) + + It("routes a single-disc track through disc resolution too", func() { + // DiscArtPriority applies to single-disc albums too, over the album's own found art. + folderRepo.result = []model.Folder{{Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}}} + albumRepo.SetData(model.Albums{{ID: "alsd", Name: "Album", FolderIDs: []string{"f1"}, Discs: model.Discs{1: ""}}}) + seedFoundStore("al", "alsd", []byte("album-art-distinct")) + mfRepo.SetData(model.MediaFiles{{ID: "mf6", AlbumID: "alsd", DiscNumber: 1, HasCoverArt: false}}) + + img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf6"), 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(readAll(img)).To(Equal(coverBytes)) + }) + }) + + Describe("disc", func() { + It("serves a local disc-folder image", func() { + folderRepo.result = []model.Folder{{Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}}} + albumRepo.SetData(model.Albums{{ID: "aldc", Name: "Album", FolderIDs: []string{"f1"}}}) + + img, err := svc.Get(ctx, model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID("aldc", 1), nil), 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(readAll(img)).To(Equal(coverBytes)) + }) + + // The resize cache keys on id + album mtime, not on the bytes, so dropping the source + // between the two requests is what shows a warm hit never touches the filesystem. + It("serves a sized disc image from cache without re-reading the source", func() { + folderRepo.result = []model.Folder{{Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}}} + albumRepo.SetData(model.Albums{{ID: "aldc3", Name: "Album", FolderIDs: []string{"f1"}}}) + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID("aldc3", 1), nil) + + first, err := svc.Get(ctx, discID, 64, false) + Expect(err).ToNot(HaveOccurred()) + warmed := readAll(first) + Expect(warmed).ToNot(BeEmpty()) + + folderRepo.result = nil + + second, err := svc.Get(ctx, discID, 64, false) + Expect(err).ToNot(HaveOccurred()) + Expect(readAll(second)).To(Equal(warmed), "a warm sized request must not touch the source") + }) + + // A disc image can change without the album row changing, so the key folds in ImagesUpdatedAt. + It("invalidates the cached image when the folder's images change", func() { + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}, + ImagesUpdatedAt: time.Now().Add(-time.Hour), + }} + albumRepo.SetData(model.Albums{{ID: "aldc4", Name: "Album", FolderIDs: []string{"f1"}}}) + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID("aldc4", 1), nil) + + first, err := svc.Get(ctx, discID, 64, false) + Expect(err).ToNot(HaveOccurred()) + firstKey := first.ETag + readAll(first) + + folderRepo.result[0].ImagesUpdatedAt = time.Now() + second, err := svc.Get(ctx, discID, 64, false) + Expect(err).ToNot(HaveOccurred()) + readAll(second) + Expect(second.ETag).ToNot(Equal(firstKey), "a replaced image must not keep the old cache entry") + }) + + // Disc art has no content hash, so without an explicit validator every ETag would be empty. + It("gives a full-size disc image a validator that tracks the source", func() { + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}, + ImagesUpdatedAt: time.Now().Add(-time.Hour), + }} + albumRepo.SetData(model.Albums{{ID: "aldc5", Name: "Album", FolderIDs: []string{"f1"}}}) + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID("aldc5", 1), nil) + + first, err := svc.Get(ctx, discID, 0, false) + Expect(err).ToNot(HaveOccurred()) + readAll(first) + Expect(first.ETag).ToNot(BeEmpty()) + + folderRepo.result[0].ImagesUpdatedAt = time.Now() + second, err := svc.Get(ctx, discID, 0, false) + Expect(err).ToNot(HaveOccurred()) + readAll(second) + Expect(second.ETag).ToNot(Equal(first.ETag), "a replaced image must not revalidate as unchanged") + }) + + It("falls back to album art when no disc image matches", func() { + folderRepo.result = nil + albumRepo.SetData(model.Albums{{ID: "aldc2", Name: "Album"}}) + seedFoundStore("al", "aldc2", coverBytes) + + img, err := svc.Get(ctx, model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID("aldc2", 1), nil), 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(readAll(img)).To(Equal(coverBytes)) + }) + }) + + Describe("GetOrPlaceholder", func() { + It("accepts a raw entity id and serves its cover art", func() { + albumRepo.SetData(model.Albums{{ID: "rawal", Name: "Album"}}) + seedFoundStore("al", "rawal", coverBytes) + + img, err := svc.GetOrPlaceholder(ctx, "rawal", 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(img.Placeholder).To(BeFalse()) + Expect(readAll(img)).To(Equal(coverBytes)) + }) + + It("falls back to the album placeholder ignoring size and square", func() { + img, err := svc.GetOrPlaceholder(ctx, "", 300, true) + Expect(err).ToNot(HaveOccurred()) + Expect(img.Placeholder).To(BeTrue()) + Expect(img.Hash).To(BeEmpty()) + Expect(img.LastUpdated).To(BeZero()) + + ph, err := resources.FS().Open(consts.PlaceholderAlbumArt) + Expect(err).ToNot(HaveOccurred()) + phBytes, _ := io.ReadAll(ph) + Expect(readAll(img)).To(Equal(phBytes)) + }) + + It("falls back to the artist placeholder for an absent artist", func() { + Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "arph"})).To(Succeed()) + + img, err := svc.GetOrPlaceholder(ctx, "ar-arph", 300, false) + Expect(err).ToNot(HaveOccurred()) + Expect(img.Placeholder).To(BeTrue()) + + ph, err := resources.FS().Open(consts.PlaceholderArtistArt) + Expect(err).ToNot(HaveOccurred()) + phBytes, _ := io.ReadAll(ph) + Expect(readAll(img)).To(Equal(phBytes)) + }) + + // "No art" and "no such entity" are different answers: clients 404 only on the latter. + It("reports not-found rather than a placeholder for an id with no entity", func() { + _, err := svc.GetOrPlaceholder(ctx, "al-nosuchalbum", 0, false) + Expect(err).To(MatchError(model.ErrNotFound)) + + _, err = svc.GetOrPlaceholder(ctx, "nosuchrawid", 0, false) + Expect(err).To(MatchError(model.ErrNotFound)) }) }) }) + +func fileMtime(path string) int64 { + GinkgoHelper() + info, err := os.Stat(path) + Expect(err).ToNot(HaveOccurred()) + return info.ModTime().UnixNano() +} + +var _ = Describe("EntityExists", func() { + var ctx context.Context + var ds *tests.MockDataStore + + BeforeEach(func() { + ctx = context.Background() + albumRepo := tests.CreateMockAlbumRepo() + albumRepo.SetData(model.Albums{{ID: "al1"}}) + artistRepo := tests.CreateMockArtistRepo() + artistRepo.SetData(model.Artists{{ID: "ar1"}}) + radioRepo := tests.CreateMockedRadioRepo() + Expect(radioRepo.Put(&model.Radio{ID: "ra1", Name: "R"})).To(Succeed()) + ds = &tests.MockDataStore{MockedAlbum: albumRepo, MockedArtist: artistRepo, MockedRadio: radioRepo} + }) + + DescribeTable("reports whether the owning entity is still there", + func(id string, expected bool) { + Expect(entityExists(ctx, ds, model.MustParseArtworkID(id))).To(Equal(expected)) + }, + Entry("existing album", "al-al1", true), + Entry("deleted album", "al-gone", false), + Entry("existing artist", "ar-ar1", true), + Entry("deleted artist", "ar-gone", false), + Entry("existing radio", "ra-ra1", true), + Entry("deleted radio", "ra-gone", false), + // Disc art has no entity of its own; it stands or falls with its album. + Entry("disc of an existing album", "dc-al1:1", true), + Entry("disc of a deleted album", "dc-gone:1", false), + Entry("malformed disc id", "dc-nodiscnum", false), + ) +}) diff --git a/core/artwork/benchmark_e2e_test.go b/core/artwork/benchmark_e2e_test.go deleted file mode 100644 index bf3d435a8..000000000 --- a/core/artwork/benchmark_e2e_test.go +++ /dev/null @@ -1,189 +0,0 @@ -package artwork - -import ( - "context" - "fmt" - "image/jpeg" - "io" - "os" - "path/filepath" - "runtime" - "sync" - "testing" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - "github.com/navidrome/navidrome/utils/cache" -) - -// setupE2EBenchmark creates an artwork instance with a real album cover image on disk, -// backed by either a real file cache or disabled cache depending on cacheSize. -// Note: This benchmarks artwork.Get() directly (not the full HTTP handler), which covers -// the critical path (source selection, decode, resize, encode, cache). This is a deliberate -// spec deviation — the full HTTP round-trip benchmark requires significant infrastructure -// (DB, scanner, fake filesystem) and can be added later if HTTP overhead proves significant. -// -// Depends on fakeFolderRepo defined in reader_artist_test.go (same package, compiled together). -func setupE2EBenchmark(b *testing.B, cacheSize string) (Artwork, model.ArtworkID, func()) { - b.Helper() - cleanup := configtest.SetupConfig() - b.Cleanup(cleanup) - - tmpDir, err := os.MkdirTemp("", "artwork-bench-*") - if err != nil { - b.Fatal(err) - } - - // Create a realistic cover image on disk - coverPath := filepath.Join(tmpDir, "cover.jpg") - coverImg := generateGradientImage(1000, 1000) - f, err := os.Create(coverPath) - if err != nil { - b.Fatal(err) - } - if err := jpeg.Encode(f, coverImg, &jpeg.Options{Quality: 90}); err != nil { - f.Close() - b.Fatal(err) - } - f.Close() - - // Configure cache - conf.Server.ImageCacheSize = cacheSize - conf.Server.CacheFolder = conf.NewDir(tmpDir) - conf.Server.CoverArtQuality = 75 - conf.Server.CoverArtPriority = "cover.*" - - // Set up mock data store with album pointing to our cover. - // Set UpdatedAt so CoverArtID().LastUpdate is consistent across calls. - album := model.Album{ - ID: "bench-album-1", - Name: "Benchmark Album", - FolderIDs: []string{"f1"}, - UpdatedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), - } - folderRepo := &fakeFolderRepo{ - result: []model.Folder{{ - Path: tmpDir, - ImageFiles: []string{"cover.jpg"}, - }}, - } - ds := &tests.MockDataStore{ - MockedTranscoding: &tests.MockTranscodingRepo{}, - MockedFolder: folderRepo, - } - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{album}) - - artID := album.CoverArtID() - - imgCache := cache.NewFileCache("BenchImage", cacheSize, "bench-images", 0, - func(ctx context.Context, arg cache.Item) (io.Reader, error) { - r, _, err := arg.(artworkReader).Reader(ctx) - return r, err - }) - - // Wait for cache init if enabled - if cacheSize != "0" { - for !imgCache.Available(context.Background()) && !imgCache.Disabled(context.Background()) { - runtime.Gosched() // Yield to allow background init goroutine to run - } - } - - ffmpeg := tests.NewMockFFmpeg("fallback content") - aw := NewArtwork(ds, imgCache, ffmpeg, nil) - - cleanupAll := func() { - os.RemoveAll(tmpDir) - } - return aw, artID, cleanupAll -} - -func BenchmarkArtworkGetE2E(b *testing.B) { - cacheConfigs := []struct { - name string - cacheSize string - }{ - {"no_cache", "0"}, - {"with_cache", "100MB"}, - } - sizes := []int{0, 300} - - for _, cc := range cacheConfigs { - for _, size := range sizes { - b.Run(fmt.Sprintf("%s/size_%d", cc.name, size), func(b *testing.B) { - aw, artID, cleanup := setupE2EBenchmark(b, cc.cacheSize) - defer cleanup() - - // Warm the cache on first call if cache is enabled - if cc.cacheSize != "0" { - r, _, err := aw.Get(context.Background(), artID, size, size > 0) - if err != nil { - b.Fatal(err) - } - _, _ = io.ReadAll(r) - r.Close() - } - - b.ResetTimer() - for i := 0; i < b.N; i++ { - r, _, err := aw.Get(context.Background(), artID, size, size > 0) - if err != nil { - b.Fatal(err) - } - _, _ = io.ReadAll(r) - r.Close() - } - }) - } - } -} - -func BenchmarkArtworkGetE2EConcurrent(b *testing.B) { - cacheConfigs := []struct { - name string - cacheSize string - }{ - {"no_cache", "0"}, - {"with_cache", "100MB"}, - } - concurrencyLevels := []int{10, 50} - - for _, cc := range cacheConfigs { - for _, n := range concurrencyLevels { - b.Run(fmt.Sprintf("%s/goroutines_%d", cc.name, n), func(b *testing.B) { - aw, artID, cleanup := setupE2EBenchmark(b, cc.cacheSize) - defer cleanup() - - // Warm cache - if cc.cacheSize != "0" { - r, _, _ := aw.Get(context.Background(), artID, 300, true) - if r != nil { - _, _ = io.ReadAll(r) - r.Close() - } - } - - b.ResetTimer() - for i := 0; i < b.N; i++ { - var wg sync.WaitGroup - wg.Add(n) - for range n { - go func() { - defer wg.Done() - r, _, err := aw.Get(context.Background(), artID, 300, true) - if err != nil { - b.Error(err) - return - } - _, _ = io.ReadAll(r) - r.Close() - }() - } - wg.Wait() - } - }) - } - } -} diff --git a/core/artwork/benchmark_helpers_test.go b/core/artwork/benchmark_helpers_test.go index 0076506f3..f3201d412 100644 --- a/core/artwork/benchmark_helpers_test.go +++ b/core/artwork/benchmark_helpers_test.go @@ -4,6 +4,7 @@ import ( "bytes" "image" "image/color" + "image/draw" "image/jpeg" "image/png" "testing" @@ -45,3 +46,11 @@ func generateGradientImage(width, height int) *image.RGBA { } return img } + +// gradientNRGBA mirrors generateGradientImage in the type makeThumbnail hands the encoders. +func gradientNRGBA(size int) *image.NRGBA { + src := generateGradientImage(size, size) + dst := image.NewNRGBA(src.Bounds()) + draw.Draw(dst, dst.Bounds(), src, src.Bounds().Min, draw.Src) + return dst +} diff --git a/core/artwork/blurhash/blurhash.go b/core/artwork/blurhash/blurhash.go new file mode 100644 index 000000000..ca5bf7026 --- /dev/null +++ b/core/artwork/blurhash/blurhash.go @@ -0,0 +1,191 @@ +// Package blurhash implements the blurhash encoding (https://github.com/woltapp/blurhash), +// parameterized to match Jellyfin so clients see equivalent hashes. +package blurhash + +import ( + "errors" + "image" + "image/draw" + "math" + "strings" + "sync" + + xdraw "golang.org/x/image/draw" +) + +const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~" + +// maxInputSize: larger inputs are slower with no visible difference in the result. +const maxInputSize = 128 + +// components picks x/y component counts targeting ~16 near-square tiles. +func components(width, height int) (int, int) { + xf := math.Sqrt(16.0 * float64(width) / float64(height)) + yf := xf * float64(height) / float64(width) + return min(int(xf)+1, 9), min(int(yf)+1, 9) +} + +// Encode returns the blurhash of img, deriving the component counts from its aspect ratio. +func Encode(img image.Image) (string, error) { + if img.Bounds().Dx() == 0 || img.Bounds().Dy() == 0 { + return "", errors.New("blurhash: empty image") + } + // Pre-downscale: its rounding can flip a component count, and the hash is a client cache key. + xComp, yComp := components(img.Bounds().Dx(), img.Bounds().Dy()) + src := pixelsOf(downscale(img)) + w, h := src.w, src.h + + cosX := make([][]float64, xComp) + for i := range cosX { + cosX[i] = make([]float64, w) + for x := range cosX[i] { + cosX[i][x] = math.Cos(math.Pi * float64(i) * float64(x) / float64(w)) + } + } + cosY := make([][]float64, yComp) + for j := range cosY { + cosY[j] = make([]float64, h) + for y := range cosY[j] { + cosY[j][y] = math.Cos(math.Pi * float64(j) * float64(y) / float64(h)) + } + } + + lin := srgbToLinearTable() + factors := make([][3]float64, xComp*yComp) + for y := range h { + row := src.pix[y*src.stride:] + for x := range w { + p := x * 4 + r, g, b := row[p], row[p+1], row[p+2] + if src.straight { + r, g, b = premultiply(r, g, b, row[p+3]) + } + lr, lg, lb := lin[r], lin[g], lin[b] + for j := range yComp { + for i := range xComp { + basis := cosX[i][x] * cosY[j][y] + f := &factors[j*xComp+i] + f[0] += basis * lr + f[1] += basis * lg + f[2] += basis * lb + } + } + } + } + for idx := range factors { + norm := 2.0 + if idx == 0 { + norm = 1.0 + } + scale := norm / float64(w*h) + factors[idx][0] *= scale + factors[idx][1] *= scale + factors[idx][2] *= scale + } + + var sb strings.Builder + sb.WriteString(encode83((xComp-1)+(yComp-1)*9, 1)) + + // Derived counts are at least 1x9, so there is always at least one AC factor. + ac := factors[1:] + actualMax := 0.0 + for _, f := range ac { + actualMax = max(actualMax, math.Abs(f[0]), math.Abs(f[1]), math.Abs(f[2])) + } + quantMax := int(max(0, min(82, math.Floor(actualMax*166-0.5)))) + maxVal := float64(quantMax+1) / 166 + sb.WriteString(encode83(quantMax, 1)) + + dc := factors[0] + sb.WriteString(encode83(linearToSRGB(dc[0])<<16|linearToSRGB(dc[1])<<8|linearToSRGB(dc[2]), 4)) + for _, f := range ac { + sb.WriteString(encode83(quantAC(f[0], maxVal)*19*19+quantAC(f[1], maxVal)*19+quantAC(f[2], maxVal), 2)) + } + return sb.String(), nil +} + +// pixels is direct Pix access for the pixel loop, avoiding a per-pixel allocation via image.At. +type pixels struct { + pix []uint8 + stride int + w, h int + // straight marks non-premultiplied alpha, which the loop premultiplies to keep the hash + // identical to the one an equivalent *image.RGBA produces. + straight bool +} + +// pixelsOf accepts the two types the artwork pipeline produces without copying, and converts +// anything else. +func pixelsOf(img image.Image) pixels { + b := img.Bounds() + switch src := img.(type) { + case *image.RGBA: + return pixels{pix: src.Pix, stride: src.Stride, w: b.Dx(), h: b.Dy()} + case *image.NRGBA: + return pixels{pix: src.Pix, stride: src.Stride, w: b.Dx(), h: b.Dy(), straight: true} + } + dst := image.NewRGBA(image.Rect(0, 0, b.Dx(), b.Dy())) + draw.Draw(dst, dst.Bounds(), img, b.Min, draw.Src) + return pixels{pix: dst.Pix, stride: dst.Stride, w: b.Dx(), h: b.Dy()} +} + +func premultiply(r, g, b, a uint8) (uint8, uint8, uint8) { + if a == 255 { + return r, g, b + } + return uint8(uint32(r) * uint32(a) / 255), uint8(uint32(g) * uint32(a) / 255), uint8(uint32(b) * uint32(a) / 255) +} + +var srgbToLinearTable = sync.OnceValue(func() *[256]float64 { + var t [256]float64 + for i := range t { + t[i] = srgbToLinear(i) + } + return &t +}) + +func downscale(img image.Image) image.Image { + b := img.Bounds() + w, h := b.Dx(), b.Dy() + if w <= maxInputSize && h <= maxInputSize { + return img + } + scale := float64(maxInputSize) / float64(max(w, h)) + dst := image.NewRGBA(image.Rect(0, 0, max(1, int(float64(w)*scale)), max(1, int(float64(h)*scale)))) + xdraw.ApproxBiLinear.Scale(dst, dst.Bounds(), img, b, draw.Src, nil) + return dst +} + +func quantAC(v, maxVal float64) int { + return int(max(0, min(18, math.Floor(signPow(v/maxVal, 0.5)*9+9.5)))) +} + +func signPow(v, exp float64) float64 { + return math.Copysign(math.Pow(math.Abs(v), exp), v) +} + +func srgbToLinear(v int) float64 { + f := float64(v) / 255 + if f <= 0.04045 { + return f / 12.92 + } + return math.Pow((f+0.055)/1.055, 2.4) +} + +func linearToSRGB(v float64) int { + v = min(max(0, v), 1) + if v <= 0.0031308 { + return int(v*12.92*255 + 0.5) + } + return int((1.055*math.Pow(v, 1/2.4)-0.055)*255 + 0.5) +} + +// encode83 encodes value as a fixed-width, big-endian base83 string of the given length. +func encode83(value, length int) string { + b := make([]byte, length) + for i := length - 1; i >= 0; i-- { + b[i] = alphabet[value%83] + value /= 83 + } + return string(b) +} diff --git a/core/artwork/blurhash/blurhash_suite_test.go b/core/artwork/blurhash/blurhash_suite_test.go new file mode 100644 index 000000000..b0e96d7a7 --- /dev/null +++ b/core/artwork/blurhash/blurhash_suite_test.go @@ -0,0 +1,17 @@ +package blurhash_test + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestBlurHash(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "BlurHash Suite") +} diff --git a/core/artwork/blurhash/blurhash_test.go b/core/artwork/blurhash/blurhash_test.go new file mode 100644 index 000000000..91187165e --- /dev/null +++ b/core/artwork/blurhash/blurhash_test.go @@ -0,0 +1,137 @@ +package blurhash_test + +import ( + "image" + "image/color" + "strings" + + "github.com/navidrome/navidrome/core/artwork/blurhash" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~" + +func decode83(s string) int { + v := 0 + for _, c := range s { + v = v*83 + strings.IndexRune(alphabet, c) + } + return v +} + +func solidImage(w, h int, c color.NRGBA) image.Image { + img := image.NewNRGBA(image.Rect(0, 0, w, h)) + for y := range h { + for x := range w { + img.SetNRGBA(x, y, c) + } + } + return img +} + +func gradientImage(w, h int) image.Image { + img := image.NewNRGBA(image.Rect(0, 0, w, h)) + for y := range h { + for x := range w { + img.SetNRGBA(x, y, color.NRGBA{R: uint8(255 * x / w), G: uint8(255 * y / h), B: 128, A: 255}) + } + } + return img +} + +var _ = Describe("Encode input types", func() { + // The pipeline hands Encode an *image.NRGBA; reading it must stay equivalent to the + // premultiplied *image.RGBA it used to receive, or every hash silently shifts. + buildPair := func(alpha uint8) (*image.NRGBA, *image.RGBA) { + const size = 40 + nrgba := image.NewNRGBA(image.Rect(0, 0, size, size)) + rgba := image.NewRGBA(image.Rect(0, 0, size, size)) + for y := range size { + for x := range size { + c := color.NRGBA{ + R: uint8(255 * x / size), G: uint8(255 * y / size), + B: uint8((x + y) * 255 / (2 * size)), A: alpha, + } + nrgba.SetNRGBA(x, y, c) + rgba.Set(x, y, c) // image.RGBA.Set premultiplies + } + } + return nrgba, rgba + } + + DescribeTable("gives an NRGBA the same hash as the premultiplied RGBA it replaces", + func(alpha uint8) { + nrgba, rgba := buildPair(alpha) + fromNRGBA, err := blurhash.Encode(nrgba) + Expect(err).ToNot(HaveOccurred()) + fromRGBA, err := blurhash.Encode(rgba) + Expect(err).ToNot(HaveOccurred()) + Expect(fromNRGBA).To(Equal(fromRGBA)) + }, + Entry("opaque", uint8(255)), + Entry("partly transparent", uint8(128)), + Entry("fully transparent, which premultiplication crushes to black", uint8(0)), + ) +}) + +var _ = Describe("Encode", func() { + // The size flag encodes (xComp-1) + (yComp-1)*9. + DescribeTable("derives component counts from aspect ratio (Jellyfin formula)", + func(w, h, expectedX, expectedY int) { + hash, err := blurhash.Encode(gradientImage(w, h)) + Expect(err).ToNot(HaveOccurred()) + Expect(decode83(hash[:1])).To(Equal((expectedX - 1) + (expectedY-1)*9)) + }, + Entry("square album art", 60, 60, 5, 5), + Entry("smallest square", 1, 1, 5, 5), + Entry("landscape 16:9", 192, 108, 6, 4), + Entry("portrait 9:16", 108, 192, 4, 6), + Entry("extreme landscape capped at 9", 1000, 10, 9, 1), + Entry("extreme portrait capped at 9", 10, 1000, 1, 9), + ) + + It("rejects an empty image", func() { + _, err := blurhash.Encode(image.NewNRGBA(image.Rect(0, 0, 0, 0))) + Expect(err).To(HaveOccurred()) + }) + + It("produces the spec-mandated length", func() { + // 1 (size flag) + 1 (max AC) + 4 (DC) + 2 per AC component; a square derives 5x5 + h, err := blurhash.Encode(solidImage(8, 8, color.NRGBA{R: 10, G: 20, B: 30, A: 255})) + Expect(err).ToNot(HaveOccurred()) + Expect(h).To(HaveLen(4 + 2 + 2*(5*5-1))) + }) + + It("stores the average color in the DC component", func() { + h, err := blurhash.Encode(solidImage(16, 16, color.NRGBA{R: 200, G: 100, B: 50, A: 255})) + Expect(err).ToNot(HaveOccurred()) + dc := decode83(h[2:6]) + Expect(dc >> 16).To(BeNumerically("~", 200, 1)) + Expect((dc >> 8) & 0xFF).To(BeNumerically("~", 100, 1)) + Expect(dc & 0xFF).To(BeNumerically("~", 50, 1)) + }) + + It("is deterministic", func() { + img := gradientImage(64, 64) + h1, err1 := blurhash.Encode(img) + h2, err2 := blurhash.Encode(img) + Expect(err1).ToNot(HaveOccurred()) + Expect(err2).ToNot(HaveOccurred()) + Expect(h1).To(Equal(h2)) + }) + + It("produces different hashes for different images", func() { + h1, _ := blurhash.Encode(solidImage(16, 16, color.NRGBA{R: 255, A: 255})) + h2, _ := blurhash.Encode(gradientImage(16, 16)) + Expect(h1).ToNot(Equal(h2)) + }) + + It("downscales large images internally without changing the result materially", func() { + big, err := blurhash.Encode(solidImage(1000, 1000, color.NRGBA{R: 60, G: 120, B: 180, A: 255})) + Expect(err).ToNot(HaveOccurred()) + small, err := blurhash.Encode(solidImage(16, 16, color.NRGBA{R: 60, G: 120, B: 180, A: 255})) + Expect(err).ToNot(HaveOccurred()) + Expect(big[2:6]).To(Equal(small[2:6])) + }) +}) diff --git a/core/artwork/cache_warmer.go b/core/artwork/cache_warmer.go deleted file mode 100644 index 5090d638e..000000000 --- a/core/artwork/cache_warmer.go +++ /dev/null @@ -1,162 +0,0 @@ -package artwork - -import ( - "context" - "fmt" - "io" - "maps" - "slices" - "sync" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/utils/cache" - "github.com/navidrome/navidrome/utils/pl" -) - -type CacheWarmer interface { - PreCache(artID model.ArtworkID) -} - -// NewCacheWarmer creates a new CacheWarmer instance. The CacheWarmer will pre-cache Artwork images in the background -// to speed up the response time when the image is requested by the UI. The cache is pre-populated with the original -// image size, as well as the size defined by the UICoverArtSize config option. -func NewCacheWarmer(artwork Artwork, cache cache.FileCache) CacheWarmer { - // If image cache is disabled, return a NOOP implementation - if conf.Server.ImageCacheSize == "0" || !conf.Server.EnableArtworkPrecache { - return &noopCacheWarmer{} - } - - // If the file cache is disabled, return a NOOP implementation - if cache.Disabled(context.Background()) { - log.Debug("Image cache disabled. Cache warmer will not run") - return &noopCacheWarmer{} - } - - a := &cacheWarmer{ - artwork: artwork, - cache: cache, - buffer: make(map[model.ArtworkID]struct{}), - wakeSignal: make(chan struct{}, 1), - coverArtSize: conf.Server.UICoverArtSize, - } - - // Create a context with a fake admin user, to be able to pre-cache Playlist CoverArts - ctx := request.WithUser(context.TODO(), model.User{IsAdmin: true}) - go a.run(ctx) - return a -} - -type cacheWarmer struct { - artwork Artwork - buffer map[model.ArtworkID]struct{} - mutex sync.Mutex - cache cache.FileCache - wakeSignal chan struct{} - coverArtSize int -} - -func (a *cacheWarmer) PreCache(artID model.ArtworkID) { - if a.cache.Disabled(context.Background()) { - return - } - a.mutex.Lock() - defer a.mutex.Unlock() - a.buffer[artID] = struct{}{} - a.sendWakeSignal() -} - -func (a *cacheWarmer) sendWakeSignal() { - // Don't block if the previous signal was not read yet - select { - case a.wakeSignal <- struct{}{}: - default: - } -} - -func (a *cacheWarmer) run(ctx context.Context) { - for { - a.waitSignal(ctx, 10*time.Second) - if ctx.Err() != nil { - break - } - - if a.cache.Disabled(ctx) { - a.mutex.Lock() - pending := len(a.buffer) - a.buffer = make(map[model.ArtworkID]struct{}) - a.mutex.Unlock() - if pending > 0 { - log.Trace(ctx, "Cache disabled, discarding precache buffer", "bufferLen", pending) - } - return - } - - // If cache not available, keep waiting - if !a.cache.Available(ctx) { - a.mutex.Lock() - bufferLen := len(a.buffer) - a.mutex.Unlock() - if bufferLen > 0 { - log.Trace(ctx, "Cache not available, buffering precache request", "bufferLen", bufferLen) - } - continue - } - - a.mutex.Lock() - - // If there's nothing to send, keep waiting - if len(a.buffer) == 0 { - a.mutex.Unlock() - continue - } - - batch := slices.Collect(maps.Keys(a.buffer)) - a.buffer = make(map[model.ArtworkID]struct{}) - a.mutex.Unlock() - - a.processBatch(ctx, batch) - } -} - -func (a *cacheWarmer) waitSignal(ctx context.Context, timeout time.Duration) { - select { - case <-time.After(timeout): - case <-a.wakeSignal: - case <-ctx.Done(): - } -} - -func (a *cacheWarmer) processBatch(ctx context.Context, batch []model.ArtworkID) { - log.Trace(ctx, "PreCaching a new batch of artwork", "batchSize", len(batch)) - input := pl.FromSlice(ctx, batch) - errs := pl.Sink(ctx, 4, input, a.doCacheImage) - for err := range errs { - log.Debug(ctx, "Error warming cache", err) - } -} - -func (a *cacheWarmer) doCacheImage(ctx context.Context, id model.ArtworkID) error { - ctx, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() - - size := a.coverArtSize - r, _, err := a.artwork.Get(ctx, id, size, true) - if err != nil { - return fmt.Errorf("caching id='%s', size=%d: %w", id, size, err) - } - _, err = io.Copy(io.Discard, r) - r.Close() - return err -} - -func NoopCacheWarmer() CacheWarmer { - return &noopCacheWarmer{} -} - -type noopCacheWarmer struct{} - -func (a *noopCacheWarmer) PreCache(model.ArtworkID) {} diff --git a/core/artwork/cache_warmer_test.go b/core/artwork/cache_warmer_test.go deleted file mode 100644 index a5da2004c..000000000 --- a/core/artwork/cache_warmer_test.go +++ /dev/null @@ -1,245 +0,0 @@ -package artwork - -import ( - "context" - "errors" - "fmt" - "io" - "strings" - "sync" - "sync/atomic" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/cache" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("CacheWarmer", func() { - var ( - fc *mockFileCache - aw *mockArtwork - ) - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - fc = &mockFileCache{} - aw = &mockArtwork{} - }) - - Context("initialization", func() { - It("returns noop when cache is disabled", func() { - fc.SetDisabled(true) - cw := NewCacheWarmer(aw, fc) - _, ok := cw.(*noopCacheWarmer) - Expect(ok).To(BeTrue()) - }) - - It("returns noop when ImageCacheSize is 0", func() { - conf.Server.ImageCacheSize = "0" - cw := NewCacheWarmer(aw, fc) - _, ok := cw.(*noopCacheWarmer) - Expect(ok).To(BeTrue()) - }) - - It("returns noop when EnableArtworkPrecache is false", func() { - conf.Server.EnableArtworkPrecache = false - cw := NewCacheWarmer(aw, fc) - _, ok := cw.(*noopCacheWarmer) - Expect(ok).To(BeTrue()) - }) - - It("returns real implementation when properly configured", func() { - conf.Server.ImageCacheSize = "100MB" - conf.Server.EnableArtworkPrecache = true - fc.SetDisabled(false) - cw := NewCacheWarmer(aw, fc) - _, ok := cw.(*cacheWarmer) - Expect(ok).To(BeTrue()) - }) - }) - - Context("buffer management", func() { - BeforeEach(func() { - conf.Server.ImageCacheSize = "100MB" - conf.Server.EnableArtworkPrecache = true - fc.SetDisabled(false) - }) - - It("drops buffered items when cache becomes disabled", func() { - cw := NewCacheWarmer(aw, fc).(*cacheWarmer) - cw.PreCache(model.MustParseArtworkID("al-test")) - fc.SetDisabled(true) - Eventually(func() int { - cw.mutex.Lock() - defer cw.mutex.Unlock() - return len(cw.buffer) - }).Should(Equal(0)) - }) - - It("adds multiple items to buffer", func() { - fc.SetReady(false) // Make cache unavailable so items stay in buffer - cw := NewCacheWarmer(aw, fc).(*cacheWarmer) - cw.PreCache(model.MustParseArtworkID("al-1")) - cw.PreCache(model.MustParseArtworkID("al-2")) - cw.mutex.Lock() - defer cw.mutex.Unlock() - Expect(len(cw.buffer)).To(Equal(2)) - }) - - It("deduplicates items in buffer", func() { - fc.SetReady(false) // Make cache unavailable so items stay in buffer - cw := NewCacheWarmer(aw, fc).(*cacheWarmer) - cw.PreCache(model.MustParseArtworkID("al-1")) - cw.PreCache(model.MustParseArtworkID("al-1")) - cw.mutex.Lock() - defer cw.mutex.Unlock() - Expect(len(cw.buffer)).To(Equal(1)) - }) - }) - - Context("error handling", func() { - BeforeEach(func() { - conf.Server.ImageCacheSize = "100MB" - conf.Server.EnableArtworkPrecache = true - fc.SetDisabled(false) - }) - - It("continues processing after artwork retrieval error", func() { - aw.err = errors.New("artwork error") - cw := NewCacheWarmer(aw, fc).(*cacheWarmer) - cw.PreCache(model.MustParseArtworkID("al-error")) - cw.PreCache(model.MustParseArtworkID("al-1")) - - Eventually(func() int { - cw.mutex.Lock() - defer cw.mutex.Unlock() - return len(cw.buffer) - }).Should(Equal(0)) - }) - - It("continues processing after cache error", func() { - fc.err = errors.New("cache error") - cw := NewCacheWarmer(aw, fc).(*cacheWarmer) - cw.PreCache(model.MustParseArtworkID("al-error")) - cw.PreCache(model.MustParseArtworkID("al-1")) - - Eventually(func() int { - cw.mutex.Lock() - defer cw.mutex.Unlock() - return len(cw.buffer) - }).Should(Equal(0)) - }) - }) - - Context("background processing", func() { - BeforeEach(func() { - conf.Server.ImageCacheSize = "100MB" - conf.Server.EnableArtworkPrecache = true - fc.SetDisabled(false) - }) - - It("processes items in batches", func() { - cw := NewCacheWarmer(aw, fc).(*cacheWarmer) - for i := range 5 { - cw.PreCache(model.MustParseArtworkID(fmt.Sprintf("al-%d", i))) - } - - Eventually(func() int { - cw.mutex.Lock() - defer cw.mutex.Unlock() - return len(cw.buffer) - }).Should(Equal(0)) - }) - - It("wakes up on new items", func() { - cw := NewCacheWarmer(aw, fc).(*cacheWarmer) - - // Add first batch - cw.PreCache(model.MustParseArtworkID("al-1")) - Eventually(func() int { - cw.mutex.Lock() - defer cw.mutex.Unlock() - return len(cw.buffer) - }).Should(Equal(0)) - - // Add second batch - cw.PreCache(model.MustParseArtworkID("al-2")) - Eventually(func() int { - cw.mutex.Lock() - defer cw.mutex.Unlock() - return len(cw.buffer) - }).Should(Equal(0)) - }) - - It("pre-caches UICoverArtSize", func() { - cw := NewCacheWarmer(aw, fc).(*cacheWarmer) - cw.PreCache(model.MustParseArtworkID("al-1")) - - Eventually(func() []int { - return aw.getCachedSizes() - }).Should(ContainElements(conf.Server.UICoverArtSize)) - }) - }) -}) - -type mockArtwork struct { - err error - mu sync.Mutex - cachedSizes []int -} - -func (m *mockArtwork) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (io.ReadCloser, time.Time, error) { - if m.err != nil { - return nil, time.Time{}, m.err - } - m.mu.Lock() - m.cachedSizes = append(m.cachedSizes, size) - m.mu.Unlock() - return io.NopCloser(strings.NewReader("test")), time.Now(), nil -} - -func (m *mockArtwork) getCachedSizes() []int { - m.mu.Lock() - defer m.mu.Unlock() - result := make([]int, len(m.cachedSizes)) - copy(result, m.cachedSizes) - return result -} - -func (m *mockArtwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error) { - return m.Get(ctx, model.ArtworkID{}, size, square) -} - -type mockFileCache struct { - disabled atomic.Bool - ready atomic.Bool - err error -} - -func (f *mockFileCache) Get(ctx context.Context, item cache.Item) (*cache.CachedStream, error) { - if f.err != nil { - return nil, f.err - } - return &cache.CachedStream{Reader: io.NopCloser(strings.NewReader("cached"))}, nil -} - -func (f *mockFileCache) Available(ctx context.Context) bool { - return f.ready.Load() && !f.disabled.Load() -} - -func (f *mockFileCache) Disabled(ctx context.Context) bool { - return f.disabled.Load() -} - -func (f *mockFileCache) SetDisabled(v bool) { - f.disabled.Store(v) - f.ready.Store(true) -} - -func (f *mockFileCache) SetReady(v bool) { - f.ready.Store(v) -} diff --git a/core/artwork/reader_disc.go b/core/artwork/disc.go similarity index 60% rename from core/artwork/reader_disc.go rename to core/artwork/disc.go index 0f648c987..21f596b60 100644 --- a/core/artwork/reader_disc.go +++ b/core/artwork/disc.go @@ -2,7 +2,6 @@ package artwork import ( "context" - "crypto/md5" "fmt" "io" "path" @@ -12,16 +11,16 @@ import ( "time" "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils" + "github.com/navidrome/navidrome/utils/slice" ) +// discArtworkReader resolves disc-level artwork from a library's folder images +// and embedded tags. It is used by the serving path's provisional disc read-through. type discArtworkReader struct { - cacheKey - a *artwork album model.Album discNumber int imgFiles []string // library-relative, forward-slash, no leading slash @@ -29,27 +28,40 @@ type discArtworkReader struct { isMultiFolder bool firstTrackRel string // library-relative; for fromTag / ffmpeg via lib.Abs lib libraryView - updatedAt *time.Time + // Newest ImagesUpdatedAt across the album's and this disc's folders: an image can be + // replaced without the album row changing, so this is what makes a cache key notice it. + imagesUpdatedAt time.Time } -func newDiscArtworkReader(ctx context.Context, a *artwork, artID model.ArtworkID) (*discArtworkReader, error) { +// cacheTime is the disc image's validity stamp: any of these moving means the selection may +// have changed. +func (d *discArtworkReader) cacheTime() time.Time { + return utils.TimeNewest(d.album.UpdatedAt, d.album.ImportedAt, d.imagesUpdatedAt) +} + +func newDiscArtworkReader(ctx context.Context, ds model.DataStore, artID model.ArtworkID) (*discArtworkReader, error) { albumID, discNumber, err := model.ParseDiscArtworkID(artID.ID) if err != nil { return nil, fmt.Errorf("invalid disc artwork id '%s': %w", artID.ID, err) } - al, err := a.ds.Album(ctx).Get(albumID) + al, err := ds.Album(ctx).Get(albumID) if err != nil { return nil, err } - _, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, a.ds, *al) + _, imgFiles, albumImagesAt, err := loadAlbumFoldersPaths(ctx, ds, *al) if err != nil { return nil, err } + var imagesUpdatedAt time.Time + if albumImagesAt != nil { + imagesUpdatedAt = *albumImagesAt + } + // Query mediafiles for this album + disc to find folder associations and first track - mfs, err := a.ds.MediaFile(ctx).GetAll(model.QueryOptions{ + mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ Sort: "track_number", Order: "ASC", Filters: squirrel.Eq{"album_id": albumID, "disc_number": discNumber}, @@ -58,29 +70,25 @@ func newDiscArtworkReader(ctx context.Context, a *artwork, artID model.ArtworkID return nil, err } - lib, err := loadLibraryView(ctx, a.ds, al.LibraryID) + lib, err := loadLibraryView(ctx, ds, al.LibraryID) if err != nil { return nil, err } // Build disc folder set and find first track. mf.Path is already library-relative. var firstTrackRel string - allFolderIDs := make(map[string]bool) for _, mf := range mfs { - allFolderIDs[mf.FolderID] = true - if firstTrackRel == "" { + if mf.Path != "" { firstTrackRel = filepath.ToSlash(mf.Path) + break } } + folderIDs := slice.Unique(slice.Map(mfs, func(mf model.MediaFile) string { return mf.FolderID })) // Resolve folder IDs to library-relative paths discFoldersRel := make(map[string]bool) - if len(allFolderIDs) > 0 { - folderIDs := make([]string, 0, len(allFolderIDs)) - for id := range allFolderIDs { - folderIDs = append(folderIDs, id) - } - folders, err := a.ds.Folder(ctx).GetAll(model.QueryOptions{ + if len(folderIDs) > 0 { + folders, err := ds.Folder(ctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"folder.id": folderIDs}, }) if err != nil { @@ -89,49 +97,20 @@ func newDiscArtworkReader(ctx context.Context, a *artwork, artID model.ArtworkID for _, f := range folders { rel := strings.TrimPrefix(path.Join(f.Path, f.Name), "/") discFoldersRel[rel] = true + imagesUpdatedAt = utils.TimeNewest(imagesUpdatedAt, f.ImagesUpdatedAt) } } - isMultiFolder := len(al.FolderIDs) > 1 - - r := &discArtworkReader{ - a: a, - album: *al, - discNumber: discNumber, - imgFiles: imgFiles, - discFoldersRel: discFoldersRel, - isMultiFolder: isMultiFolder, - firstTrackRel: firstTrackRel, - lib: lib, - updatedAt: imagesUpdatedAt, - } - r.cacheKey.artID = artID - r.cacheKey.lastUpdate = utils.TimeNewest(al.UpdatedAt, al.ImportedAt) - if imagesUpdatedAt != nil { - r.cacheKey.lastUpdate = utils.TimeNewest(r.cacheKey.lastUpdate, *imagesUpdatedAt) - } - return r, nil -} - -func (d *discArtworkReader) Key() string { - hash := md5.Sum([]byte(conf.Server.DiscArtPriority)) - return fmt.Sprintf( - "%s.%x", - d.cacheKey.Key(), - hash, - ) -} - -func (d *discArtworkReader) LastUpdated() time.Time { - return d.lastUpdate -} - -func (d *discArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) { - var ff = d.fromDiscArtPriority(ctx, d.a.ffmpeg, conf.Server.DiscArtPriority) - // Fallback to album cover art - albumArtID := model.NewArtworkID(model.KindAlbumArtwork, d.album.ID, &d.album.UpdatedAt) - ff = append(ff, fromAlbum(ctx, d.a, albumArtID)) - return selectImageReader(ctx, d.cacheKey.artID, ff...) + return &discArtworkReader{ + album: *al, + discNumber: discNumber, + imgFiles: imgFiles, + discFoldersRel: discFoldersRel, + isMultiFolder: len(al.FolderIDs) > 1, + firstTrackRel: firstTrackRel, + lib: lib, + imagesUpdatedAt: imagesUpdatedAt, + }, nil } func (d *discArtworkReader) fromDiscArtPriority(ctx context.Context, ffmpeg ffmpeg.FFmpeg, priority string) []sourceFunc { @@ -162,14 +141,13 @@ func (d *discArtworkReader) fromDiscArtPriority(ctx context.Context, ffmpeg ffmp func (d *discArtworkReader) fromDiscSubtitle(ctx context.Context, subtitle string) sourceFunc { return func() (io.ReadCloser, string, error) { for _, file := range d.imgFiles { - name := path.Base(file) - stem := strings.TrimSuffix(name, path.Ext(name)) + stem := utils.BaseName(file) if !strings.EqualFold(stem, subtitle) { continue } f, err := d.lib.FS.Open(file) if err != nil { - log.Warn(ctx, "Could not open disc art file", "file", file, err) + log.Warn(ctx, "Artwork: Could not open disc art file", "file", file, err) continue } return f, file, nil @@ -178,19 +156,12 @@ func (d *discArtworkReader) fromDiscSubtitle(ctx context.Context, subtitle strin } } -// globMetaChars holds the substitution metacharacters understood by -// filepath.Match. The '\' escape character is intentionally excluded: -// disc art patterns come from user config and never include escaped -// metachars in practice, and treating '\' as a metachar would misalign -// the literal-prefix extraction in extractDiscNumber. +// filepath.Match's '\' escape is excluded on purpose: treating it as a metachar +// would misalign the literal-prefix extraction in extractDiscNumber. const globMetaChars = "*?[" -// extractDiscNumber parses the disc number from a filename matched by a -// filepath.Match-style glob pattern. -// -// Both pattern and filename must already be lowercased by the caller, which -// is also expected to have verified that filepath.Match(pattern, filename) -// is true before calling this function. +// extractDiscNumber parses the disc number from a filename matched by a filepath.Match-style +// glob. Caller must lowercase both args and have already verified the match. func extractDiscNumber(pattern, filename string) (int, bool) { metaIdx := strings.IndexAny(pattern, globMetaChars) if metaIdx < 0 { @@ -216,9 +187,8 @@ func extractDiscNumber(pattern, filename string) (int, bool) { return num, true } -// fromExternalFile returns a sourceFunc that matches image files against a glob -// pattern. A numbered filename whose number equals the target disc wins over -// any unnumbered candidate; callers must pass a lowercase pattern. +// fromExternalFile matches image files against a (lowercase) glob pattern. A numbered +// filename whose number equals the target disc wins over any unnumbered candidate. func (d *discArtworkReader) fromExternalFile(ctx context.Context, pattern string) sourceFunc { isLiteral := !strings.ContainsAny(pattern, globMetaChars) return func() (io.ReadCloser, string, error) { @@ -227,7 +197,7 @@ func (d *discArtworkReader) fromExternalFile(ctx context.Context, pattern string name := strings.ToLower(path.Base(file)) match, err := filepath.Match(pattern, name) if err != nil { - log.Warn(ctx, "Error matching disc art file to pattern", "pattern", pattern, "file", file) + log.Warn(ctx, "Artwork: Error matching disc art file to pattern", "pattern", pattern, "file", file) continue } if !match { @@ -241,7 +211,7 @@ func (d *discArtworkReader) fromExternalFile(ctx context.Context, pattern string } f, err := d.lib.FS.Open(file) if err != nil { - log.Warn(ctx, "Could not open disc art file", "file", file, err) + log.Warn(ctx, "Artwork: Could not open disc art file", "file", file, err) continue } return f, file, nil @@ -257,7 +227,7 @@ func (d *discArtworkReader) fromExternalFile(ctx context.Context, pattern string for _, file := range fallbacks { f, err := d.lib.FS.Open(file) if err != nil { - log.Warn(ctx, "Could not open disc art file", "file", file, err) + log.Warn(ctx, "Artwork: Could not open disc art file", "file", file, err) continue } return f, file, nil diff --git a/core/artwork/reader_disc_test.go b/core/artwork/disc_test.go similarity index 100% rename from core/artwork/reader_disc_test.go rename to core/artwork/disc_test.go diff --git a/core/artwork/dominant/dominant.go b/core/artwork/dominant/dominant.go new file mode 100644 index 000000000..2bbda8ae9 --- /dev/null +++ b/core/artwork/dominant/dominant.go @@ -0,0 +1,127 @@ +// Package dominant extracts an image's dominant colour, for use as a flat placeholder while the +// real artwork loads. +package dominant + +import ( + "fmt" + "image" + "math" + "sort" +) + +const ( + // 4 bits per channel: coarse enough that near-identical pixels land together, fine enough that + // distinct colours stay apart. + bits = 4 + nBins = 1 << (3 * bits) + // Only the heaviest bins can win, and merging is O(n^2) over whatever survives. + maxBins = 64 + // Oklab distance below which two bins are the same colour to the eye. Merging matters because a + // gradient splits across adjacent bins and would otherwise lose to a smaller flat region. + mergeDist = 0.10 +) + +type bin struct { + r, g, b float64 + n float64 +} + +// Color returns the dominant colour as "#rrggbb", or "" when the image has no pixels. It reports +// presence, not salience: a mostly white sleeve returns white. +func Color(img image.Image) string { + var bins [nBins]bin + total := 0 + eachPixel(img, func(r, g, b uint8) { + i := int(r>>(8-bits))<<(2*bits) | int(g>>(8-bits))<>(8-bits)) + bins[i].r += float64(r) + bins[i].g += float64(g) + bins[i].b += float64(b) + bins[i].n++ + total++ + }) + if total == 0 { + return "" + } + + used := make([]bin, 0, 32) + for i := range bins { + if bins[i].n > 0 { + used = append(used, bins[i]) + } + } + sort.Slice(used, func(i, j int) bool { return used[i].n > used[j].n }) + if len(used) > maxBins { + used = used[:maxBins] + } + + merged := make([]bin, 0, len(used)) + for _, b := range used { + if i := nearest(merged, b); i >= 0 { + merged[i].r += b.r + merged[i].g += b.g + merged[i].b += b.b + merged[i].n += b.n + continue + } + merged = append(merged, b) + } + + best := merged[0] + for _, m := range merged[1:] { + if m.n > best.n { + best = m + } + } + return fmt.Sprintf("#%02x%02x%02x", + uint8(best.r/best.n+0.5), uint8(best.g/best.n+0.5), uint8(best.b/best.n+0.5)) +} + +func nearest(merged []bin, b bin) int { + bl, ba, bb := oklab(b.r/b.n, b.g/b.n, b.b/b.n) + for i, m := range merged { + ml, ma, mb := oklab(m.r/m.n, m.g/m.n, m.b/m.n) + if math.Sqrt((bl-ml)*(bl-ml)+(ba-ma)*(ba-ma)+(bb-mb)*(bb-mb)) < mergeDist { + return i + } + } + return -1 +} + +// eachPixel walks the image, taking the NRGBA fast path the artwork pipeline always hits: both hash +// encoders already read the shared thumbnail in that form. +func eachPixel(img image.Image, fn func(r, g, b uint8)) { + if p, ok := img.(*image.NRGBA); ok { + for y := range p.Rect.Dy() { + row := p.Pix[y*p.Stride : y*p.Stride+p.Rect.Dx()*4] + for x := 0; x < len(row); x += 4 { + fn(row[x], row[x+1], row[x+2]) + } + } + return + } + b := img.Bounds() + for y := b.Min.Y; y < b.Max.Y; y++ { + for x := b.Min.X; x < b.Max.X; x++ { + r, g, bl, _ := img.At(x, y).RGBA() + fn(uint8(r>>8), uint8(g>>8), uint8(bl>>8)) + } + } +} + +func srgbToLinear(v float64) float64 { + v /= 255 + if v <= 0.04045 { + return v / 12.92 + } + return math.Pow((v+0.055)/1.055, 2.4) +} + +func oklab(r, g, b float64) (float64, float64, float64) { + lr, lg, lb := srgbToLinear(r), srgbToLinear(g), srgbToLinear(b) + l := math.Cbrt(0.4122214708*lr + 0.5363325363*lg + 0.0514459929*lb) + m := math.Cbrt(0.2119034982*lr + 0.6806995451*lg + 0.1073969566*lb) + s := math.Cbrt(0.0883024619*lr + 0.2817188376*lg + 0.6299787005*lb) + return 0.2104542553*l + 0.7936177850*m - 0.0040720468*s, + 1.9779984951*l - 2.4285922050*m + 0.4505937099*s, + 0.0259040371*l + 0.7827717662*m - 0.8086757660*s +} diff --git a/core/artwork/dominant/dominant_suite_test.go b/core/artwork/dominant/dominant_suite_test.go new file mode 100644 index 000000000..56dbd6209 --- /dev/null +++ b/core/artwork/dominant/dominant_suite_test.go @@ -0,0 +1,17 @@ +package dominant_test + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestDominant(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Dominant Suite") +} diff --git a/core/artwork/dominant/dominant_test.go b/core/artwork/dominant/dominant_test.go new file mode 100644 index 000000000..da7fed09c --- /dev/null +++ b/core/artwork/dominant/dominant_test.go @@ -0,0 +1,91 @@ +package dominant_test + +import ( + "image" + "image/color" + + "github.com/navidrome/navidrome/core/artwork/dominant" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// fill paints rect with c onto img. +func fill(img *image.NRGBA, r image.Rectangle, c color.NRGBA) { + for y := r.Min.Y; y < r.Max.Y; y++ { + for x := r.Min.X; x < r.Max.X; x++ { + img.SetNRGBA(x, y, c) + } + } +} + +func newImg(w, h int, c color.NRGBA) *image.NRGBA { + img := image.NewNRGBA(image.Rect(0, 0, w, h)) + fill(img, img.Bounds(), c) + return img +} + +var _ = Describe("Color", func() { + It("returns a solid image's own colour", func() { + Expect(dominant.Color(newImg(20, 20, color.NRGBA{0x33, 0x66, 0x99, 255}))).To(Equal("#336699")) + }) + + It("returns empty for an image with no pixels", func() { + Expect(dominant.Color(image.NewNRGBA(image.Rect(0, 0, 0, 0)))).To(Equal("")) + }) + + // Presence, not salience: this is a placeholder, so the large field wins even though the small + // patch is the more interesting colour. + It("picks the largest area, not the most vivid one", func() { + img := newImg(20, 20, color.NRGBA{0xfa, 0xfa, 0xfa, 255}) + fill(img, image.Rect(0, 0, 4, 4), color.NRGBA{0xff, 0x00, 0x00, 255}) + Expect(dominant.Color(img)).To(Equal("#fafafa")) + }) + + It("reports a near-black cover as near-black", func() { + img := newImg(20, 20, color.NRGBA{0x05, 0x05, 0x05, 255}) + fill(img, image.Rect(0, 0, 5, 5), color.NRGBA{0x00, 0xff, 0x00, 255}) + Expect(dominant.Color(img)).To(Equal("#050505")) + }) + + // A gradient splits across many quantisation bins. Without merging, each slice is smaller than + // the flat block and the block would win despite covering far less of the image. + It("merges a gradient's bins so it beats a smaller flat block", func() { + img := image.NewNRGBA(image.Rect(0, 0, 40, 40)) + for y := range 40 { + for x := range 40 { + // 30 columns of blue gradient == 75% of the image + if x < 30 { + img.SetNRGBA(x, y, color.NRGBA{0x10, 0x20, uint8(0xa0 + x), 255}) + } else { + img.SetNRGBA(x, y, color.NRGBA{0xff, 0xcc, 0x00, 255}) + } + } + } + got := dominant.Color(img) + Expect(got).To(HavePrefix("#1020"), "expected the blue gradient, got "+got) + }) + + It("is deterministic", func() { + img := image.NewNRGBA(image.Rect(0, 0, 30, 30)) + for y := range 30 { + for x := range 30 { + img.SetNRGBA(x, y, color.NRGBA{uint8(x * 7), uint8(y * 5), uint8(x + y), 255}) + } + } + first := dominant.Color(img) + for range 5 { + Expect(dominant.Color(img)).To(Equal(first)) + } + }) + + It("handles images that are not NRGBA", func() { + src := newImg(10, 10, color.NRGBA{0x20, 0x40, 0x60, 255}) + rgba := image.NewRGBA(src.Bounds()) + for y := range 10 { + for x := range 10 { + rgba.Set(x, y, src.At(x, y)) + } + } + Expect(dominant.Color(rgba)).To(Equal("#204060")) + }) +}) diff --git a/core/artwork/e2e/acquire_serve_test.go b/core/artwork/e2e/acquire_serve_test.go new file mode 100644 index 000000000..d6c374e39 --- /dev/null +++ b/core/artwork/e2e/acquire_serve_test.go @@ -0,0 +1,326 @@ +package e2e + +import ( + "context" + "encoding/base64" + "errors" + "io" + "os" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/utils/cache" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Covers the enqueue → drain → serve chain; per-source resolution rules live in the unit suites. +var _ = Describe("Acquisition → serve loop", func() { + var ( + ctx context.Context + ds *tests.MockDataStore + artRepo *tests.MockArtworkRepo + queueRepo *tests.MockArtworkQueueRepo + albumRepo *tests.MockAlbumRepo + artistRepo *tests.MockArtistRepo + mfRepo *tests.MockMediaFileRepo + plRepo *tests.MockPlaylistRepo + radioRepo *tests.MockedRadioRepo + folderRepo *fakeFolderRepo + libRepo *tests.MockLibraryRepo + store *artwork.ImageStore + svc artwork.Artwork + worker *artwork.Worker + coverBytes []byte + ) + + itemFound := func(kind model.Kind, id string) func() bool { + return func() bool { + ia, err := artRepo.GetItemArtwork(kind, id, model.ImageTypePrimary) + return err == nil && ia.Hash != "" + } + } + itemAbsent := func(kind model.Kind, id string) func() bool { + return func() bool { + ia, err := artRepo.GetItemArtwork(kind, id, model.ImageTypePrimary) + return err == nil && ia.Hash == "" + } + } + // Enqueues the way the serving paths do, so the drain is driven by a plain queue row. + bump := func(kind, id string) { + GinkgoHelper() + Expect(ds.ArtworkQueue(ctx).EnqueuePreservingBackoff(model.ArtworkQueueItem{ + ItemKind: kind, ItemID: id, ImageType: model.ImageTypePrimary, + Priority: model.ArtworkPriorityBump, + })).To(Succeed()) + } + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + ctx = context.Background() + repoRoot, err := os.Getwd() + Expect(err).ToNot(HaveOccurred()) + coverBytes = readFixture(coverFixture) + + conf.Server.CacheFolder = conf.NewDir(GinkgoT().TempDir()) + conf.Server.DataFolder = conf.NewDir(GinkgoT().TempDir()) + conf.Server.CoverArtPriority = "cover.jpg" + conf.Server.ArtistArtPriority = "artist.png" // keeps artist resolution offline + conf.Server.EnableMediaFileCoverArt = true + conf.Server.DevArtworkWorkerConcurrency = 1 + + folderRepo = &fakeFolderRepo{} + libRepo = &tests.MockLibraryRepo{} + libRepo.SetData(model.Libraries{{ID: 0, Path: repoRoot}}) + artRepo = tests.CreateMockArtworkRepo() + queueRepo = tests.CreateMockArtworkQueueRepo() + albumRepo = tests.CreateMockAlbumRepo() + artistRepo = tests.CreateMockArtistRepo() + mfRepo = tests.CreateMockMediaFileRepo() + plRepo = tests.CreateMockPlaylistRepo() + radioRepo = tests.CreateMockedRadioRepo() + radioRepo.Data = map[string]*model.Radio{} + ds = &tests.MockDataStore{ + MockedArtwork: artRepo, + MockedArtworkQueue: queueRepo, + MockedAlbum: albumRepo, + MockedArtist: artistRepo, + MockedMediaFile: mfRepo, + MockedPlaylist: plRepo, + MockedRadio: radioRepo, + MockedFolder: folderRepo, + MockedLibrary: libRepo, + } + ffm := tests.NewMockFFmpeg("") + store = artwork.NewImageStore(GinkgoT().TempDir()) + // size=0 requests stream originals, so this reader is never called (serving_test covers resizing). + imgCache := cache.NewFileCache("ArtworkPipelineE2E", "100MB", "images", 0, + func(context.Context, cache.Item) (io.Reader, error) { + return nil, errors.New("resize not exercised in e2e") + }) + Eventually(func() bool { return imgCache.Available(ctx) }).Should(BeTrue()) + + svc = artwork.NewArtwork(ds, imgCache, store, ffm) + worker = artwork.NewWorker(ds, store, agents.GetAgents(ds, nil), ffm, events.NoopBroker(), imgCache) + }) + + seedFolderAlbum := func(albumID string) { + folderRepo.result = []model.Folder{{Path: albumFolderPath, ImageFiles: []string{"cover.jpg"}}} + albumRepo.SetData(model.Albums{{ID: albumID, Name: "Album", FolderIDs: []string{"f1"}, LibraryID: 0}}) + } + + It("acquires album folder art and serves the exact bytes under its hash", func() { + seedFolderAlbum("al1") + bump("al", "al1") + runWorkerUntil(ctx, worker, itemFound(model.KindAlbumArtwork, "al1")) + + ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Source).To(Equal("folder")) + + img, err := svc.Get(ctx, model.MustParseArtworkID("al-al1"), 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(img.Hash).To(Equal(ia.Hash)) + Expect(img.Placeholder).To(BeFalse()) + Expect(readAll(img)).To(Equal(coverBytes)) + }) + + It("acquires an artist's uploaded image and serves it", func() { + name := writeUpload(consts.EntityArtist, "artist-e2e.png", artistPngFixture) + artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist", UploadedImage: name}}) + bump("ar", "ar1") + runWorkerUntil(ctx, worker, itemFound(model.KindArtistArtwork, "ar1")) + + ia, err := artRepo.GetItemArtwork(model.KindArtistArtwork, "ar1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Source).To(Equal("upload")) + + img, err := svc.Get(ctx, model.MustParseArtworkID("ar-ar1"), 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(img.Hash).To(Equal(ia.Hash)) + Expect(readAll(img)).To(Equal(readFixture(artistPngFixture))) + }) + + It("generates a playlist grid from its tracks' album art and serves it from the store", func() { + seedFolderAlbum("al1") + plRepo.SetData(model.Playlists{{ID: "pl1", Name: "Playlist"}}) + plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"al1"}} + bump("pl", "pl1") + runWorkerUntil(ctx, worker, itemFound(model.KindPlaylistArtwork, "pl1")) + + ia, err := artRepo.GetItemArtwork(model.KindPlaylistArtwork, "pl1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Source).To(Equal("generated")) + + img, err := svc.Get(ctx, model.MustParseArtworkID("pl-pl1"), 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(img.Hash).To(Equal(ia.Hash)) + art, err := artRepo.GetImage(ia.Hash) + Expect(err).ToNot(HaveOccurred()) + Expect(art.Mime).To(Equal("image/png")) + Expect(len(readAll(img))).To(BeNumerically(">", 0)) + }) + + It("acquires a radio station's uploaded image and serves it", func() { + name := writeUpload(consts.EntityRadio, "radio-e2e.jpg", coverFixture) + radioRepo.Data["ra1"] = &model.Radio{ID: "ra1", Name: "Station", UploadedImage: name} + bump("ra", "ra1") + runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1")) + + ia, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Source).To(Equal("upload")) + + img, err := svc.Get(ctx, model.MustParseArtworkID("ra-ra1"), 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(img.Hash).To(Equal(ia.Hash)) + Expect(readAll(img)).To(Equal(coverBytes)) + }) + + It("serves an unresolved track provisionally, then upgrades to the worker's state row", func() { + mfRepo.SetData(model.MediaFiles{{ + ID: "mf1", AlbumID: "al1", HasCoverArt: true, LibraryID: 0, Path: mp3Fixture, + }}) + + provisional, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf1"), 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(provisional.Placeholder).To(BeFalse()) + Expect(provisional.Hash).ToNot(BeEmpty()) + provisionalBytes := readAll(provisional) + Expect(len(provisionalBytes)).To(BeNumerically(">", 0)) + + _, err = artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound), "provisional serving must not write a state row") + + // The provisional read enqueued a Bump; drain it. + runWorkerUntil(ctx, worker, itemFound(model.KindMediaFileArtwork, "mf1")) + ia, err := artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Source).To(Equal("embedded")) + Expect(ia.Hash).To(Equal(provisional.Hash)) + + resolved, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf1"), 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(resolved.Hash).To(Equal(ia.Hash)) + Expect(readAll(resolved)).To(Equal(provisionalBytes)) + }) + + It("stores dimensions, mime and a real blurhash alongside the acquired bytes", func() { + seedFolderAlbum("al1") + bump("al", "al1") + runWorkerUntil(ctx, worker, itemFound(model.KindAlbumArtwork, "al1")) + + ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + art, err := artRepo.GetImage(ia.Hash) + Expect(err).ToNot(HaveOccurred()) + Expect(art.Mime).To(Equal("image/jpeg")) + Expect(art.Width).To(BeNumerically(">", 0)) + Expect(art.Height).To(BeNumerically(">", 0)) + Expect(art.SizeBytes).To(BeNumerically("==", len(coverBytes))) + // Never a synthesized value: both hashes are encoded from the real pixels. + Expect(art.BlurHash).ToNot(BeEmpty()) + Expect(art.ThumbHash).ToNot(BeEmpty()) + raw, err := base64.StdEncoding.DecodeString(art.ThumbHash) + Expect(err).ToNot(HaveOccurred()) + Expect(len(raw)).To(BeNumerically(">=", 5)) + }) + + It("acquires GIF artwork, whose decoder only core/artwork's blank import registers", func() { + writeUploadedImage(consts.EntityRadio, "station.gif", gifFixture) + radioRepo.Data["ra1"] = &model.Radio{ID: "ra1", Name: "Station", UploadedImage: "station.gif"} + bump("ra", "ra1") + runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1")) + + ia, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + art, err := artRepo.GetImage(ia.Hash) + Expect(err).ToNot(HaveOccurred()) + Expect(art.Mime).To(Equal("image/gif")) + Expect(art.Width).To(BeNumerically("==", 4)) + }) + + It("deduplicates byte-identical art across entities onto one image row", func() { + folderRepo.result = []model.Folder{{Path: albumFolderPath, ImageFiles: []string{"cover.jpg"}}} + albumRepo.SetData(model.Albums{ + {ID: "al1", Name: "Album", FolderIDs: []string{"f1"}, LibraryID: 0}, + {ID: "al2", Name: "Same Cover", FolderIDs: []string{"f1"}, LibraryID: 0}, + }) + bump("al", "al1") + bump("al", "al2") + runWorkerUntil(ctx, worker, func() bool { + return itemFound(model.KindAlbumArtwork, "al1")() && itemFound(model.KindAlbumArtwork, "al2")() + }) + + ia1, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + ia2, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al2", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia1.Hash).To(Equal(ia2.Hash), "identical bytes must share one content hash") + Expect(readAll(mustGet(svc.Get(ctx, model.MustParseArtworkID("al-al2"), 0, false)))).To(Equal(coverBytes)) + }) + + It("stops serving a file-backed image once its source file changes underneath", func() { + name := writeUpload(consts.EntityRadio, "radio-stale.jpg", coverFixture) + radioRepo.Data["ra1"] = &model.Radio{ID: "ra1", Name: "Station", UploadedImage: name} + bump("ra", "ra1") + runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1")) + + ia, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + staleHash := ia.Hash + + path := model.UploadedImagePath(consts.EntityRadio, name) + Expect(os.WriteFile(path, readFixture(artistPngFixture), 0o600)).To(Succeed()) + newer := time.Now().Add(2 * time.Second) + Expect(os.Chtimes(path, newer, newer)).To(Succeed()) + + // The mtime no longer matches the state row, so the stale bytes are not served. + _, err = svc.Get(ctx, model.MustParseArtworkID("ra-ra1"), 0, false) + Expect(err).To(MatchError(artwork.ErrUnavailable)) + + // That failed read enqueued a re-resolution. + runWorkerUntil(ctx, worker, func() bool { + cur, gerr := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary) + return gerr == nil && cur.Hash != "" && cur.Hash != staleHash + }) + img, err := svc.Get(ctx, model.MustParseArtworkID("ra-ra1"), 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(readAll(img)).To(Equal(readFixture(artistPngFixture))) + }) + + It("records an absent state for an entity with no art and reports it unavailable", func() { + albumRepo.SetData(model.Albums{{ID: "alx", Name: "Artless", LibraryID: 0}}) + bump("al", "alx") + runWorkerUntil(ctx, worker, itemAbsent(model.KindAlbumArtwork, "alx")) + + _, err := svc.Get(ctx, model.MustParseArtworkID("al-alx"), 0, false) + Expect(err).To(MatchError(artwork.ErrUnavailable)) + + img, err := svc.GetOrPlaceholder(ctx, "al-alx", 0, false) + Expect(err).ToNot(HaveOccurred()) + Expect(img.Placeholder).To(BeTrue()) + }) +}) + +func mustGet(img *artwork.Image, err error) *artwork.Image { + GinkgoHelper() + Expect(err).ToNot(HaveOccurred()) + return img +} + +// Raw bytes on purpose: encoding a GIF here would register image/gif in the test binary, masking +// the production import the spec above guards. +var gifFixture = []byte{ + 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x04, 0x00, 0x04, 0x00, 0x80, 0x00, + 0x00, 0x2e, 0x86, 0xc1, 0xf4, 0xd0, 0x3f, 0x2c, 0x00, 0x00, 0x00, 0x00, + 0x04, 0x00, 0x04, 0x00, 0x00, 0x02, 0x05, 0x44, 0x7c, 0x67, 0xb8, 0x05, + 0x00, 0x3b, +} diff --git a/core/artwork/e2e/album_test.go b/core/artwork/e2e/album_test.go index 5e61684cc..7b5e7b4a3 100644 --- a/core/artwork/e2e/album_test.go +++ b/core/artwork/e2e/album_test.go @@ -1,4 +1,4 @@ -package artworke2e_test +package e2e import ( "testing/fstest" @@ -9,14 +9,11 @@ import ( . "github.com/onsi/gomega" ) -const ( - defaultCoverPriority = "cover.*, folder.*, front.*, embedded, external" - defaultDiscPriority = "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded" -) - +// The in-memory library FS cannot satisfy the os.Open(SourcePath) used to serve folder art, so +// folder scenarios assert on the worker's state row (Source + SourcePath) instead of the bytes. var _ = Describe("Album artwork resolution", func() { BeforeEach(func() { - setupHarness() + setupResolutionHarness() }) When("an album has a single folder with cover.jpg at the album root", func() { @@ -28,12 +25,10 @@ var _ = Describe("Album artwork resolution", func() { conf.Server.CoverArtPriority = defaultCoverPriority setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/cover.jpg": imageFile("album-root"), + "Artist/Album/cover.jpg": smallPNG("album-root"), }) scan() - - al := firstAlbum() - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root"))) + expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg") }) }) @@ -55,16 +50,16 @@ var _ = Describe("Album artwork resolution", func() { setLayout(fstest.MapFS{ "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"), "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"), - "Artist/Album/cover.jpg": imageFile("album-root"), - "Artist/Album/CD1/cover.jpg": imageFile("disc1"), - "Artist/Album/CD2/cover.jpg": imageFile("disc2"), + "Artist/Album/cover.jpg": smallPNG("album-root"), + "Artist/Album/CD1/cover.jpg": smallPNG("disc1"), + "Artist/Album/CD2/cover.jpg": smallPNG("disc2"), }) scan() al := firstAlbum() Expect(al.FolderIDs).To(HaveLen(2), - "sanity check: scanner should treat the two disc subfolders as one multi-disc album") - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root"))) + "sanity check: the two disc subfolders should form one multi-disc album") + expectAlbumFolderCover(al, "Artist/Album/cover.jpg") }) }) @@ -86,14 +81,12 @@ var _ = Describe("Album artwork resolution", func() { setLayout(fstest.MapFS{ "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"), "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"), - "Artist/Album/folder.jpg": imageFile("album-root"), - "Artist/Album/CD1/folder.jpg": imageFile("disc1"), - "Artist/Album/CD2/folder.jpg": imageFile("disc2"), + "Artist/Album/folder.jpg": smallPNG("album-root"), + "Artist/Album/CD1/folder.jpg": smallPNG("disc1"), + "Artist/Album/CD2/folder.jpg": smallPNG("disc2"), }) scan() - - al := firstAlbum() - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root"))) + expectAlbumFolderCover(firstAlbum(), "Artist/Album/folder.jpg") }) }) @@ -109,12 +102,10 @@ var _ = Describe("Album artwork resolution", func() { conf.Server.CoverArtPriority = defaultCoverPriority setLayout(fstest.MapFS{ "Artist/Album/disc1/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/cover.jpg": imageFile("album-root"), + "Artist/Album/cover.jpg": smallPNG("album-root"), }) scan() - - al := firstAlbum() - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root"))) + expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg") }) }) @@ -133,14 +124,12 @@ var _ = Describe("Album artwork resolution", func() { setLayout(fstest.MapFS{ "Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"), "Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"), - "Album/cover.jpg": imageFile("album-root"), - "Album/CD1/folder.jpg": imageFile("disc1"), - "Album/CD2/folder.jpg": imageFile("disc2"), + "Album/cover.jpg": smallPNG("album-root"), + "Album/CD1/folder.jpg": smallPNG("disc1"), + "Album/CD2/folder.jpg": smallPNG("disc2"), }) scan() - - al := firstAlbum() - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root"))) + expectAlbumFolderCover(firstAlbum(), "Album/cover.jpg") }) }) @@ -153,14 +142,14 @@ var _ = Describe("Album artwork resolution", func() { conf.Server.CoverArtPriority = "embedded, cover.*, folder.*, front.*, external" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"has_picture": "true"}), - "Artist/Album/cover.jpg": imageFile("external"), + "Artist/Album/cover.jpg": smallPNG("external"), }) scan() - // Swap in real MP3 bytes so libFS.Open returns a taglib-readable stream. replaceWithRealMP3("Artist/Album/01 - Track.mp3") - al := firstAlbum() - Expect(readArtwork(al.CoverArtID())).To(Equal(embeddedArtBytes)) + ia := acquire(model.KindAlbumArtwork, firstAlbum().ID) + Expect(ia.Source).To(Equal("embedded")) + Expect(storedBytes(ia)).To(Equal(embeddedArtBytes)) }) }) @@ -176,8 +165,9 @@ var _ = Describe("Album artwork resolution", func() { scan() replaceWithRealMP3("Artist/Album/01 - Track.mp3") - al := firstAlbum() - Expect(readArtwork(al.CoverArtID())).To(Equal(embeddedArtBytes)) + ia := acquire(model.KindAlbumArtwork, firstAlbum().ID) + Expect(ia.Source).To(Equal("embedded")) + Expect(storedBytes(ia)).To(Equal(embeddedArtBytes)) }) }) @@ -190,12 +180,10 @@ var _ = Describe("Album artwork resolution", func() { conf.Server.CoverArtPriority = "cover.*, folder.*" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/Cover.JPG": imageFile("case-insensitive"), + "Artist/Album/Cover.JPG": smallPNG("case-insensitive"), }) scan() - - al := firstAlbum() - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("case-insensitive"))) + expectAlbumFolderCover(firstAlbum(), "Artist/Album/Cover.JPG") }) }) @@ -209,30 +197,25 @@ var _ = Describe("Album artwork resolution", func() { conf.Server.CoverArtPriority = "cover.*" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/cover.jpg": imageFile("primary"), - "Artist/Album/cover.1.jpg": imageFile("secondary"), + "Artist/Album/cover.jpg": smallPNG("primary"), + "Artist/Album/cover.1.jpg": smallPNG("secondary"), }) scan() - - al := firstAlbum() - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("primary"))) + expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg") }) }) When("the album has no cover and CoverArtPriority lists only file patterns", func() { // Artist/ // └── Album/ - // └── 01 - Track.mp3 (no image files — returns ErrUnavailable) - It("returns ErrUnavailable", func() { + // └── 01 - Track.mp3 (no image files — settles absent) + It("settles absent", func() { conf.Server.CoverArtPriority = "cover.*, folder.*" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), }) scan() - - al := firstAlbum() - _, err := readArtworkOrErr(model.NewArtworkID(model.KindAlbumArtwork, al.ID, &al.UpdatedAt)) - Expect(err).To(HaveOccurred()) + expectAlbumAbsent(firstAlbum()) }) }) @@ -248,12 +231,10 @@ var _ = Describe("Album artwork resolution", func() { conf.Server.CoverArtPriority = defaultCoverPriority setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/folder.jpg": imageFile("folder"), + "Artist/Album/folder.jpg": smallPNG("folder"), }) scan() - - al := firstAlbum() - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("folder"))) + expectAlbumFolderCover(firstAlbum(), "Artist/Album/folder.jpg") }) }) @@ -266,12 +247,10 @@ var _ = Describe("Album artwork resolution", func() { conf.Server.CoverArtPriority = defaultCoverPriority setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/front.jpg": imageFile("front"), + "Artist/Album/front.jpg": smallPNG("front"), }) scan() - - al := firstAlbum() - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("front"))) + expectAlbumFolderCover(firstAlbum(), "Artist/Album/front.jpg") }) }) @@ -286,14 +265,12 @@ var _ = Describe("Album artwork resolution", func() { conf.Server.CoverArtPriority = defaultCoverPriority setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/cover.jpg": imageFile("cover"), - "Artist/Album/folder.jpg": imageFile("folder"), - "Artist/Album/front.jpg": imageFile("front"), + "Artist/Album/cover.jpg": smallPNG("cover"), + "Artist/Album/folder.jpg": smallPNG("folder"), + "Artist/Album/front.jpg": smallPNG("front"), }) scan() - - al := firstAlbum() - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("cover"))) + expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg") }) }) @@ -307,13 +284,11 @@ var _ = Describe("Album artwork resolution", func() { conf.Server.CoverArtPriority = defaultCoverPriority setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/folder.jpg": imageFile("folder"), - "Artist/Album/front.jpg": imageFile("front"), + "Artist/Album/folder.jpg": smallPNG("folder"), + "Artist/Album/front.jpg": smallPNG("front"), }) scan() - - al := firstAlbum() - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("folder"))) + expectAlbumFolderCover(firstAlbum(), "Artist/Album/folder.jpg") }) }) @@ -328,14 +303,12 @@ var _ = Describe("Album artwork resolution", func() { conf.Server.CoverArtPriority = "cover.*" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/cover.2.jpg": imageFile("second"), - "Artist/Album/cover.jpg": imageFile("primary"), - "Artist/Album/cover.1.jpg": imageFile("first"), + "Artist/Album/cover.2.jpg": smallPNG("second"), + "Artist/Album/cover.jpg": smallPNG("primary"), + "Artist/Album/cover.1.jpg": smallPNG("first"), }) scan() - - al := firstAlbum() - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("primary"))) + expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg") }) }) @@ -348,12 +321,10 @@ var _ = Describe("Album artwork resolution", func() { conf.Server.CoverArtPriority = "bogus.*, cover.*" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/cover.jpg": imageFile("cover"), + "Artist/Album/cover.jpg": smallPNG("cover"), }) scan() - - al := firstAlbum() - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("cover"))) + expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg") }) }) @@ -371,21 +342,16 @@ var _ = Describe("Album artwork resolution", func() { It("does not use the artist image as album art", func() { conf.Server.CoverArtPriority = defaultCoverPriority setLayout(fstest.MapFS{ - "Artist/folder.jpg": imageFile("artist-thumbnail"), + "Artist/folder.jpg": smallPNG("artist-thumbnail"), "Artist/Album A/01 - Track.mp3": trackFile(1, "Track A", map[string]any{"album": "Album A", "albumartist": "Artist"}), "Artist/Album B/01 - Track.mp3": trackFile(1, "Track B", map[string]any{"album": "Album B", "albumartist": "Artist"}), - "Artist/Album B/cover.jpg": imageFile("album-b"), + "Artist/Album B/cover.jpg": smallPNG("album-b"), }) scan() - alA := albumByName("Album A") - _, err := readArtworkOrErr(alA.CoverArtID()) - Expect(err).To(HaveOccurred(), - "Album A has no images of its own, so it must fall through to the placeholder "+ - "instead of inheriting the artist folder's folder.jpg") - - alB := albumByName("Album B") - Expect(readArtwork(alB.CoverArtID())).To(Equal(imageBytes("album-b"))) + // Album B first: the acquire in expectAlbumAbsent would settle Album B too. + expectAlbumFolderCover(albumByName("Album B"), "Artist/Album B/cover.jpg") + expectAlbumAbsent(albumByName("Album A")) }) }) @@ -402,21 +368,58 @@ var _ = Describe("Album artwork resolution", func() { It("does not use the artist image as album art for the spread album", func() { conf.Server.CoverArtPriority = defaultCoverPriority setLayout(fstest.MapFS{ - "Artist/folder.jpg": imageFile("artist-thumbnail"), + "Artist/folder.jpg": smallPNG("artist-thumbnail"), "Artist/Album A/01 - Track.mp3": trackFile(1, "Track A1", map[string]any{"album": "Album A", "albumartist": "Artist"}), "Artist/Album A bonus/02 - Track.mp3": trackFile(2, "Track A2", map[string]any{"album": "Album A", "albumartist": "Artist"}), "Artist/Album B/01 - Track.mp3": trackFile(1, "Track B", map[string]any{"album": "Album B", "albumartist": "Artist"}), - "Artist/Album B/cover.jpg": imageFile("album-b"), + "Artist/Album B/cover.jpg": smallPNG("album-b"), }) scan() alA := albumByName("Album A") Expect(alA.FolderIDs).To(HaveLen(2), - "sanity check: scanner should treat the two sibling folders as one spread album") - _, err := readArtworkOrErr(alA.CoverArtID()) - Expect(err).To(HaveOccurred(), - "the spread album has no images of its own, so it must fall through to the "+ - "placeholder instead of inheriting the artist folder's folder.jpg") + "sanity check: the two sibling folders should form one spread album") + expectAlbumAbsent(alA) + }) + }) + + // albumRootParent refuses the library root as an album root (parent.ParentID == ""). + When("a multi-disc album sits directly at the library root with a cover.jpg beside it", func() { + // (library root) + // ├── cover.jpg ← must NOT be adopted + // ├── CD1/ + // │ └── 01 - Track.mp3 + // └── CD2/ + // └── 01 - Track.mp3 + It("does not adopt the library-root image as album art", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "cover.jpg": smallPNG("library-root"), + "CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"album": "Rootless", "disc": "1"}), + "CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"album": "Rootless", "disc": "2"}), + }) + scan() + expectAlbumAbsent(firstAlbum()) + }) + }) + + // The shallower artist-folder cover.jpg would win the basename tie, but albumRootParent skips + // the parent folder for a single-folder album that has images of its own. + When("a single-folder album has its own cover.jpg and the artist folder has one too", func() { + // Artist/ + // ├── cover.jpg ← shallower, but must NOT win + // └── Album/ + // ├── 01 - Track.mp3 + // └── cover.jpg ← should win + It("prefers the album's own cover over the shallower artist-folder cover", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Artist/cover.jpg": smallPNG("artist-image"), + "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), + "Artist/Album/cover.jpg": smallPNG("album-own"), + }) + scan() + expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg") }) }) @@ -430,13 +433,13 @@ var _ = Describe("Album artwork resolution", func() { // ├── Album A bonus/ // │ └── 02 - Track.mp3 (album: "Album A") // └── Album B/ - // └── 01 - Track.mp3 + // └── 01 - Track.mp3 (other-album audio: rejects the artist folder as a root) It("prefers the album's own art over the artist image", func() { conf.Server.CoverArtPriority = defaultCoverPriority setLayout(fstest.MapFS{ - "Artist/cover.jpg": imageFile("artist-image"), + "Artist/cover.jpg": smallPNG("artist-image"), "Artist/Album A/01 - Track.mp3": trackFile(1, "Track A1", map[string]any{"album": "Album A", "albumartist": "Artist"}), - "Artist/Album A/front.jpg": imageFile("album-a-front"), + "Artist/Album A/front.jpg": smallPNG("album-a-front"), "Artist/Album A bonus/02 - Track.mp3": trackFile(2, "Track A2", map[string]any{"album": "Album A", "albumartist": "Artist"}), "Artist/Album B/01 - Track.mp3": trackFile(1, "Track B", map[string]any{"album": "Album B", "albumartist": "Artist"}), }) @@ -444,8 +447,8 @@ var _ = Describe("Album artwork resolution", func() { alA := albumByName("Album A") Expect(alA.FolderIDs).To(HaveLen(2), - "sanity check: scanner should treat the two sibling folders as one spread album") - Expect(readArtwork(alA.CoverArtID())).To(Equal(imageBytes("album-a-front"))) + "sanity check: the two sibling folders should form one spread album") + expectAlbumFolderCover(alA, "Artist/Album A/front.jpg") }) }) @@ -458,12 +461,10 @@ var _ = Describe("Album artwork resolution", func() { conf.Server.CoverArtPriority = "embedded, cover.*" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/cover.jpg": imageFile("cover"), + "Artist/Album/cover.jpg": smallPNG("cover"), }) scan() - - al := firstAlbum() - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("cover"))) + expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg") }) }) }) diff --git a/core/artwork/e2e/artist_test.go b/core/artwork/e2e/artist_test.go index 2079d37ce..5a9396cc3 100644 --- a/core/artwork/e2e/artist_test.go +++ b/core/artwork/e2e/artist_test.go @@ -1,4 +1,4 @@ -package artworke2e_test +package e2e import ( "os" @@ -8,6 +8,7 @@ import ( "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -16,9 +17,11 @@ import ( // Doc reference: // https://www.navidrome.org/docs/usage/library/artwork/#artists // Default ArtistArtPriority is "artist.*, album/artist.*, external". +// Library-folder images are file-backed (asserted on the worker state row); uploaded and +// image-folder images are real files on disk (asserted byte-for-byte). var _ = Describe("Artist artwork resolution", func() { BeforeEach(func() { - setupHarness() + setupResolutionHarness() }) When("the artist folder contains an artist.jpg", func() { @@ -30,13 +33,10 @@ var _ = Describe("Artist artwork resolution", func() { conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), - "Artist/artist.jpg": imageFile("artist-folder"), + "Artist/artist.jpg": smallPNG("artist-folder"), }) scan() - - ar := soleArtist() - artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) - Expect(readArtwork(artID)).To(Equal(imageBytes("artist-folder"))) + expectArtistFolder(soleArtist(), "Artist/artist.jpg") }) }) @@ -49,13 +49,10 @@ var _ = Describe("Artist artwork resolution", func() { conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), - "Artist/Album/artist.jpg": imageFile("album-artist"), + "Artist/Album/artist.jpg": smallPNG("album-artist"), }) scan() - - ar := soleArtist() - artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) - Expect(readArtwork(artID)).To(Equal(imageBytes("album-artist"))) + expectArtistFolder(soleArtist(), "Artist/Album/artist.jpg") }) }) @@ -69,14 +66,11 @@ var _ = Describe("Artist artwork resolution", func() { conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), - "Artist/artist.jpg": imageFile("artist-folder"), - "Artist/Album/artist.jpg": imageFile("album-artist"), + "Artist/artist.jpg": smallPNG("artist-folder"), + "Artist/Album/artist.jpg": smallPNG("album-artist"), }) scan() - - ar := soleArtist() - artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) - Expect(readArtwork(artID)).To(Equal(imageBytes("artist-folder"))) + expectArtistFolder(soleArtist(), "Artist/artist.jpg") }) }) @@ -89,13 +83,10 @@ var _ = Describe("Artist artwork resolution", func() { conf.Server.ArtistArtPriority = "artist.*" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), - "Artist/artist.jpg": imageFile("artist-folder"), + "Artist/artist.jpg": smallPNG("artist-folder"), }) scan() - - ar := soleArtist() - artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) - Expect(readArtwork(artID)).To(Equal(imageBytes("artist-folder"))) + expectArtistFolder(soleArtist(), "Artist/artist.jpg") }) }) @@ -111,14 +102,11 @@ var _ = Describe("Artist artwork resolution", func() { setLayout(fstest.MapFS{ "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track 1", map[string]any{"albumartist": "Artist", "album": "Album"}), "Artist/Album/CD2/02 - Track.mp3": trackFile(2, "Track 2", map[string]any{"albumartist": "Artist", "album": "Album"}), - "Artist/artist.jpg": imageFile("artist-folder"), - "Artist/Album/artist.jpg": imageFile("album-artist"), + "Artist/artist.jpg": smallPNG("artist-folder"), + "Artist/Album/artist.jpg": smallPNG("album-artist"), }) scan() - - ar := soleArtist() - artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) - Expect(readArtwork(artID)).To(Equal(imageBytes("artist-folder"))) + expectArtistFolder(soleArtist(), "Artist/artist.jpg") }) }) @@ -136,14 +124,11 @@ var _ = Describe("Artist artwork resolution", func() { "Artist/Album1/CD1/01 - Track.mp3": trackFile(1, "Track 1", map[string]any{"albumartist": "Artist", "album": "Album1"}), "Artist/Album1/CD2/02 - Track.mp3": trackFile(2, "Track 2", map[string]any{"albumartist": "Artist", "album": "Album1"}), "Artist/Album2/03 - Track.mp3": trackFile(3, "Track 3", map[string]any{"albumartist": "Artist", "album": "Album2"}), - "Artist/artist.jpg": imageFile("artist-folder"), - "Artist/Album1/artist.jpg": imageFile("album-artist"), + "Artist/artist.jpg": smallPNG("artist-folder"), + "Artist/Album1/artist.jpg": smallPNG("album-artist"), }) scan() - - ar := soleArtist() - artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) - Expect(readArtwork(artID)).To(Equal(imageBytes("artist-folder"))) + expectArtistFolder(soleArtist(), "Artist/artist.jpg") }) }) @@ -164,14 +149,11 @@ var _ = Describe("Artist artwork resolution", func() { "Artist/Album1/CD2/02 - Track.mp3": trackFile(2, "Track 2", map[string]any{"albumartist": "Artist", "album": "Album1"}), "Artist/Album2/CD1/03 - Track.mp3": trackFile(3, "Track 3", map[string]any{"albumartist": "Artist", "album": "Album2"}), "Artist/Album2/CD2/04 - Track.mp3": trackFile(4, "Track 4", map[string]any{"albumartist": "Artist", "album": "Album2"}), - "Artist/artist.jpg": imageFile("artist-folder"), - "Artist/Album1/artist.jpg": imageFile("album-artist"), + "Artist/artist.jpg": smallPNG("artist-folder"), + "Artist/Album1/artist.jpg": smallPNG("album-artist"), }) scan() - - ar := soleArtist() - artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) - Expect(readArtwork(artID)).To(Equal(imageBytes("artist-folder"))) + expectArtistFolder(soleArtist(), "Artist/artist.jpg") }) }) @@ -189,18 +171,19 @@ var _ = Describe("Artist artwork resolution", func() { conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), - "Artist/artist.jpg": imageFile("artist-folder"), + "Artist/artist.jpg": smallPNG("artist-folder"), }) scan() ar := soleArtist() uploaded := ar.ID + "_upload.jpg" - writeUploadedImage(consts.EntityArtist, uploaded, imageBytes("artist-uploaded")) + writeUploadedImage(consts.EntityArtist, uploaded, pngBytes("artist-uploaded")) ar.UploadedImage = uploaded - Expect(ds.Artist(ctx).Put(&ar)).To(Succeed()) + Expect(rds.Artist(rctx).Put(&ar)).To(Succeed()) - artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) - Expect(readArtwork(artID)).To(Equal(imageBytes("artist-uploaded"))) + ia := acquire(model.KindArtistArtwork, ar.ID) + Expect(ia.Source).To(Equal("upload")) + Expect(serveBytes(model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil))).To(Equal(pngBytes("artist-uploaded"))) }) }) @@ -213,13 +196,36 @@ var _ = Describe("Artist artwork resolution", func() { conf.Server.ArtistArtPriority = "album/artist.*, external" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), - "Artist/Album/artist.jpg": imageFile("album-artist"), + "Artist/Album/artist.jpg": smallPNG("album-artist"), }) scan() + expectArtistFolder(soleArtist(), "Artist/Album/artist.jpg") + }) + }) + + // resolveArtist only samples albums where this artist is the SOLE album artist, so a + // collaboration or compilation never donates its images as the artist's own. + When("the artist's only album is credited to two album artists", func() { + // Artist/ + // └── Collab Album/ (album artists: "Artist" + a collaborator) + // ├── 01 - Track.mp3 + // └── artist.jpg ← must NOT become the artist image + It("ignores the album's images and settles absent", func() { + conf.Server.ArtistArtPriority = "album/artist.*" + // " / " is a default artists split separator, so this single tag yields two album artists. + setLayout(fstest.MapFS{ + "Artist/Collab Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist / Collaborator"}), + "Artist/Collab Album/artist.jpg": smallPNG("collab-artist"), + }) + scan() + Expect(firstAlbum().Participants[model.RoleAlbumArtist]).To(HaveLen(2), + "sanity check: the album must be credited to two album artists") ar := soleArtist() - artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) - Expect(readArtwork(artID)).To(Equal(imageBytes("album-artist"))) + ia := acquire(model.KindArtistArtwork, ar.ID) + Expect(ia.Hash).To(BeEmpty()) + Expect(serveErr(model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil))). + To(MatchError(artwork.ErrUnavailable)) }) }) @@ -232,7 +238,7 @@ var _ = Describe("Artist artwork resolution", func() { // └── 01 - Track.mp3 (no artist.* present in library) It("returns the image from the configured artist image folder", func() { imgFolder := GinkgoT().TempDir() - Expect(os.WriteFile(filepath.Join(imgFolder, "Artist.jpg"), imageBytes("image-folder"), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(imgFolder, "Artist.jpg"), pngBytes("image-folder"), 0o600)).To(Succeed()) conf.Server.ArtistImageFolder = imgFolder conf.Server.ArtistArtPriority = "image-folder, artist.*, album/artist.*" @@ -242,15 +248,16 @@ var _ = Describe("Artist artwork resolution", func() { scan() ar := soleArtist() - artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) - Expect(readArtwork(artID)).To(Equal(imageBytes("image-folder"))) + ia := acquire(model.KindArtistArtwork, ar.ID) + Expect(ia.Source).To(Equal("folder")) + Expect(serveBytes(model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil))).To(Equal(pngBytes("image-folder"))) }) }) }) func soleArtist() model.Artist { GinkgoHelper() - artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{ + artists, err := rds.Artist(rctx).GetAll(model.QueryOptions{ Filters: squirrel.Eq{"artist.name": "Artist"}, }) Expect(err).ToNot(HaveOccurred()) diff --git a/core/artwork/e2e/disc_test.go b/core/artwork/e2e/disc_test.go index 667079458..5fdd5f27b 100644 --- a/core/artwork/e2e/disc_test.go +++ b/core/artwork/e2e/disc_test.go @@ -1,18 +1,20 @@ -package artworke2e_test +package e2e import ( "fmt" "testing/fstest" "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/core/artwork" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) +// Disc art is a serve-time read through the library FS (no worker state row), so per-disc images +// are asserted byte-for-byte, while album-root covers are asserted on the state row. var _ = Describe("Disc artwork resolution", func() { BeforeEach(func() { - setupHarness() + setupResolutionHarness() }) When("the album is single-disc with a disc1.jpg in the only folder", func() { @@ -24,32 +26,25 @@ var _ = Describe("Disc artwork resolution", func() { conf.Server.DiscArtPriority = "disc*.*, cd*.*, cover.*, folder.*, front.*, embedded" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/disc1.jpg": imageFile("disc1-image"), + "Artist/Album/disc1.jpg": smallPNG("disc1-image"), }) scan() - - al := firstAlbum() - discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) - Expect(readArtwork(discID)).To(Equal(imageBytes("disc1-image"))) + expectDiscImage(firstAlbum(), 1, "disc1-image") }) }) When("the album has no per-disc image and no album cover", func() { // Artist/ // └── Album/ - // └── 01 - Track.mp3 (no disc or album art — returns ErrUnavailable) - It("returns ErrUnavailable for the disc lookup", func() { + // └── 01 - Track.mp3 (no disc or album art — nothing to serve) + It("reports the disc lookup as unavailable", func() { conf.Server.DiscArtPriority = "disc*.*, cd*.*" conf.Server.CoverArtPriority = "cover.*, folder.*" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), }) scan() - - al := firstAlbum() - discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) - _, err := readArtworkOrErr(discID) - Expect(err).To(HaveOccurred()) + Expect(serveErr(discArtID(firstAlbum(), 1))).To(MatchError(artwork.ErrUnavailable)) }) }) @@ -63,13 +58,10 @@ var _ = Describe("Disc artwork resolution", func() { conf.Server.CoverArtPriority = defaultCoverPriority setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/cover.jpg": imageFile("album-cover"), + "Artist/Album/cover.jpg": smallPNG("album-cover"), }) scan() - - al := firstAlbum() - discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) - Expect(readArtwork(discID)).To(Equal(imageBytes("album-cover"))) + expectDiscImage(firstAlbum(), 1, "album-cover") }) }) @@ -83,14 +75,11 @@ var _ = Describe("Disc artwork resolution", func() { conf.Server.DiscArtPriority = "disc*.*" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/disc1.jpg": imageFile("disc-one"), - "Artist/Album/disc10.jpg": imageFile("disc-ten"), + "Artist/Album/disc1.jpg": smallPNG("disc-one"), + "Artist/Album/disc10.jpg": smallPNG("disc-ten"), }) scan() - - al := firstAlbum() - discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) - Expect(readArtwork(discID)).To(Equal(imageBytes("disc-one"))) + expectDiscImage(firstAlbum(), 1, "disc-one") }) }) @@ -108,14 +97,11 @@ var _ = Describe("Disc artwork resolution", func() { setLayout(fstest.MapFS{ "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}), "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}), - "Artist/Album/CD1/disc1.jpg": imageFile("disc-1"), - "Artist/Album/CD2/disc2.jpg": imageFile("disc-2"), + "Artist/Album/CD1/disc1.jpg": smallPNG("disc-1"), + "Artist/Album/CD2/disc2.jpg": smallPNG("disc-2"), }) scan() - - al := firstAlbum() - discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 2), &al.UpdatedAt) - Expect(readArtwork(discID)).To(Equal(imageBytes("disc-2"))) + expectDiscImage(firstAlbum(), 2, "disc-2") }) }) @@ -136,14 +122,11 @@ var _ = Describe("Disc artwork resolution", func() { setLayout(fstest.MapFS{ "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}), "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}), - "Artist/Album/CD1/disc1.jpg": imageFile("disc-1"), - "Artist/Album/CD2/cd2.png": imageFile("cd-2"), + "Artist/Album/CD1/disc1.jpg": smallPNG("disc-1"), + "Artist/Album/CD2/cd2.png": smallPNG("cd-2"), }) scan() - - al := firstAlbum() - discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 2), &al.UpdatedAt) - Expect(readArtwork(discID)).To(Equal(imageBytes("cd-2"))) + expectDiscImage(firstAlbum(), 2, "cd-2") }) }) @@ -161,14 +144,11 @@ var _ = Describe("Disc artwork resolution", func() { setLayout(fstest.MapFS{ "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}), "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}), - "Artist/Album/CD1/cover.jpg": imageFile("disc1-cover"), - "Artist/Album/CD2/cover.jpg": imageFile("disc2-cover"), + "Artist/Album/CD1/cover.jpg": smallPNG("disc1-cover"), + "Artist/Album/CD2/cover.jpg": smallPNG("disc2-cover"), }) scan() - - al := firstAlbum() - discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) - Expect(readArtwork(discID)).To(Equal(imageBytes("disc1-cover"))) + expectDiscImage(firstAlbum(), 1, "disc1-cover") }) }) @@ -188,17 +168,15 @@ var _ = Describe("Disc artwork resolution", func() { setLayout(fstest.MapFS{ "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}), "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}), - "Artist/Album/CD1/disc1.jpg": imageFile("disc-1"), - "Artist/Album/CD2/cd2.png": imageFile("cd-2"), - "Artist/Album/cover.jpg": imageFile("album-cover"), + "Artist/Album/CD1/disc1.jpg": smallPNG("disc-1"), + "Artist/Album/CD2/cd2.png": smallPNG("cd-2"), + "Artist/Album/cover.jpg": smallPNG("album-cover"), }) scan() al := firstAlbum() for _, n := range []int{1, 2} { - discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, n), &al.UpdatedAt) - Expect(readArtwork(discID)).To(Equal(imageBytes("album-cover")), - "disc %d should use the album cover when DiscArtPriority is empty", n) + expectDiscImage(al, n, "album-cover") } }) }) @@ -223,17 +201,15 @@ var _ = Describe("Disc artwork resolution", func() { "Artist/Album/disc1/02 - Track.mp3": trackFile(2, "T2", map[string]any{"disc": "1"}), "Artist/Album/disc2/01 - Track.mp3": trackFile(1, "T3", map[string]any{"disc": "2"}), "Artist/Album/disc2/02 - Track.mp3": trackFile(2, "T4", map[string]any{"disc": "2"}), - "Artist/Album/disc1/disc1.jpg": imageFile("disc-1"), - "Artist/Album/disc2/cd2.png": imageFile("cd-2"), - "Artist/Album/cover.jpg": imageFile("album-root"), + "Artist/Album/disc1/disc1.jpg": smallPNG("disc-1"), + "Artist/Album/disc2/cd2.png": smallPNG("cd-2"), + "Artist/Album/cover.jpg": smallPNG("album-root"), }) scan() al := firstAlbum() - disc1ID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) - disc2ID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 2), &al.UpdatedAt) - Expect(readArtwork(disc1ID)).To(Equal(imageBytes("disc-1"))) - Expect(readArtwork(disc2ID)).To(Equal(imageBytes("cd-2"))) + expectDiscImage(al, 1, "disc-1") + expectDiscImage(al, 2, "cd-2") }) }) @@ -246,13 +222,10 @@ var _ = Describe("Disc artwork resolution", func() { conf.Server.DiscArtPriority = "discsubtitle" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1", "discsubtitle": "Bonus Tracks"}), - "Artist/Album/Bonus Tracks.jpg": imageFile("bonus-tracks"), + "Artist/Album/Bonus Tracks.jpg": smallPNG("bonus-tracks"), }) scan() - - al := firstAlbum() - discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) - Expect(readArtwork(discID)).To(Equal(imageBytes("bonus-tracks"))) + expectDiscImage(firstAlbum(), 1, "bonus-tracks") }) }) @@ -286,26 +259,22 @@ var _ = Describe("Disc artwork resolution", func() { "Disc 12 (History of the Grateful Dead, Volume One (Bear's Choice))", } layout := fstest.MapFS{ - "Pop; Rock/Grateful Dead/(2001) The Golden Road/cover.jpg": imageFile("album-root-cover"), + "Pop; Rock/Grateful Dead/(2001) The Golden Road/cover.jpg": smallPNG("album-root-cover"), } for i, name := range discNames { discNum := i + 1 prefix := fmt.Sprintf("Pop; Rock/Grateful Dead/(2001) The Golden Road/%s/", name) layout[prefix+"01 - Track.mp3"] = trackFile(1, fmt.Sprintf("T%d", discNum), map[string]any{"disc": fmt.Sprintf("%d", discNum)}) - layout[prefix+"folder.jpg"] = imageFile(fmt.Sprintf("disc-%02d-folder", discNum)) + layout[prefix+"folder.jpg"] = smallPNG(fmt.Sprintf("disc-%02d-folder", discNum)) } setLayout(layout) scan() al := firstAlbum() - - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root-cover"))) - + expectAlbumFolderCover(al, "(2001) The Golden Road/cover.jpg") for i := range discNames { discNum := i + 1 - discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, discNum), &al.UpdatedAt) - Expect(readArtwork(discID)).To(Equal(imageBytes(fmt.Sprintf("disc-%02d-folder", discNum))), - "disc %d should use its own folder.jpg", discNum) + expectDiscImage(al, discNum, fmt.Sprintf("disc-%02d-folder", discNum)) } }) }) @@ -328,24 +297,20 @@ var _ = Describe("Disc artwork resolution", func() { conf.Server.DiscArtPriority = defaultDiscPriority conf.Server.CoverArtPriority = defaultCoverPriority layout := fstest.MapFS{ - "Album/cover.jpg": imageFile("album-root-cover"), + "Album/cover.jpg": smallPNG("album-root-cover"), } for i := 1; i <= 3; i++ { prefix := fmt.Sprintf("Album/Disc %02d/", i) layout[prefix+"01 - Track.mp3"] = trackFile(1, fmt.Sprintf("T%d", i), map[string]any{"disc": fmt.Sprintf("%d", i)}) - layout[prefix+"folder.jpg"] = imageFile(fmt.Sprintf("disc-%02d-folder", i)) + layout[prefix+"folder.jpg"] = smallPNG(fmt.Sprintf("disc-%02d-folder", i)) } setLayout(layout) scan() al := firstAlbum() - - Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root-cover"))) - + expectAlbumFolderCover(al, "Album/cover.jpg") for i := 1; i <= 3; i++ { - discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, i), &al.UpdatedAt) - Expect(readArtwork(discID)).To(Equal(imageBytes(fmt.Sprintf("disc-%02d-folder", i))), - "disc %d should use its own folder.jpg", i) + expectDiscImage(al, i, fmt.Sprintf("disc-%02d-folder", i)) } }) }) @@ -359,13 +324,10 @@ var _ = Describe("Disc artwork resolution", func() { conf.Server.DiscArtPriority = "discsubtitle, cover.*" setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1", "discsubtitle": "Bonus Tracks"}), - "Artist/Album/cover.jpg": imageFile("cover"), + "Artist/Album/cover.jpg": smallPNG("cover"), }) scan() - - al := firstAlbum() - discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) - Expect(readArtwork(discID)).To(Equal(imageBytes("cover"))) + expectDiscImage(firstAlbum(), 1, "cover") }) }) }) diff --git a/core/artwork/e2e/e2e_suite_test.go b/core/artwork/e2e/e2e_suite_test.go new file mode 100644 index 000000000..8881da3a6 --- /dev/null +++ b/core/artwork/e2e/e2e_suite_test.go @@ -0,0 +1,83 @@ +// Package e2e exercises the artwork pipeline end to end: the real Worker drains the queue and the +// real Service serves the result, over a real ImageStore and real library files. +package e2e + +import ( + "context" + "io" + "os" + "path/filepath" + "testing" + "time" + + _ "github.com/navidrome/navidrome/adapters/gotaglib" // registers the "taglib" local-storage extractor + "github.com/navidrome/navidrome/core/artwork" + _ "github.com/navidrome/navidrome/core/storage/local" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestArtworkE2E(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Artwork Pipeline E2E Suite") +} + +// Fixtures relative to the project root (tests.Init chdirs there). +const ( + coverFixture = "tests/fixtures/artist/an-album/cover.jpg" + mp3Fixture = "tests/fixtures/artist/an-album/test.mp3" + artistPngFixture = "tests/fixtures/artist/an-album/artist.png" + albumFolderPath = "tests/fixtures/artist/an-album" +) + +func readFixture(rel string) []byte { + GinkgoHelper() + data, err := os.ReadFile(rel) + Expect(err).ToNot(HaveOccurred(), "reading fixture %q", rel) + return data +} + +func readAll(img *artwork.Image) []byte { + GinkgoHelper() + Expect(img).ToNot(BeNil()) + defer img.Close() + data, err := io.ReadAll(img) + Expect(err).ToNot(HaveOccurred()) + return data +} + +func runWorkerUntil(ctx context.Context, worker *artwork.Worker, until func() bool) { + GinkgoHelper() + runCtx, cancel := context.WithCancel(ctx) + done := make(chan error, 1) + go func() { done <- worker.Run(runCtx) }() + Eventually(until, 5*time.Second, 10*time.Millisecond).Should(BeTrue()) + cancel() + Eventually(done, 2*time.Second).Should(Receive(BeNil())) +} + +type fakeFolderRepo struct { + model.FolderRepository + result []model.Folder +} + +func (f *fakeFolderRepo) GetAll(...model.QueryOptions) ([]model.Folder, error) { return f.result, nil } + +func (f *fakeFolderRepo) HasAudioOutsideFolders(model.Folder, []string) (bool, error) { + return false, nil +} + +func (f *fakeFolderRepo) Get(string) (*model.Folder, error) { return nil, model.ErrNotFound } + +func writeUpload(entityType, name, srcFixture string) string { + GinkgoHelper() + dst := model.UploadedImagePath(entityType, name) + Expect(os.MkdirAll(filepath.Dir(dst), 0o755)).To(Succeed()) + Expect(os.WriteFile(dst, readFixture(srcFixture), 0o600)).To(Succeed()) + return name +} diff --git a/core/artwork/e2e/helpers_test.go b/core/artwork/e2e/helpers_test.go deleted file mode 100644 index ddc926cec..000000000 --- a/core/artwork/e2e/helpers_test.go +++ /dev/null @@ -1,184 +0,0 @@ -package artworke2e_test - -import ( - "bytes" - "context" - _ "embed" - "errors" - "hash/fnv" - "image" - "image/color" - "image/png" - "io" - "maps" - "net/url" - "os" - "path/filepath" - "testing/fstest" - - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/external" - "github.com/navidrome/navidrome/core/storage/storagetest" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/resources" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "go.senan.xyz/taglib" -) - -// realMP3WithEmbeddedArt is the bytes of the canonical test fixture that -// contains a valid MP3 stream with an embedded picture. Used in the -// embedded-art e2e scenarios where FakeFS's JSON-encoded tag data isn't -// readable by taglib. Swap this into fakeFS.MapFS *after* scanning so the -// scanner still populates EmbedArtPath via the JSON-tagged track, and the -// artwork reader gets real bytes when it calls libFS.Open. -// -//go:embed testdata/embedded_art.mp3 -var realMP3WithEmbeddedArt []byte - -// embeddedArtBytes is the exact image payload that the artwork reader will -// extract from realMP3WithEmbeddedArt. Computed once via taglib so tests can -// assert byte-for-byte equality — if this ever differs it means the reader -// pulled from a different source. -var embeddedArtBytes = extractEmbeddedArt(realMP3WithEmbeddedArt) - -func extractEmbeddedArt(mp3 []byte) []byte { - tf, err := taglib.OpenStream(bytes.NewReader(mp3)) - if err != nil { - panic("embedded-art fixture: taglib.OpenStream failed: " + err.Error()) - } - defer tf.Close() - images := tf.Properties().Images - if len(images) == 0 { - panic("embedded-art fixture has no embedded images") - } - data, err := tf.Image(0) - if err != nil || len(data) == 0 { - panic("embedded-art fixture: could not read image 0") - } - return data -} - -// replaceWithRealMP3 swaps the FakeFS entry at the given library-relative -// path so libFS.Open returns an MP3 stream taglib can parse. -func replaceWithRealMP3(relPath string) { - GinkgoHelper() - fakeFS.MapFS[relPath] = &fstest.MapFile{Data: realMP3WithEmbeddedArt} -} - -// placeholderBytes returns the bundled album-placeholder image bytes — the -// same stream the artwork reader emits when every source falls through. -func placeholderBytes() []byte { - GinkgoHelper() - r, err := resources.FS().Open(consts.PlaceholderAlbumArt) - Expect(err).ToNot(HaveOccurred()) - defer r.Close() - data, err := io.ReadAll(r) - Expect(err).ToNot(HaveOccurred()) - return data -} - -// writeUploadedImage drops `filename` into /artwork// with -// the given bytes, matching the on-disk layout expected by -// model.UploadedImagePath. -func writeUploadedImage(entity, filename string, data []byte) { - GinkgoHelper() - dir := filepath.Dir(model.UploadedImagePath(entity, filename)) - Expect(os.MkdirAll(dir, 0755)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(dir, filename), data, 0600)).To(Succeed()) -} - -func newNoopFFmpeg() *tests.MockFFmpeg { - ff := tests.NewMockFFmpeg("") - ff.Error = errors.New("noop") - return ff -} - -// trackFile builds a FakeFS MP3 entry with optional tag overrides. -func trackFile(num int, title string, extra ...map[string]any) *fstest.MapFile { - tags := storagetest.Track(num, title) - for _, e := range extra { - maps.Copy(tags, e) - } - return storagetest.MP3(tags) -} - -// imageFile builds a label-keyed image entry. The bytes are deterministic -// per-label so tests can assert which file won. -func imageFile(label string) *fstest.MapFile { - return &fstest.MapFile{Data: []byte("image:" + label)} -} - -// realPNG builds a minimal 2x2 PNG with a color derived from label. Needed by -// tests that feed the bytes into image.Decode (e.g. playlist tiled covers). -func realPNG(label string) *fstest.MapFile { - img := image.NewRGBA(image.Rect(0, 0, 2, 2)) - // Derive a deterministic color per label. - h := fnv.New32a() - _, _ = h.Write([]byte(label)) - sum := h.Sum32() - c := color.RGBA{R: byte(sum), G: byte(sum >> 8), B: byte(sum >> 16), A: 255} - for y := range 2 { - for x := range 2 { - img.Set(x, y, c) - } - } - var buf bytes.Buffer - Expect(png.Encode(&buf, img)).To(Succeed()) - return &fstest.MapFile{Data: buf.Bytes()} -} - -// imageBytes returns the bytes that imageFile(label) writes. -func imageBytes(label string) []byte { return imageFile(label).Data } - -// setLayout populates fakeFS with the given map. Call after setupHarness. -// All paths must be forward-slash and relative (no leading "/"). -func setLayout(files fstest.MapFS) { - GinkgoHelper() - fakeFS.SetFiles(files) -} - -func readArtwork(artID model.ArtworkID) []byte { - GinkgoHelper() - r, _, err := aw.Get(ctx, artID, 0, false) - Expect(err).ToNot(HaveOccurred()) - defer r.Close() - b, err := io.ReadAll(r) - Expect(err).ToNot(HaveOccurred()) - return b -} - -func readArtworkOrErr(artID model.ArtworkID) ([]byte, error) { - r, _, err := aw.Get(ctx, artID, 0, false) - if err != nil { - return nil, err - } - defer r.Close() - return io.ReadAll(r) -} - -// noopProvider implements external.Provider with not-found returns so the -// "external" priority entry never produces a result. -type noopProvider struct{} - -func (n *noopProvider) UpdateAlbumInfo(context.Context, string) (*model.Album, error) { - return nil, model.ErrNotFound -} -func (n *noopProvider) UpdateArtistInfo(context.Context, string, int, bool) (*model.Artist, error) { - return nil, model.ErrNotFound -} -func (n *noopProvider) SimilarSongs(context.Context, string, int) (model.MediaFiles, error) { - return nil, nil -} -func (n *noopProvider) TopSongs(context.Context, string, string, int) (model.MediaFiles, error) { - return nil, nil -} -func (n *noopProvider) ArtistImage(context.Context, string) (*url.URL, error) { - return nil, model.ErrNotFound -} -func (n *noopProvider) AlbumImage(context.Context, string) (*url.URL, error) { - return nil, model.ErrNotFound -} - -var _ external.Provider = (*noopProvider)(nil) diff --git a/core/artwork/e2e/mediafile_test.go b/core/artwork/e2e/mediafile_test.go index 1f43a3827..d26756dcd 100644 --- a/core/artwork/e2e/mediafile_test.go +++ b/core/artwork/e2e/mediafile_test.go @@ -1,4 +1,4 @@ -package artworke2e_test +package e2e import ( "testing/fstest" @@ -17,13 +17,11 @@ import ( // 2. For multi-disc albums, disc-level artwork // 3. Album cover art // -// FakeFS cannot synthesize taglib-readable embedded JPEGs, so scenario (1) -// is covered by the existing embedded-art album tests (which currently -// Skip under FakeFS). The tests below cover (2) and (3): the fallback -// chain for tracks without embedded art. -var _ = Describe("MediaFile artwork fallback", func() { +// Embedded art lands in the content-addressed store (asserted byte-for-byte); disc-level art is a +// serve-time read through the library FS. +var _ = Describe("MediaFile artwork resolution", func() { BeforeEach(func() { - setupHarness() + setupResolutionHarness() }) When("a multi-disc album track has no embedded art", func() { @@ -42,14 +40,14 @@ var _ = Describe("MediaFile artwork fallback", func() { setLayout(fstest.MapFS{ "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}), "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}), - "Artist/Album/CD1/disc1.jpg": imageFile("disc-1"), - "Artist/Album/CD2/disc2.jpg": imageFile("disc-2"), - "Artist/Album/cover.jpg": imageFile("album-root"), + "Artist/Album/CD1/disc1.jpg": smallPNG("disc-1"), + "Artist/Album/CD2/disc2.jpg": smallPNG("disc-2"), + "Artist/Album/cover.jpg": smallPNG("album-root"), }) scan() mf := mediafileOn("Artist/Album/CD2/01 - Track.mp3") - Expect(readArtwork(mf.CoverArtID())).To(Equal(imageBytes("disc-2"))) + Expect(serveBytes(mf.CoverArtID())).To(Equal(pngBytes("disc-2"))) }) }) @@ -63,12 +61,12 @@ var _ = Describe("MediaFile artwork fallback", func() { conf.Server.DiscArtPriority = defaultDiscPriority setLayout(fstest.MapFS{ "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - "Artist/Album/cover.jpg": imageFile("album-cover"), + "Artist/Album/cover.jpg": smallPNG("album-cover"), }) scan() mf := mediafileOn("Artist/Album/01 - Track.mp3") - Expect(readArtwork(mf.CoverArtID())).To(Equal(imageBytes("album-cover"))) + Expect(serveBytes(mf.CoverArtID())).To(Equal(pngBytes("album-cover"))) }) }) @@ -86,19 +84,60 @@ var _ = Describe("MediaFile artwork fallback", func() { setLayout(fstest.MapFS{ "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}), "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}), - "Artist/Album/cover.jpg": imageFile("album-root"), + "Artist/Album/cover.jpg": smallPNG("album-root"), }) scan() mf := mediafileOn("Artist/Album/CD2/01 - Track.mp3") - Expect(readArtwork(mf.CoverArtID())).To(Equal(imageBytes("album-root"))) + Expect(serveBytes(mf.CoverArtID())).To(Equal(pngBytes("album-root"))) + }) + }) + + When("a track has its own embedded art", func() { + // Artist/ + // └── Album/ + // └── 01 - Track.mp3 ← has embedded picture (wins over every fallback) + It("resolves the track's embedded image into the store", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"has_picture": "true"}), + }) + scan() + replaceWithRealMP3("Artist/Album/01 - Track.mp3") + + mf := mediafileOn("Artist/Album/01 - Track.mp3") + ia := acquire(model.KindMediaFileArtwork, mf.ID) + Expect(ia.Source).To(Equal("embedded")) + Expect(storedBytes(ia)).To(Equal(embeddedArtBytes)) + }) + }) + + When("EnableMediaFileCoverArt is turned off after the track was scanned", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 ← has embedded picture (must NOT be served) + // └── cover.jpg ← wins (per-track art disabled at serve time) + It("serves the album cover instead of the track's embedded art", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"has_picture": "true"}), + "Artist/Album/cover.jpg": smallPNG("album-cover"), + }) + scan() + replaceWithRealMP3("Artist/Album/01 - Track.mp3") + + // The setting is not part of the artwork fingerprint, so it must be honored at serve time. + conf.Server.EnableMediaFileCoverArt = false + mf := mediafileOn("Artist/Album/01 - Track.mp3") + trackArtID := model.NewArtworkID(model.KindMediaFileArtwork, mf.ID, nil) + Expect(serveBytes(trackArtID)).To(Equal(pngBytes("album-cover"))) }) }) }) func mediafileOn(relPath string) model.MediaFile { GinkgoHelper() - mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + mfs, err := rds.MediaFile(rctx).GetAll(model.QueryOptions{ Filters: squirrel.Like{"media_file.path": relPath}, }) Expect(err).ToNot(HaveOccurred()) diff --git a/core/artwork/e2e/playlist_test.go b/core/artwork/e2e/playlist_test.go index d28efca8e..f5ac8a7e3 100644 --- a/core/artwork/e2e/playlist_test.go +++ b/core/artwork/e2e/playlist_test.go @@ -1,13 +1,16 @@ -package artworke2e_test +package e2e import ( + "image/color" "os" "path/filepath" "testing/fstest" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/slice" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -17,14 +20,13 @@ import ( // 2. Sidecar image next to the .m3u file (same basename, any image ext) // 3. ExternalImageURL (http/https requires EnableM3UExternalAlbumArt; local path always allowed) // 4. Generated 2x2 tiled cover from the playlist's albums -// 5. Album placeholder image +// 5. Absent // -// The library FS is FakeFS, but uploaded/sidecar/local-external images are -// real files on disk — the reader reads them via os.Open, so the tests -// place them in a real tempdir under DataFolder. +// The library is an in-memory FS, but uploaded/sidecar/local-external images are real files on +// disk — the resolver reads them via os.Open, so those tests place them in a real tempdir. var _ = Describe("Playlist artwork resolution", func() { BeforeEach(func() { - setupHarness() + setupResolutionHarness() }) When("a playlist has an uploaded image", func() { @@ -33,11 +35,12 @@ var _ = Describe("Playlist artwork resolution", func() { // └── playlist/ // └── pl-1_upload.jpg ← matched by UploadedImagePath() (highest priority) It("returns the uploaded image bytes", func() { - writeUploadedImage(consts.EntityPlaylist, "pl-1_upload.jpg", imageBytes("playlist-upload")) - + writeUploadedImage(consts.EntityPlaylist, "pl-1_upload.jpg", pngBytes("playlist-upload")) pl := putPlaylist(model.Playlist{ID: "pl-1", Name: "Test", UploadedImage: "pl-1_upload.jpg"}) - Expect(readArtwork(pl.CoverArtID())).To(Equal(imageBytes("playlist-upload"))) + ia := acquire(model.KindPlaylistArtwork, pl.ID) + Expect(ia.Source).To(Equal("upload")) + Expect(serveBytes(pl.CoverArtID())).To(Equal(pngBytes("playlist-upload"))) }) }) @@ -48,12 +51,14 @@ var _ = Describe("Playlist artwork resolution", func() { It("returns the sidecar image", func() { dir := GinkgoT().TempDir() m3uPath := filepath.Join(dir, "MyList.m3u") - Expect(os.WriteFile(m3uPath, []byte("#EXTM3U\n"), 0600)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(dir, "MyList.jpg"), imageBytes("sidecar"), 0600)).To(Succeed()) + Expect(os.WriteFile(m3uPath, []byte("#EXTM3U\n"), 0o600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(dir, "MyList.jpg"), pngBytes("sidecar"), 0o600)).To(Succeed()) pl := putPlaylist(model.Playlist{ID: "pl-2", Name: "MyList", Path: m3uPath}) - Expect(readArtwork(pl.CoverArtID())).To(Equal(imageBytes("sidecar"))) + ia := acquire(model.KindPlaylistArtwork, pl.ID) + Expect(ia.Source).To(Equal("folder")) + Expect(serveBytes(pl.CoverArtID())).To(Equal(pngBytes("sidecar"))) }) }) @@ -64,12 +69,12 @@ var _ = Describe("Playlist artwork resolution", func() { It("matches case-insensitively", func() { dir := GinkgoT().TempDir() m3uPath := filepath.Join(dir, "MyList.m3u") - Expect(os.WriteFile(m3uPath, []byte("#EXTM3U\n"), 0600)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(dir, "MyList.PNG"), imageBytes("sidecar-png"), 0600)).To(Succeed()) + Expect(os.WriteFile(m3uPath, []byte("#EXTM3U\n"), 0o600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(dir, "MyList.PNG"), pngBytes("sidecar-png"), 0o600)).To(Succeed()) pl := putPlaylist(model.Playlist{ID: "pl-3", Name: "MyList", Path: m3uPath}) - Expect(readArtwork(pl.CoverArtID())).To(Equal(imageBytes("sidecar-png"))) + Expect(serveBytes(pl.CoverArtID())).To(Equal(pngBytes("sidecar-png"))) }) }) @@ -80,31 +85,39 @@ var _ = Describe("Playlist artwork resolution", func() { conf.Server.EnableM3UExternalAlbumArt = false // local paths bypass the toggle dir := GinkgoT().TempDir() imgPath := filepath.Join(dir, "cover.jpg") - Expect(os.WriteFile(imgPath, imageBytes("external-local"), 0600)).To(Succeed()) + Expect(os.WriteFile(imgPath, pngBytes("external-local"), 0o600)).To(Succeed()) pl := putPlaylist(model.Playlist{ID: "pl-4", Name: "WithExt", ExternalImageURL: imgPath}) - Expect(readArtwork(pl.CoverArtID())).To(Equal(imageBytes("external-local"))) + Expect(serveBytes(pl.CoverArtID())).To(Equal(pngBytes("external-local"))) }) }) When("a playlist has an http(s) ExternalImageURL and EnableM3UExternalAlbumArt is false", func() { - // (no local files — http source is gated off, reader falls through to placeholder) - It("skips the URL and falls through to the bundled placeholder", func() { + // (no local files — the http source is gated off, so resolution settles absent) + It("skips the URL and settles absent", func() { conf.Server.EnableM3UExternalAlbumArt = false - pl := putPlaylist(model.Playlist{ID: "pl-5", Name: "HttpGated", ExternalImageURL: "https://example.com/cover.jpg"}) - Expect(readArtwork(pl.CoverArtID())).To(Equal(placeholderBytes())) + ia := acquire(model.KindPlaylistArtwork, pl.ID) + Expect(ia.Hash).To(BeEmpty()) + Expect(serveErr(pl.CoverArtID())).To(MatchError(artwork.ErrUnavailable)) + + img, err := rsvc.GetOrPlaceholder(rctx, pl.CoverArtID().String(), 0, false) + Expect(err).ToNot(HaveOccurred()) + defer img.Close() + Expect(img.Placeholder).To(BeTrue()) }) }) When("a playlist has no images and no tracks", func() { - // (reader falls all the way through to the bundled album placeholder) - It("returns the album placeholder", func() { + // (no uploaded/sidecar/external image and no album art to sample) + It("settles absent", func() { pl := putPlaylist(model.Playlist{ID: "pl-6", Name: "Empty"}) - Expect(readArtwork(pl.CoverArtID())).To(Equal(placeholderBytes())) + ia := acquire(model.KindPlaylistArtwork, pl.ID) + Expect(ia.Hash).To(BeEmpty()) + Expect(serveErr(pl.CoverArtID())).To(MatchError(artwork.ErrUnavailable)) }) }) @@ -113,37 +126,79 @@ var _ = Describe("Playlist artwork resolution", func() { // Artist/ // ├── AlbumA/ // │ ├── 01 - Track.mp3 - // │ └── cover.png (real PNG — wins as tile 1 source) + // │ └── cover.png ← tile 1 source // └── AlbumB/ // ├── 01 - Track.mp3 - // └── cover.png (real PNG — wins as tile 2 source) - // Playlist "pl-7" references tracks from both albums, so the reader - // generates a 2x2 tiled cover from 2 distinct album art tiles (the - // tiled generator mirrors when it has fewer than 4 unique tiles). + // └── cover.png ← tile 2 source + // Playlist "pl-7" references tracks from both albums, so the worker generates a tiled + // cover from 2 distinct album art tiles (mirrored to fill the 2x2 grid). It("generates a tiled cover from album art", func() { conf.Server.CoverArtPriority = "cover.*" setLayout(fstest.MapFS{ "Artist/AlbumA/01 - Track.mp3": trackFile(1, "TA", map[string]any{"album": "AlbumA"}), - "Artist/AlbumA/cover.png": realPNG("albumA"), + "Artist/AlbumA/cover.png": smallPNG("albumA"), "Artist/AlbumB/01 - Track.mp3": trackFile(1, "TB", map[string]any{"album": "AlbumB"}), - "Artist/AlbumB/cover.png": realPNG("albumB"), + "Artist/AlbumB/cover.png": smallPNG("albumB"), }) scan() - // Pull the scanned mediafile IDs so we can attach them to the playlist. - mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{}) + mfs, err := rds.MediaFile(rctx).GetAll(model.QueryOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(mfs).To(HaveLen(2)) pl := model.Playlist{ID: "pl-7", Name: "Mix", OwnerID: "admin-1"} pl.AddMediaFilesByID([]string{mfs[0].ID, mfs[1].ID}) - Expect(ds.Playlist(ctx).Put(&pl)).To(Succeed()) + Expect(rds.Playlist(rctx).Put(&pl)).To(Succeed()) - data := readArtwork(pl.CoverArtID()) - // The tiled cover is a PNG-encoded 600x600 image (tileSize const). - // Exact bytes vary (random album order), so assert format + non-trivial size. + ia := acquire(model.KindPlaylistArtwork, pl.ID) + Expect(ia.Source).To(Equal("generated")) + data := storedBytes(ia) + // The tiled cover is a PNG-encoded image; exact bytes vary (random album order). Expect(data[:8]).To(Equal([]byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a})) - Expect(len(data)).To(BeNumerically(">", 1000)) + // Two tiles are mirrored into the 2x2 grid as [A B B A], so opposite corners match. + q := gridQuadrants(data) + Expect(q[0]).To(Equal(q[3])) + Expect(q[1]).To(Equal(q[2])) + Expect(q[0]).ToNot(Equal(q[1])) + }) + }) + + When("a playlist has tracks from four albums, each with its own cover", func() { + // Library: + // Artist/ + // ├── AlbumA/{01 - Track.mp3, cover.png} ← tile 1 + // ├── AlbumB/{01 - Track.mp3, cover.png} ← tile 2 + // ├── AlbumC/{01 - Track.mp3, cover.png} ← tile 3 + // └── AlbumD/{01 - Track.mp3, cover.png} ← tile 4 + It("fills all four grid quadrants with distinct album art", func() { + conf.Server.CoverArtPriority = "cover.*" + layout := fstest.MapFS{} + for _, name := range []string{"AlbumA", "AlbumB", "AlbumC", "AlbumD"} { + layout["Artist/"+name+"/01 - Track.mp3"] = trackFile(1, "T"+name, map[string]any{"album": name}) + layout["Artist/"+name+"/cover.png"] = smallPNG(name) + } + setLayout(layout) + scan() + + mfs, err := rds.MediaFile(rctx).GetAll(model.QueryOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(mfs).To(HaveLen(4)) + ids := slice.Map(mfs, func(mf model.MediaFile) string { return mf.ID }) + + pl := model.Playlist{ID: "pl-8", Name: "Four", OwnerID: "admin-1"} + pl.AddMediaFilesByID(ids) + Expect(rds.Playlist(rctx).Put(&pl)).To(Succeed()) + + ia := acquire(model.KindPlaylistArtwork, pl.ID) + Expect(ia.Source).To(Equal("generated")) + q := gridQuadrants(storedBytes(ia)) + Expect([]color.RGBA{q[0], q[1], q[2], q[3]}).To(HaveLen(4)) + Expect(q[0]).ToNot(Equal(q[1])) + Expect(q[0]).ToNot(Equal(q[2])) + Expect(q[0]).ToNot(Equal(q[3])) + Expect(q[1]).ToNot(Equal(q[2])) + Expect(q[1]).ToNot(Equal(q[3])) + Expect(q[2]).ToNot(Equal(q[3])) }) }) }) @@ -153,6 +208,6 @@ func putPlaylist(pl model.Playlist) model.Playlist { if pl.OwnerID == "" { pl.OwnerID = "admin-1" } - Expect(ds.Playlist(ctx).Put(&pl)).To(Succeed()) + Expect(rds.Playlist(rctx).Put(&pl)).To(Succeed()) return pl } diff --git a/core/artwork/e2e/radio_test.go b/core/artwork/e2e/radio_test.go index 73ee5f377..bba85224a 100644 --- a/core/artwork/e2e/radio_test.go +++ b/core/artwork/e2e/radio_test.go @@ -1,15 +1,18 @@ -package artworke2e_test +package e2e import ( "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) +// Radio art is uploaded-image-only, with no fallback. Uploads are real files on disk, so they +// serve back byte-for-byte; a radio with no upload settles absent. var _ = Describe("Radio artwork resolution", func() { BeforeEach(func() { - setupHarness() + setupResolutionHarness() }) When("a radio has an uploaded image", func() { @@ -18,25 +21,25 @@ var _ = Describe("Radio artwork resolution", func() { // └── radio/ // └── rd-1_logo.jpg ← matched by UploadedImagePath() It("returns the uploaded image bytes", func() { - writeUploadedImage(consts.EntityRadio, "rd-1_logo.jpg", imageBytes("radio-logo")) - + writeUploadedImage(consts.EntityRadio, "rd-1_logo.jpg", pngBytes("radio-logo")) rd := model.Radio{ID: "rd-1", Name: "Test Radio", StreamUrl: "https://example.com/stream", UploadedImage: "rd-1_logo.jpg"} - Expect(ds.Radio(ctx).Put(&rd)).To(Succeed()) + Expect(rds.Radio(rctx).Put(&rd)).To(Succeed()) - artID := model.NewArtworkID(model.KindRadioArtwork, rd.ID, nil) - Expect(readArtwork(artID)).To(Equal(imageBytes("radio-logo"))) + ia := acquire(model.KindRadioArtwork, rd.ID) + Expect(ia.Source).To(Equal("upload")) + Expect(serveBytes(model.NewArtworkID(model.KindRadioArtwork, rd.ID, nil))).To(Equal(pngBytes("radio-logo"))) }) }) When("a radio has no uploaded image", func() { - // (no files on disk — reader has no sources to fall back to) - It("returns ErrUnavailable", func() { + // (no files on disk — the resolver has no sources to fall back to) + It("settles absent", func() { rd := model.Radio{ID: "rd-2", Name: "Bare Radio", StreamUrl: "https://example.com/stream"} - Expect(ds.Radio(ctx).Put(&rd)).To(Succeed()) + Expect(rds.Radio(rctx).Put(&rd)).To(Succeed()) - artID := model.NewArtworkID(model.KindRadioArtwork, rd.ID, nil) - _, err := readArtworkOrErr(artID) - Expect(err).To(HaveOccurred()) + ia := acquire(model.KindRadioArtwork, rd.ID) + Expect(ia.Hash).To(BeEmpty()) + Expect(serveErr(model.NewArtworkID(model.KindRadioArtwork, rd.ID, nil))).To(MatchError(artwork.ErrUnavailable)) }) }) }) diff --git a/core/artwork/e2e/resolution_harness_test.go b/core/artwork/e2e/resolution_harness_test.go new file mode 100644 index 000000000..1168393df --- /dev/null +++ b/core/artwork/e2e/resolution_harness_test.go @@ -0,0 +1,365 @@ +package e2e + +import ( + "bytes" + "context" + "fmt" + "hash/fnv" + "image" + "image/color" + "image/png" + "io" + "maps" + "os" + "path/filepath" + "strings" + "sync" + "testing/fstest" + "time" + + _ "github.com/navidrome/navidrome/adapters/gotaglib" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/metrics" + "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/core/storage/storagetest" + "github.com/navidrome/navidrome/db" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/persistence" + "github.com/navidrome/navidrome/scanner" + "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/tests/harness" + "github.com/navidrome/navidrome/utils/cache" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.senan.xyz/taglib" +) + +const fakeLibScheme = "artworkfake" +const fakeLibPath = fakeLibScheme + ":///music" + +const ( + defaultCoverPriority = "cover.*, folder.*, front.*, embedded, external" + defaultDiscPriority = "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded" +) + +var ( + rctx context.Context + rds *tests.MockDataStore + rstore *artwork.ImageStore + rsvc artwork.Artwork + rworker *artwork.Worker + fakeFS *storagetest.FakeFS +) + +// The go-sqlite3 singleton holds the file open for the whole suite, and Windows cannot unlink a +// file with a live handle, so the DB cannot live in Ginkgo's per-spec TempDir. +var suiteDBTempDir string + +// Migrating the schema costs ~400ms, so it runs once per suite and specs reset by truncating. +var userTables []string + +var _ = BeforeSuite(func() { + suiteDBTempDir = GinkgoT().TempDir() + + DeferCleanup(configtest.SetupConfig()) + conf.Server.DbPath = filepath.Join(suiteDBTempDir, "artwork-resolution-e2e.db") + "?_journal_mode=WAL" + conf.Server.DataFolder = conf.NewDir(GinkgoT().TempDir()) + db.Db().SetMaxOpenConns(1) + db.Init(request.WithUser(context.Background(), model.User{ID: "admin-1", IsAdmin: true})) + + userTables = harness.ResettableTables() +}) + +var _ = AfterSuite(func() { + db.Close(context.Background()) +}) + +func setupResolutionHarness() { + DeferCleanup(configtest.SetupConfig()) + + tempDir := GinkgoT().TempDir() + conf.Server.DbPath = filepath.Join(suiteDBTempDir, "artwork-resolution-e2e.db") + "?_journal_mode=WAL" + conf.Server.DataFolder = conf.NewDir(tempDir) + conf.Server.MusicFolder = fakeLibPath + conf.Server.DevExternalScanner = false + conf.Server.ImageCacheSize = "0" + conf.Server.EnableExternalServices = false + conf.Server.EnableMediaFileCoverArt = true + conf.Server.DevArtworkWorkerConcurrency = 1 + + rctx = request.WithUser(GinkgoT().Context(), model.User{ID: "admin-1", UserName: "admin", IsAdmin: true}) + harness.TruncateDB(userTables) + + rds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} + + adminUser := model.User{ID: "admin-1", UserName: "admin", Name: "Admin", IsAdmin: true, NewPassword: "password"} + Expect(rds.User(rctx).Put(&adminUser)).To(Succeed()) + + lib := model.Library{ID: 1, Name: "Music", Path: fakeLibPath} + Expect(rds.Library(rctx).Put(&lib)).To(Succeed()) + Expect(rds.User(rctx).SetUserLibraries(adminUser.ID, []int{lib.ID})).To(Succeed()) + + loadEmbeddedFixture() + + fakeFS = &storagetest.FakeFS{} + storagetest.Register(fakeLibScheme, fakeFS) + + ffm := tests.NewMockFFmpeg("") + rstore = artwork.NewImageStore(filepath.Join(tempDir, consts.HashedArtworkFolder)) + // size=0 requests stream originals, so this reader is never called (serving_test covers resizing). + imgCache := cache.NewFileCache("ArtworkResolutionE2E", "100MB", "images", 0, + func(context.Context, cache.Item) (io.Reader, error) { + return nil, fmt.Errorf("resize not exercised in e2e") + }) + Eventually(func() bool { return imgCache.Available(rctx) }).Should(BeTrue()) + + rsvc = artwork.NewArtwork(rds, imgCache, rstore, ffm) + rworker = artwork.NewWorker(rds, rstore, agents.GetAgents(rds, nil), ffm, events.NoopBroker(), imgCache) +} + +// setLayout paths must be relative and forward-slash. +func setLayout(files fstest.MapFS) { + GinkgoHelper() + fakeFS.SetFiles(files) +} + +func scan() { + GinkgoHelper() + s := scanner.New(rctx, rds, events.NoopBroker(), + playlists.NewPlaylists(rds, artwork.NewUploader(rds)), metrics.NewNoopInstance()) + _, err := s.ScanAll(rctx, true) + Expect(err).ToNot(HaveOccurred()) +} + +func acquire(kind model.Kind, id string) model.ItemArtwork { + GinkgoHelper() + // Enqueues the way the serving paths do, so the drain is driven by a plain queue row. + Expect(rds.ArtworkQueue(rctx).EnqueuePreservingBackoff(model.ArtworkQueueItem{ + ItemKind: kind.Prefix(), ItemID: id, ImageType: model.ImageTypePrimary, + Priority: model.ArtworkPriorityBump, + })).To(Succeed()) + var ia *model.ItemArtwork + runResolutionWorkerUntil(func() bool { + got, err := rds.Artwork(rctx).GetItemArtwork(kind, id, model.ImageTypePrimary) + if err != nil { + return false + } + ia = got + return true + }) + return *ia +} + +func runResolutionWorkerUntil(until func() bool) { + GinkgoHelper() + runCtx, cancel := context.WithCancel(rctx) + done := make(chan error, 1) + go func() { done <- rworker.Run(runCtx) }() + Eventually(until, 5*time.Second, 10*time.Millisecond).Should(BeTrue()) + cancel() + Eventually(done, 2*time.Second).Should(Receive(BeNil())) +} + +func serveBytes(artID model.ArtworkID) []byte { + GinkgoHelper() + img, err := rsvc.Get(rctx, artID, 0, false) + Expect(err).ToNot(HaveOccurred()) + defer img.Close() + data, err := io.ReadAll(img) + Expect(err).ToNot(HaveOccurred()) + return data +} + +func serveErr(artID model.ArtworkID) error { + img, err := rsvc.Get(rctx, artID, 0, false) + if img != nil { + img.Close() + } + return err +} + +func libFileBytes(suffix string) []byte { + GinkgoHelper() + var match string + for name := range fakeFS.MapFS { + if strings.HasSuffix(name, suffix) { + Expect(match).To(BeEmpty(), "suffix %q is ambiguous: %q and %q", suffix, match, name) + match = name + } + } + Expect(match).ToNot(BeEmpty(), "no library file ends with %q", suffix) + return fakeFS.MapFS[match].Data +} + +// Serving before acquiring is deliberate: with no state row the request resolves through the +// library FS, while a settled folder row is read with os.Open, which the in-memory FS cannot serve. +func expectAlbumFolderCover(al model.Album, suffix string) { + GinkgoHelper() + requireNoStateRow(model.KindAlbumArtwork, al.ID) + Expect(serveBytes(al.CoverArtID())).To(Equal(libFileBytes(suffix))) + ia := acquire(model.KindAlbumArtwork, al.ID) + Expect(ia.Source).To(Equal("folder")) + Expect(filepath.ToSlash(ia.SourcePath)).To(HaveSuffix(suffix)) +} + +// A drain settles every ready item, so byte-level folder assertions must precede any acquire. +func requireNoStateRow(kind model.Kind, id string) { + GinkgoHelper() + _, err := rds.Artwork(rctx).GetItemArtwork(kind, id, model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound), + "assert %s %q before acquiring any other entity in this spec", kind, id) +} + +func expectAlbumAbsent(al model.Album) { + GinkgoHelper() + ia := acquire(model.KindAlbumArtwork, al.ID) + Expect(ia.Hash).To(BeEmpty()) + Expect(serveErr(al.CoverArtID())).To(MatchError(artwork.ErrUnavailable)) +} + +func expectArtistFolder(ar model.Artist, suffix string) { + GinkgoHelper() + requireNoStateRow(model.KindArtistArtwork, ar.ID) + Expect(serveBytes(model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil))).To(Equal(libFileBytes(suffix))) + ia := acquire(model.KindArtistArtwork, ar.ID) + Expect(ia.Source).To(Equal("folder")) + Expect(filepath.ToSlash(ia.SourcePath)).To(HaveSuffix(suffix)) +} + +func writeUploadedImage(entity, filename string, data []byte) { + GinkgoHelper() + dst := model.UploadedImagePath(entity, filename) + Expect(os.MkdirAll(filepath.Dir(dst), 0o755)).To(Succeed()) + Expect(os.WriteFile(dst, data, 0o600)).To(Succeed()) +} + +func discArtID(al model.Album, disc int) model.ArtworkID { + return model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, disc), &al.UpdatedAt) +} + +// Disc art is a pure serve-time read through the library FS: no worker, no state row. +func expectDiscImage(al model.Album, disc int, label string) { + GinkgoHelper() + Expect(serveBytes(discArtID(al, disc))).To(Equal(pngBytes(label))) +} + +// Samples in rect() order: top-left, top-right, bottom-left, bottom-right. +func gridQuadrants(data []byte) [4]color.RGBA { + GinkgoHelper() + img, _, err := image.Decode(bytes.NewReader(data)) + Expect(err).ToNot(HaveOccurred()) + b := img.Bounds() + qw, qh := b.Dx()/4, b.Dy()/4 + at := func(x, y int) color.RGBA { + c := color.RGBAModel.Convert(img.At(b.Min.X+x, b.Min.Y+y)) + return c.(color.RGBA) + } + return [4]color.RGBA{at(qw, qh), at(3*qw, qh), at(qw, 3*qh), at(3*qw, 3*qh)} +} + +// Store-backed sources only (embedded/generated); file-backed ones assert on ia.SourcePath. +func storedBytes(ia model.ItemArtwork) []byte { + GinkgoHelper() + art, err := rds.Artwork(rctx).GetImage(ia.Hash) + Expect(err).ToNot(HaveOccurred()) + r, err := rstore.Open(ia.Hash, art.Mime) + Expect(err).ToNot(HaveOccurred()) + defer r.Close() + data, err := io.ReadAll(r) + Expect(err).ToNot(HaveOccurred()) + return data +} + +// The pixel color derives from label, so each label yields distinct, still-decodable bytes. +func smallPNG(label string) *fstest.MapFile { + h := fnv.New32a() + _, _ = h.Write([]byte(label)) + sum := h.Sum32() + c := color.RGBA{R: byte(sum), G: byte(sum >> 8), B: byte(sum >> 16), A: 255} + img := image.NewRGBA(image.Rect(0, 0, 2, 2)) + for y := range 2 { + for x := range 2 { + img.Set(x, y, c) + } + } + var buf bytes.Buffer + Expect(png.Encode(&buf, img)).To(Succeed()) + return &fstest.MapFile{Data: buf.Bytes()} +} + +func pngBytes(label string) []byte { + GinkgoHelper() + return smallPNG(label).Data +} + +func trackFile(num int, title string, extra ...map[string]any) *fstest.MapFile { + tags := storagetest.Track(num, title) + for _, e := range extra { + maps.Copy(tags, e) + } + return storagetest.MP3(tags) +} + +// FakeFS's JSON-encoded tags aren't taglib-readable, so embedded-art specs swap in these real MP3 +// bytes after scanning. Loaded lazily: tests.Init must chdir to the project root first. +var ( + embeddedFixtureOnce sync.Once + embeddedArtFixture []byte + embeddedArtBytes []byte +) + +func loadEmbeddedFixture() { + embeddedFixtureOnce.Do(func() { + embeddedArtFixture = readFixture(mp3Fixture) + embeddedArtBytes = extractEmbeddedArt(embeddedArtFixture) + }) +} + +func extractEmbeddedArt(mp3 []byte) []byte { + tf, err := taglib.OpenStream(bytes.NewReader(mp3)) + if err != nil { + panic("embedded-art fixture: taglib.OpenStream failed: " + err.Error()) + } + defer tf.Close() + images := tf.Properties().Images + if len(images) == 0 { + panic("embedded-art fixture has no embedded images") + } + data, err := tf.Image(0) + if err != nil || len(data) == 0 { + panic("embedded-art fixture: could not read image 0") + } + return data +} + +func replaceWithRealMP3(relPath string) { + GinkgoHelper() + fakeFS.MapFS[relPath] = &fstest.MapFile{Data: embeddedArtFixture} +} + +func firstAlbum() model.Album { + GinkgoHelper() + albums, err := rds.Album(rctx).GetAll(model.QueryOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(albums).To(HaveLen(1), "expected exactly one album, got %d", len(albums)) + return albums[0] +} + +func albumByName(name string) model.Album { + GinkgoHelper() + albums, err := rds.Album(rctx).GetAll(model.QueryOptions{}) + Expect(err).ToNot(HaveOccurred()) + for _, al := range albums { + if al.Name == name { + return al + } + } + Fail(fmt.Sprintf("album %q not found among %d albums", name, len(albums))) + return model.Album{} +} diff --git a/core/artwork/e2e/suite_test.go b/core/artwork/e2e/suite_test.go deleted file mode 100644 index 06cc05b6f..000000000 --- a/core/artwork/e2e/suite_test.go +++ /dev/null @@ -1,120 +0,0 @@ -package artworke2e_test - -import ( - "context" - "fmt" - "path/filepath" - "testing" - - _ "github.com/navidrome/navidrome/adapters/gotaglib" - "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/metrics" - "github.com/navidrome/navidrome/core/playlists" - "github.com/navidrome/navidrome/core/storage/storagetest" - "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/scanner" - "github.com/navidrome/navidrome/server/events" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestArtworkE2E(t *testing.T) { - tests.Init(t, false) - log.SetLevel(log.LevelFatal) - RegisterFailHandler(Fail) - RunSpecs(t, "Artwork E2E Suite") -} - -const fakeLibScheme = "artworkfake" -const fakeLibPath = fakeLibScheme + ":///music" - -var ( - ctx context.Context - ds *tests.MockDataStore - aw artwork.Artwork - fakeFS *storagetest.FakeFS -) - -// The DB file lives in a suite-level tempdir: the go-sqlite3 singleton keeps -// the file open for the whole suite, and Ginkgo's per-spec TempDir cleanup -// can't unlink a file with a live handle on Windows. A suite-level tempdir -// combined with an AfterSuite close avoids the lock conflict. -var suiteDBTempDir string - -var _ = BeforeSuite(func() { - suiteDBTempDir = GinkgoT().TempDir() -}) - -var _ = AfterSuite(func() { - db.Close(GinkgoT().Context()) -}) - -func setupHarness() { - DeferCleanup(configtest.SetupConfig()) - - tempDir := GinkgoT().TempDir() - // Reuse the suite-level DB path so the singleton connection keeps working - // across specs (see suiteDBTempDir comment). - conf.Server.DbPath = filepath.Join(suiteDBTempDir, "artwork-e2e.db") + "?_journal_mode=WAL" - conf.Server.DataFolder = conf.NewDir(tempDir) - conf.Server.MusicFolder = fakeLibPath - conf.Server.DevExternalScanner = false - conf.Server.ImageCacheSize = "0" // disabled cache → reader runs on every call - conf.Server.EnableExternalServices = false - - db.Db().SetMaxOpenConns(1) - ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "admin-1", UserName: "admin", IsAdmin: true}) - db.Init(ctx) - DeferCleanup(func() { Expect(tests.ClearDB()).To(Succeed()) }) - - ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} - - adminUser := model.User{ID: "admin-1", UserName: "admin", Name: "Admin", IsAdmin: true, NewPassword: "password"} - Expect(ds.User(ctx).Put(&adminUser)).To(Succeed()) - - lib := model.Library{ID: 1, Name: "Music", Path: fakeLibPath} - Expect(ds.Library(ctx).Put(&lib)).To(Succeed()) - Expect(ds.User(ctx).SetUserLibraries(adminUser.ID, []int{lib.ID})).To(Succeed()) - - fakeFS = &storagetest.FakeFS{} - storagetest.Register(fakeLibScheme, fakeFS) - - aw = artwork.NewArtwork(ds, artwork.GetImageCache(), newNoopFFmpeg(), &noopProvider{}) -} - -func scan() { - GinkgoHelper() - 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()) -} - -func firstAlbum() model.Album { - GinkgoHelper() - albums, err := ds.Album(ctx).GetAll(model.QueryOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(albums).To(HaveLen(1), "expected exactly one album, got %d", len(albums)) - return albums[0] -} - -func albumByName(name string) model.Album { - GinkgoHelper() - albums, err := ds.Album(ctx).GetAll(model.QueryOptions{}) - Expect(err).ToNot(HaveOccurred()) - for _, al := range albums { - if al.Name == name { - return al - } - } - Fail(fmt.Sprintf("album %q not found among %d albums", name, len(albums))) - return model.Album{} -} diff --git a/core/artwork/e2e/testdata/embedded_art.mp3 b/core/artwork/e2e/testdata/embedded_art.mp3 deleted file mode 100644 index 18cb906749e047afda734b62c1ffcdd89ca72ead..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 64223 zcmd421yodD7dUzeNdb`(kWvJc8fJzW1_h)+kOo0=3ZxrFK#&xXE)kRl2?-HN!JtH1 zq`SL2{}+G$zIyLl>wD|1_13$?y|eGW`|RHPoS8W&%fW~N03Z~l(^1xef)EJ+5NX@n zSpYg3@*?0p8F(-4{I0TJXN!UoNW25dl-hCB>JQGzI}y&W0=FCbD*T?<5>i55Yj z5P~8izeUSwss6!2Ay9~*2viXETcIAs82vQI#2K*7o1U?1@0NaQpAp!&J6U4E7&_ZKxd5QrHh;P9F9LRuKucajRoXyNT3O8$jH>Qk zQw@1(6#ycH5`q3h$^W3!(UO)``I`vB|2XhnQ{A1+*jn&!)s%I(khnkQ8(0O3#%xUQi;s8M2 z&ceph`2pu2BJ#RF03XQv(W6KILG5bi?10Bwnp@&={~V#K{hL3SUId^vrzZe#dSVEM z83Ks^z6$-m8Us4O1Hck+1~`9TDS&^{Kmj2@6r?f+ls5AXXRZ%~{6 zcf4Ie-p+snfCsQ3e{=A}12|B|pR6zgEw*)aw!~tb(3WHe7;hjt^aeyey93zT` z;slWp5riNdio^?|MG-JT97;q?1ceqghl!#A1RN^@!y`lm%^^6nARLWB38G;Th#*n~ zj~9huATT5X{kJ6ae@Mbv{gwra!a)!)a}gLu5Gg8#5`^Q;v4Us}(p(UWfZ!20j0h46 zM*=Ve41qvGu!1l#QFBlquxQ0lSg;7p%|%6U2n-f3iuiA$obZ2(!Xj}XSCptAN)!ec zgkzB)7bF&xhetz1ATT%riZuts@EANEs1{2nLVE<8XhA z!u*F-U?Xs_cg8z`W?{~35*33BnnRHw8yF5FC}s`^dks_!C5D5+aBw^nKtbUU7zzy) zgrKmXn{iN-APNIF7ZinHv7&G|9EpSd!z}E7$U-|gq3taG3Ktv!5k-sO;DTZ}5mC@R zXqX@d2Sx&gg+lOfs5uxB1OS0R%|U;O2_li0GarJ6i$THYB1F(AET|w>1pbdI|4(5! zOD8;019f4fTQt(I8kthfy414f+&axS`Y(fD-MBzV$gW}e-rk1Q#{Lh ztQg!J0T(kDM2Mlma6rJopdna{AWj4(iUCEVVOS^tK_juEVqj^Az+s?9qHu^H298I8 zUWGy+VqjgEBmS!!9h~fOuGqgU!=aEOSTP(D%q|p|AYe@iqOmX$uzFBPs0bPZ6U89_ za|jxOf*`?2!l0mKFfcdJqM#cQI8YrtP7H}b{KJi4UH@A#gL5D#>`&0p=3)pjBnku0 zfN+ts3^5nPpmAV2ilNYO92O-42jc<5nS&;R(r{qnfk8tdK^KDI!$QH7#law=__O|g zSN3fF#Dmf-TrFMDPJn|28V&pd#$W(9do0=pY&&is{vR-iH@9(h!2=HFAo>sJ24WrX zPUiMbw)itf|9*Q$_^0n%o{9WxMpaYSx(jeZejjk?9{`5@1Bl7}0g&*&01+7K-vJQ? zIOI1*L{|1M00Nbh`HKbwz~-o|ab}@CXyM-h102Am$G_ZQek*g9qyQfMFE9oGdkp}t z27bR`0J%GYXAsT`{Z9}B{9U>LXx~3!%pY3`i2iA-_FWB`zioy5t*po&044JW_{$d% zY1rTPfQaArz-8oR|1#`v%o*wbg86%jAawbUtolc^&I$$!7CQz8uB%YyXfZ*!xj9(i zc(6px5#}(Q2m%9^1D=!buhh5>K%p=k1O~wfV$8wJf@7g*K`;dof?&5tVla59C>90# ze?|?6ikM^I;Isg;1^XVzR}d{C0wy^gj}Q@sBEgyz{afF$@f=IUk4Bnw1~MF44g%42}~8 z+Z+-j3WfZG_5VivH*0APWmy2YD+DeAEN#&icwq-{bw_l1f(6sk4JG zSSUb{N?Az`T#Nvp&rULMSq*3n*?=o%fY?I%t~3A?hf^M*&z9tzSS7G}fX6%l;NuSf z2O!F44gg%i73zW+0EoW?049f|O3ho~19Ar?18~K3F8J&uuzm3QjGEL%`K~n0VR=OR21ca)3aB~r|f8Ofw!eB zC#~~nVA+^7Mz4OGBW9)xO(31|E!TyTGew6s^M;DBFTJ#8#FcVJIb|h`j9h5ivlH73 zgPj)y^!+FVO1UlOJDzMYC6R7uOI z?og8vrhA;-(d)l*p?NaDd?rt3^;ugc*2T`GS}gW*DmFlf@t~#yOAc|%#n`Qqe=?lA zlzR7gauai-G(e61;88c*`1OLpv|V41+-Z)&_=$|i-M39HK891hkc?xet-LcwoZ5d< zZ1GX&Im@=A~YywE55;qwfiP`JPtdU2(>(E#?g+v(w%LFv{|zDfm?F4Y?lW`(0$ zseyV6#BFMCzZz7qB;KS=m-@EK?FO^CApT1+rb|}s;(mSlWDVWB-Tx%A2&mFVG9vY(W-H*7H4R9kCO5geF?5EEJ!F>5;SLlc>C3fh8ZKQH$O*7STP9Zj(P5?*g3>E23?7`yyUcwQna(9Lw!Qt`e5TjOQJ^`6IV^%XSOGRM>WD+SJ5+sp6cbnGDIM<@6M{%Jf%+ONN)ckG%{m+)=tDn_DigJrZA#wf{+o z{&6*O_bVZXV{E6)*SUjXhGgQNRz+0RmTp_yn}r)8_p5uPc(*TFd994)tDgJm;T5L* ztdfZ<`;vuAq6z2GjNrKULXgB4gT=_C*BYXd`bW=QwKgNp&$;;tW$hj^NBa{od-n~0 zRgFj(wr8}T!g77;d=gbeHbvztOWbuzsj8hip50IP<|-1*kIdB5nevj{BK>zNpXlE^2`WLv zbJv`w?IlHAH{Kv|zlo!7$)7X7opnh{BPpmUGSYdr{GodUTVNvH6N6Mw)~}_~3Z<WQ_MzbYB1IJO+~>q^L9t&U#?E|Nk+*FaWrpHb&(mz zQ)90F5(_Q)+33`Bh)96QJlM(N@MLr!J5886Z^r7?^=t1iB9tSa{B5Z#$^P&S%I4N+ zWU(=$Tv}y&G(M`=dzH#KQ>+S)Pb`9u1#T2Mz>_ldsjjL+e-_G>KMQCO9+ z@Z?&*|6!Xn_U;cIPfJq6LyD>0(_KxT@(gv- z8754TFJ$Jf``K?Cm$OJwSCp!kNi`u@`RLFi8k0rj_qCE+s|3y&ENIk}zjE<|=raX1 zNryY>RaP74aLYu)YPJHI)jvHcU|Nf-uwDSqBVsTOhgnLpeV4 zcpVFM#7bMcehECp$gLEJin_~Ex}p~ zi%V8MQ^&b&I@_a0dF+RU?_cG~CcMABylce17cI?EH{|zzq}1ut-mi1O-G!^NNyeLO z7Sq;RJxlmFvx#@#UuZR4^3R}nB7&;!7k^|z5P+0m(%|s9DUUm^d9{`x4QEuSiC(0; zh(DU6Pn%tRD$*}R*i4^8;-6-NWAfuvJRD)JCEW8BCM&=9Q)*Y?G%tSiRx`^AI!ff$ z%0{)D(=+G6gJUMC+l4$b+&oHTj$acu7RS4aXtU0ZoD=b;54q|>saO7e@!Bj;n$lbO zoxlQWPxz98!D{R};>2hHTvIm>q$pb4D+3ymw=sCE<3v zuvcCp}MF@?w{H6Liz~kdg#gU=ILsD*ox69{dEqGjC2A5Im z%S>~$K5ry>nfF?C@=cuniL8qa&DS)t@CM3T_bz;9n!h$9?XvD1Q0Hvd{d&W5<16B% zQnfcV(*@-b!MfmfUExuK*v-ktYMal_`}Q85za|2%ZX7I9)JEa2XU3F%9Rhaef9`Jz z^b@gXFk{s0Z;tiN&1NLqS?zKDkh#)wxE0(h)T{ZXe;Cbtnj;gQWBoX>Pab=LAvHof zHHKQ~#r$3cO+viV6&fF+G@S`{J!n6vnS{I>hMa+n`^8Hq|1oCr9!a&r};1QNe1Q(rrClP@wLmL z0{t{~bL6SKeNF=ztI~pdHeFK7;_=!O$Isp+3SFdbRv4==IPpn@${AYowYvtZH2Dqe zN-blm1USs;Z_&x!xN&2lyM5;Y+=^MB(Q01K=H<|uUiM|VFK`>E zkbd0V+#;Jc29sz%OEuHz^~00~8p*ifw$TmIBhr`x7cGl5=t}a zUEWeC(Dss>lO3e$(6v@Y#NLaUJD>86XOHU=ZT+6d(8UuX0_64gZF|jJj!eXs6dX*b zC^gtLQ=4-vBaxMnw80PO;ULMUp(3qOChG+!V_K(1B_aetS(oT27QQ_u6I0=o_A4uA zFT9v9OweZZXfC1Us?LW9azK82Ku}clqpejV!sHO8ruO4vEF!nc#Du$%P`uciZu zEPZIx>S&PRpph_PyB(iqZo&%#8FaKH0PVpMRp(#MufMSCDeS7xEd0BKIJl}<0aS9 z&&ZYdac!pQX!P}!isg(J;tk`CTjw}*0^Sn{VJkl-XC)iH4kw9!e#e5ami4?9I!=Xg zwUj z-QB zqoba+`o+>F@ce1!SF+MJ#q?u<&swIljFUW;VSp{WLK2E4z^NleW488jcVfdgk#^k7aAs{{F{OzZ}b@=oXKI0NGRH z?md1!%F*%Ds}xu$IV88DPor`G?cz@!wMF<1NFD9nn*ZsZ+w{Tm=Yv@Sx+&Wm*Uk7B z;%7P^v@Kf9zm?Z{r3w_0uzB+_v=$>Zqh0bPdK%J^m7CFrE6x6Bkr?oZ*n(w2JJV&)Lxt) z`C0rn>l8CgQvPm?WG{#=U#(`L*LY+=zE z=HZ4a^N{@#n|thW4sRh9JqcTwb4UL72nHkT?!nenAuS_me=32eH%S`mh&6xemPHL#P@u8kkjVDT-iNHqfuyxf7|*2pIcxV`0lijODk6+Rp!B5o7drvyxC78QtGMJ zSE7@@^6t%wT6I4=x(R;aG~qV>@T*LCoUo~htjmtkV_QIu^%v_Hg^iDtKLxZzA|to? z!a8h_xUFA1HU#?Q=+SJ$!@V_hjl;Oio7b=3ZPyoHXB50&7k(Kv;mM9|c<>Vb**sp2 z)huFXsl-Qq@O$|;ph9Ddf|~z?rc$Q9k5Ag=x~bPTgZ=P8HKkV~!gjCM)O*=Qs9o~V zW-nqR<16u}Ttf@E7mi=XQS>V@tn`u%a>cbOrkn%pCY4g)fUY1;(OC0Dq`ljM?{&%t zMF^gZx||N#+n*ZLu|)ZrCl~e0q69-Meyp93Gp{dyeeY*I98T!W1#w79pJKvK`ibs*!rBbq+juZf}bt%K!@gD)n9Z^Q=P3r6s#_`HH4aXrPjE&@0AxQ9g}Al_N~AO ziO8hr`YH67?Bz$*Ycq@^o||`h6?QsgX(G?Fn%%sy|0_LQasg+@IjUun_iN3JB*+07 z6?6LnVCTECg7Bj1aAuJhx_uJDa|!W@FU3l%RnNvp;tq>kbVzD;d{pVvCY``@I-yvV z!`>~K>2g!g^#Ho7tnddB8{*H73ymtEzJoyp`o&i+p6DF6cBIO_ZlOBw(p)MZPGmTl zTqDiX{XEQ|Fxo&RcS7T{L*wBvUXs^^Ak?dN&O3XK2;d=!4x#GsG|7=T=kz$QG2x^F zU;N~?*6|D(-3&JLV}sT$8!9>BKC=ayE|C^|m)M~@L!+$2_<`?30B{Nz#* z{ncHKGTC)DQ*%$+&x*PnzO_diD!dAaM{8p(H;GNx!xHDBsB5tleX_&PW-e44E2A(a zm(ov!s4j5{_`g?0$!`vZy0t~8!u`81e61t6uJlYu4Xwc`X&fZY;jvUaMeP^X`$8q) zm)`&+u2WCEKdD27jbu1Y*X%%xziA zs)ByhIJdALAMkR))g(*rPn;XPk|XBy&9z z(F382R&N+HmD;jjdDW>mj(1xTd*xG}Y*|}5Z)JVtlD_FBkb}ihx>{$jGxusR=Qj&o z6%J+`qNMJ&B3a{K?xll}UVAe8X6#*&O-jdSjvVCYoN3^#;wE*G?lL zx}kTMm3uU31%O0FrS4}d-yNfk$*0Q6Oe>pfc(fd{I(Fzu;`5g8V7}VJyT|PX`^9$lbW~}Q# zB;fS?iyCYB<%sdaBw{(7N736B#^s%R5%(-hy@8!%v$xfp8K&iD4Vw!Kfl zc&XAIhHD>`eEDNkC7$N^j(oY_%ljaNd)R zD0iXQ&NTVLy5rkS-<~^hn)VshY^jLvH>Ky$c;<=F!!Bn!&fxS#Spk=+rpW7^Fu=Ln z-1Dl-c4plt?+cLRwD~dEm|Nb2dOUEYw1H<+ zR!d!?b~i!hB>xB-(UWyZ;zP(}-`7M4nSJDchrovDV-WqMu+GvGuQgX`%@7i0R%Icr zb|zW&pYWADoLYX~VH(6O&x&YE??mL%8^5|xZE}8GIeAJbi|9lMlkkkk?ux73-X*$# zo!hxA@%JhrPh>m{t{(hQ$_GEbq9(OAtJU^jRifS$4gk}!`#y5p=T$Op%H5))*KzPn zdvEhASNCaU>!iJAT5yxLo?cwV__tXk7x&MnBi+TW2HLkJco&MrlZ-cf2Yc>Eom(f) zii~ZRj=;3VP({$Z;x`_tEcyg~@QHjiR$X1KL_~c-`{`?2@Doqu92NcV&#in7Usm0g z2VzxUF~A0hn7--KMrKu{(5^^*1qy=i>q`BqCR2z{h$FsmpE>U`v52AJnojOQOCc_r zMDdnXD!-V<-9sp;vd*;gb1f>yP$pMXy|E9f&M1RlE}NI-`+||4)jnF^#`)IJ>&Q5zjfEFJcEnq5sBT6Z>T}5)z z-l+!n!0pXt?sD(Bd4&cOuauRmW`o(PjVj#69`9AVdFl&><0=w?$f6LAvHbhdb*z$m ztDO(OWIZtIT@Ce#36&#>^t{qbv#T8*BrIU0_H%@|(Rz~$dlvzuNqfW+?Hn1;*!lRP zkuCx`zr;@hX?%q*JAETl`8NEMDMlqd@k5y83MjgfnCb1jXZvBO-OzB%oVupF28wUw zNkRl2XB8#Kom0bBMGjR>uV1zW)p{>Yk;c(qlTqn2iJ^HR++Ru{%cnl^7jg%>osF9X zGW^^Kd?)U7Z5N&QxtGnn@zijNmZE-r-Sx{jtB7NwTY477OE}(6j5MJdAf+@DlWS9W7B%2muG{bMML1HP{jL6Q=3F z-sKneJFG5K^plE{^bp}_gIBfuHC|Sl-Px@bS~S?xh;tF&2kMI~?~jRoZ-XjcTk@f| z60~I}Y3I6KIzEulW|+J`-RTon8WYaW`PDLiNp$^r?z=#_9V@v9s)NRw8)Rb0{+OXF zTY2mr*&PCm8jN$bgn6I1GI4>2)&`fBzk5I<>=g^0b#Hmv-9tDP z@pzXc7E>?vV>>2L+K4{sx*FSU9}Sn{ALzN%>p;yfv#=@YQwF8<0}Hvws%hG0WJpmh zn@bdC?eKYdYtMD(P0D*(>W2)sF%7flq6~RYRotG6kk_ZBKFe9ZdP}X}3{gNcaq<|q zz?I4iYjjH92?S`?0^>X>-rio+G`IJ<{K%GN(0JSQl{LfntNP`VV=P_mr*@3Kp#Gl& zGWehB<;sZO_R=q?MCH;4^J9-XB$Y9FtQ?;_vb`{pl4OO=zm%E=lTR2R%cP5<#8Rh4 z0;+{QeYXzJe?(qT>BoI$-OVum_Hor{w%Y&G@n&Xa$7*SC6laWwi657kX0%~(7~2-1 zzX2iuZuamtFITguPyG9evLB~+bL#uhzWZgd2Hah`o=-k$Ht>erbUJ6zN}9u}%6EJ` ziSKThNf>V(&0s1vPmMQDA&%s~qs)W-sed`+Lx*~4^!&D>eG>X$=h>VOVbL8wGcxTM z+OYeUwfkySFWuN8Ec-EukA+fDb*t$?rP!-X~08Vlh8@*SW&4=kxQ;gYc1zAQGhA~ZCT+jrU&xGtFtS7<%oJr_K z@Rb|>B;C@^aPSAD3U(_I8mm~_!gEE5Vb4?+h~sXf)aI-+zRsf(KdsB=Oa}_lzcY(` z{ct?cH$$(zYBS``uHm6|s*i%YBbgWKetE1$4qIrgl}!5bgmzc_()|j%a)t0V0YMUH z#DM`)m6l98jr%9D0Yq~+rB<70!U@6fI~zYo)@q?w=nm7g3dzjr6@p%e03boX_V?T1Dp_PT8=_zfet$cs($JPhIf#(M%loVIf>_xV$ z*}WZ3h%1DQ5chSKuBI~RN8JhQkl==oWq6yrVJPiy-X`)%MP(Ll4Sq=o?%=p>YIdJ} z_x_PDY16`+Q&Oko4>Gv#(#g|xr5j6Wwd@WL?&?SVAaiF}`|r-E2RkTn2q-DFSZRPUqk8T5x50Pk(WW z^bODOCCFk4(jEIEJMGEf$5Dcb&9~pF#7V37wqgSJ4|KY-wC>!1_D6=_-Wm`u>Cc+1 ze-J9e)bZ>mG`}AEEX#W(tDhaB_F=JxbvpZwa?Z6a{;p8M9_u?V7Gg<00ql7)?||jF z8_lv6lwCx7M=oWvC2|vb?0Uop}pm$d|SMJr!W+=Sulf~mPn{hH}+2MC^2}Y95fd? z&jb|D(Y8NI??G=!Cs1D`bih?Y4vtC{L#|j=X_l>q8Hfb8Fr;xjWma~bB&~gN zg^DAP{5G4I|JQOUozsK>BUsnoW@bf){fo7kVS6>(wp2Nnq{d#7f~h*uao_pdKV^1z z-QkKZEI%A_c($n0Nht!ctajgV8jtxPj|gr(R|%A0mNZ?AJxz7=W$ft<(8HTt@zvRN zZn)%f`?+>#lthd0ZMU!&Q-A_ncp-OBO*@-;C?ey;_yNj(Cm=JUL%xBAjpSU{Tb+JD zNvMJhJ#h|z-d`g{tGcAWnDz4EB6N7LSwZy69sF^)&-bw}QA6r2`bdA*BIkEa_8Jto z9ZdW0;>XV~dCJUIyhA8%$UYp0%yKB-CxWLRp-aZn;5 zzI@)nN7yHXBf%c>_USoh7oR3N?r4u7S)rbu47EL`W1r01*Y3S1e^790HO)TubIBtP zC6MI)tGcvH_~2Q`osp#FX~NXRd`6C6YCabpUH#Z%ke}j$3D0B=*x8s_R9GPM>OA;N z#xdYc+cL5@CR8ewQTv%2{fem(sx+s!Na}ZkdrjAOxrOzhka?^KA!!VdZ!(9k&g@lZ@G1(`olj8C>A8R7Ln^Xlis=?Nd|R@`h^Wm>hp&SG zPGDdJ_f{b$ChC<-E<6dTH{wxFsx|a$zn4G$Nrv@N$sGxl^4w}dMjshX0D0PVxj2oa zu=fq@o2VaN`t0y8fq*`drvsfg+o$rivP)NYxll6+bW@@Zf`(4~m1+8xPs3-kgM8Eu ztx}Uq zk?*+lH8yackI|-X>O;BNuE>2EXJK-B|NCDY{jNl;%h+E$U`kboDids^b}Xwl^oHcQ zOI|qc|2Vf=5O%U`nuZOfd;DaJMN)2F>4XF4;ELF379|9CL|r@Edxb;0;Ki{{c<>DX z|EEC&+zuxQh3SaE#YM!#MN#|^h&Tko^z^j#-v-#)<1EeH|N8**Aw@@U0P~pyZF_T< zhiE4}@bKY7Axk@JXDr$QFJ$jzk+^b;1tbEL<<;a0WXybj?^`2Kf`YpS|Jt`^<$$*U zpP%-fjsX|$C@3oc1OxOjkb)02F93vuga9!SG08bnGEy>-kPsjuragC&ON35ZljI88 zksb=udK!6ECbjqr0~~?r>{=t$b#ifyd0)cFEh_6gzJ5(xM-S^8oz5f|C4V{qkex}A z0*;W^9e@bf1&*UnyEp0koi<0LXurRy{9sv)*-T%l%Hw8 zO|<(In9#eo+eS-En?BDX8414Af^YwTX}Oah)QVg^L(|ed49zVpIcdL;!k9K3aq}cL zi(YPcr&t$O!|GcMTsYn}a0v{-X*0wy{Q1FUd$h>x;D)!<3|6CLCG&)slXfXUtL% zofB+qDqI){Y9I4_wgE$2u{#vV?O9jDlNup55I5OWug@I^mVDlMVZ^|e+fY+aof=qg zqtG~xI+XvmzMS<2`SoW!5hP-p<`gJ=xXAmX#Zyw7kY~4ep|BzUd}>f9l=|7|@lRFe z8rnz6#Z$TA8EbiuBg9J)-dOK?$Q7Pb;G*t$P0QMnkOmY+-cr8!)-+H5s3LrVM+mu@ z>;03X#>&Ga_f5~bt_FjGiVx7iW)9!JS-+!A%G@SuU}SNl*lpDWS1-HmlAY=#zP;RK zK6R+u2u*ZbH~Qp$bpOJH4~I%M0>*G-6cdR;Bt)#Br#uxAh}TWuUf0-&M0M7)$#H`-DWv2A`2$hoaB4 zlVScgYCRs-jR7x6yl<-?cX_)ho+LV+0w+?ZKv(i`z)t(~iXPKW=Nl4>O$`d9JA_h1 zl8&mE1={Gv+9}?4>KEz8J~w=+HD$xVTX?;5wJ+EY`RSFe%@b5h%3@%jLL;`Ngyafu z&Ulixu)hZbevgsj-4jmQn-)=6tI}|{y|6h`20t)Cv^@7+Ntf|KQ7XYkY{Qh>Yu>c>$aGR%lZUl?X}e}6yM-o zi8&WETiE8l7hJoPJ?LnfZ34hEFc7GG>h32~VzD(WQ+Ittyw90uB_eoEFi0hiAWsD|nZax{aNKQOy@kUP8E)7ae zC_km8d*ppP@i}IbrhEu>C=lu=Bo3aBt87w(g z=RMI@my%GVL@y)BE;CL@#W+eo;XtoUR%xj(n}(~SW|$2w@h4G0DDBNXdB$Z0B`wAN9tZ@VM{KiAkNb?sx~U%XSS0briSP2M&ctZJh!u zZ=Sd|oVcnVSRc+5ew6Au5j=T+3e;d)Ygm`hxtfKs)))0k5hz=AmtBjA9!huj467?X zieDOW9ARQDBM-MKnd7E8*&1DmDCukCX*MNTe-uSs_4ShtSPjYV4tW^54MVMUz3g?L zyJ&X%N)d4(y+Tgn*J_W@hcD_%mN{n3dyBD8`W#Z!YSqNsK02^7+F-mgzp$wvw~h`+ zo=A^-AGP|O0^?PO??;$QZZXat3x6mtoY#HO)V_B9K=a1oU2{EDdSDXC_#S+D{%slZ znYssM*bDWy^HaS48RcJVEw?;J(aWj@Txt}Bw8Rp*EtmGZK6T)wrr4QIftEh06V}=) zj;33}r$9oH_YuAVL{k`$@bT|YOFo&pU##CP8?ENv&0&)!5#e;I$~(vJwwf3hI#CUyPi zv-JgSsyz#$%wh-DW#jO9rzb*QQn})uGxTy`9pWVipPvFHP^s~kbR8bdr+{t$Np|!p za6LOYI(Lw-R4}Jm{{^#w+Ow<0Z^A>nlRN^!?sr~}@^M6xLM(@FyY=lYdnwt?B#F>d z;N#LMpxtx|#QY@WO&OCqRPlF|q<-A@eXAOM3Uo`a5^UZ*k#S!m7iUx5V*i<(l0|w> z_Nd{Fb?L10Ve7s}-k!RY_@uXZ_Oa;k=Dk6Vd2hy3z`%PHy0&i9){@Q`%4+X;-)7F{ zH96(Hr^iy(m+W`XA$xx5T-;&5%5j){ao=n?^?P6U{0_ClEl4LC8=DzmXs18ZW4!%`c0 zl&2hhjRKclR^A2Y3lc*_YqAH=2@979@vSRV-`3WPSVJBC5t#J7M|+>=ruxL^lCQBL zyvb!l9FuBbQG!1loBiS{Bb+i-rgA^|#9TR>dEY17)_eCSaICeE&q=)a6QJ*B0cx-7 z8Z$C3M_yo}XTtVna4)uVL_@TPSUCC$^*28ju7 zy34J$ZN}ksuiIb+|JR-@On0qllka=s;!jMUq}ht~p<&>SNt)MT$bdTUMY!0KtSkQ} zsabFUUB@A-X5RDudVyWXN3QCwVIKA|0j3Guy3L&H15e}yn;1$MFhgLgurNrU%U z-xhn>bDVbyWZU1P6Sl0pJPESRZDM!7d4%cB z{t-S;)uCH&*rG-aiauhs7WdB+&dbr zml}yajND4bd$%5GAJ0ow@Xs25Hf0DTVfQj%z5LE~p;_YbIl3!X9yo;YREuo%=OMhV zGPXFTzhh(>YJYW$F1V1q-6k-8)lA*|HeX(u4ZK#obI85)b>054cOp$iKQL?+; z#7nu8)OSKeQ*PNi>Zua34ZE*ac#{;5``%qAopEd2xih34-k<#9rGEML*xJHif@98T z^{9K_Hg+9$#dOS5{*fYcUAtAQZ_o@Mbs1mr48}`s`h>LZp1)(h>Tdo(`3@-`b;uK# z^!rb7=G<#5*WW)p0$nEQ#oKSX=}mtMJXT*(+z2X(@#uCi${TWHMm<@bV33wdO0O5| zxyQKHHAD^{fT$?>b&F+?>}g>j}_rUpGWVuwlUY2<2^67vDS+~?X)AH z3~x)d731R-&A$JeYF%EjrvP$YeK+G|>R%tI-V9YZq@wttiEX;P^mMv}>ukpO*JZ_D z3yuGJ{g=7rzta5m;QwDt1;)l|T(c_sNQk^FJm1jT!8}?h+>6*UR>tCZ#HVzvhBa3- z*a};edcWQcX`Os)JolO|$GqKqMY^=6jHT}ejY0eV`FFt+xU}x${Aq5Qq)v5=y>+CT zEn$`9yUJ^-IrYbJEn_`=pJEs6dQz^bdUqz?Rd$-{`2`t;Xs;a2b}oGa`#%{@Pnp#u zXk=gI#}d2tjp83~xmMiOt2XHh!G&Q$bz8bnly|;T0b~ zxoNKDNtd1}@kHt)Zk@(L=rdm1NnigWN;*kktmmG*-d?VJ&~57vV-8B?dOZXsX#;b- z17fyieHs_w2<5G0r$^RUe&_r7x)m;@pV?0O>n0#MHZv@|_oG#rT(!t{!+g4{>W-7k z0xGYS^|btGxjkC7mgKl6pFltQpp@|EyVZ7&lkea6Y@hSrT9946ztPz}SIs^{9LV-j z{NeI(Ny?a;M`BH>a0IOs2m2@LN>lU@}suODeiKOczrvPRB-O#0d0#pGF zgty*!pNe^>jomuua$iQxR*GwHe(+a&Z?#LD{Z|~{M}_|Q6ge+`OdfEh!GV5jS*+jS z-3PU)zrRNK_b(Mp$1NX9#A#%Pnq4H#*t%5VI&EoaH1IS1;eL~GV7D{)f8oWex<_)k z893oNmw19zzGKT?#=+=2;ps#CZZQ3|A23IR&()&ci2WnEcL9-J6n*ZOo20ww4zeDf z7f5tourbOFh^5WD5H{L#F>!5X93|7NTkutKIhK9l*0jx%Y1&9go0`OH%W`)PPK)$$ zW`z#^?~4zNo!#}NK0e6hE2+!!oM5WlJ#{bCR*$C|c~f>uF{AkTvy5SxjR>`$ zEsujsU&z0W=Y6>-J`h-hlJ40R=ow_)Q`S0a84G(|*Vckb;C*T7I-=1~JQ{+8dFgbp zm%zfhtqOTM2ikPebK=`?U<2o^Epzj9h-$3L=DWj(A^og#4BG?Q{K-%INpvS-_8#j0 z=nx^Md8&y*PZNJwO%Zj9UMEyeGS{@kFobZ8e!J~WZc@~c0Q>Zyv;~1Dc8DY@L?;KsFthbZDZNj@m=jZwuRFYo<5^H&8@mM*K{wag?*4vKPgc% zo?s$=Kun(6$3|D%kJ85TOgpJ~u@PuI!7c7I;YxGkqJ~-;m;ttxee8VibHaw-Y(vuZMx@n7uVq#%T1d^g=nk7R-GpzaRW()_)dUvNYr2 zaFl1Gd!G5WnHN&RdM%$CrP=&VlH^WkEW1;zx*bD^!UL?y@ei`)y|&%8=NtM{n8SEp zJyTxxH2kQab<}p~1+VE7*uDOfYg-;+46{a~_mcOR-z5hr7|RC_hpfaA6;AT6Wl7s} zImPR_yLdnncP6}q0@m`UDxTzQ?=jE*VqP!&Tyfj==9JX8@#`4e;J2Dun1+6k_Q?BN zb=&%7eV(`cuQV%lUTwSI^v&}H={rD!28vvdoo}vUUw{Gvm20b}GPh>mj<>wkzdmfg zO5@*4!BIV>9%N?lqhYErZB z-QPUNpeN@^SKc`DB$EtYczqp1wy}J}@tU<7iui#}ZINnQDXHdmY40XoHZmxXL2cl( zGzWnIx`&!eXh73!w4g$@>KnwagpT+6K{wMk){XvA^=7d!GWE2~>_|AJb;tJn9y5jY z+Q@F+sNODpa?;cmuEv@QLf}U?-{DQA!p!ihNU(!zw`?zT&U!Ox?QQYEEWShja_jV3 zlI_f_Bn2bh{?R^1%(9mO5tUB=M{}+d;zjHZ`7Xn`Tloul`nI&S?-qy)qZU@J2z3i? zHFgMl-Pzl|%J1W`)xQ~;mi@lRtnwXG!Y8SQDU<2^^87^$&k>7hzM+}MS?v4A32lE` z$-jT;$P+})_$cnx!aAVYQcGSIkZ3L%68U@pC%G*+@>s4tI}B5*>Z9+Gx?1bsUN^xX z`p{=dH)0M^i@u23b0cMb8QN-a@P$Tmt$W&-!BKi;S;!>CedWGUV%`V6Hui0;~xEr-0m&kTaaHexvn*sYU1U^8ebA_5TEeyS(xB8 zL6gJ{-i}ki=F^u)v3n`k%nZY3N7O|mYR+v7DkTme)oKFeCex;04LK!W-L31WlMQ#; z?-{1~YJ;t-J9B$0-MkRV7RIuQg>W)RWKXro4n8odriPjt~4y+$u% zw9!j+MsK4Nof&N~I{B>i-S1lO^RD~3{$1CPS!>pLo_inr*vCH3iqMH-Z}R@xR=R~( z3ykJ5m8kCVb5yEDv5YsovJA>-_j4rPZ2I+sf1&e=xQ=BP5bARRPv6>~Q0B^dNVK0&9uWCG?DoZs z>oh${pjJcBnioA?$B9f?ihO=t&*TV~xX9wC+%JFb` z)|A#W*`ai!8?yc`qN#+w%VKUtD7Rwbie(ogd}(4K_)&qgs109(W!*FlMDnESD#bE_ zFwS!$JxCswJgi#sP(g>YUFsj$_%!Ek45{gX!&Sxa{LR~WDH?Qh)7JEf<}x6ntD+YO;kwSRebg^r9O>Nu!hB)u}Y!s zIN?phMpks%8k z>w=+0C+hwY#!cRUT=}?iL=B4pSL)QCj}ol<)$kP0fql9yvIWU!>Ffrf-O&U9A1a!d zY?Ma+?CN`TW4(N;&>{s(0gK$=c>FFAt7x0na-AeRi{0zht>Bv#zVOdV3Jgy$B+*vi zN$Ihxqvssd_*!p2G%|^@sUqHu3fa1F1?oAN>zwSq+W9!1x{*FqB$N@^ zX5VA$A&7(@)5XU9|GNLPEb|Okzf8wJZ;C$V9ny2MG?mMZY>sL6CDklOuZ8U#BbZ#CN2kYHBXb!|qg?uO^M$kX%7A z8G+YCD_r~6jNz&9$c*zx_}%GMg$UJVicK1+>J>7U(NjIbI?$*0o6TcBTv;s=e8!x*x{Dic$tL1C5em4zEUOZ>N1exZj zcW^vsNqFfMGaaKO)r%dy>;fXS13Km(NZcHPSO%;r1~fTIsv2kCXS4Kc%1O_z(O+Ca z+uBc_GXW;`kW-9a09Q6mSa?4&ZPtp;CWF&1r&*$!8`;&UDU!X^+){ZW|G_YYt-S72 zy4k_@!;GDz>7$1PU}X>MJriu-amX>skw-oBxq?uG>^1i+#N_4Av1(^>XnZ!VtX#Pz*$h!R%02)e}CI77sJtQxTIgbt9ElxfRYsScWBDT z$2ECm3#n z!X8fd3sa#6DnwbQ(V$&FTnWoAWt+^#e~wt+8&xx;-z z!YT}VXE(cvC%`BjG1JARYAtCiBG@5z+*b1vDNQ#(uOAlRap zmaEX^npLeAr@exUNb7x7B|I((+Xi#ez`mRD+s$8^bYc%4=Q&h4czNoh8<(DT?meL3 zSyYIVnEKsNZlqbYW(f~2LS!1~iQE*5xR@S$Uo?H1vZF7N%XmQ|!eYc$OqeT)2la>A z;RyQOc#mG3=QQe(=~poVE|5W&>c1n@>^2b)*_awZtA~3?r3|tzIR;cjx9nMTlJ^IF zFRQUc$q$!29=ELC1ib~tmtn^ch(WKsN%?y~jXLCR9WAwXZ`Ztd5=GqcqEDBR$^Nw} z+SsGG8JUj7(LcxykFR?X*pQI$CX%ZOA=`UEL&#yGexqUB>^lJ^^2*F)KQT~B(=82( zK8jtsR8WhFiyl&=`*RZ#tlRK_D! zxZS!qp|u*{EI9OH4>FPfK_s&d5(2(E%aBvo)QT>k%6d^VSORI6peOI$J4Zyx^5<0z z=8$d`vL&k8YUnUMEhz7*wx9IU{DUz8c8}rkH8AZluJ%Q{;JxYCS5v?3)>u}JebDjE z_CqS1Sy+I#5~Y+bd`Ihos}sa^jGAK);49d*b{^ga`kW7mSrx}8TN#(01C1cy(njA^ z^0BIXZ&Utv#j20EX-!?bkoLZeUdyr!8y_cCiZZ`3biBd?)%YrcJs!b0nn;42Hdr@I zN3&$awxv}H1EN*TAB{HQZw1{@cBjNpY6-i6wxcUq!>)Gd#@bAISQj#joW)n@9|GeN1+VR{=qwPtfZKKo}&OR?W{bB=~`}4f1 zYi)rhv2G&lOCYP;)@1+rAjeaLC68cHlQ2C~2K|Ex@%6i{Gc^l-t{>SgEgHKE;_cBe zoK?4(-bE`2k(jzuNjZRE4uY5)MzYq07AN_Gk8`%-JMn4nQ3dX4m-=e?`ttyj3Qo%k zAE`&G1g|1imiP~NihDaZB>JI3k=g;@cc_^(0}T1@T$!c+3^H#@puse8{%VC2P7Sa- zLChP4O6%Q2bheAOG)jjbmTw*K9vWD>d5lHTMY>d67Pl}U=0&jcjt6m~*~|(pROTcX zf>;-#nCz-aOor?^f_P9c@x``xbCqYK|KX*qJ|@@*Oz79QSbFri$ink+21Sxr=Q=%I zrVo`w;GbDn3SBL08q$?@=7ujGi&P02(Wpc6MYVM%lA`-zb93!xmdwN-ZurqBL`pII z#^0>DxZfx@P!yMVhs%F2129^#Rxx^BuQ%q(7vA?<5b1UTHnQQ0Uq?-eaUL>Bcftuc zL!U2fUr1B5d_n+k7hmTU=sRXU@avEy8 z;Pi-8R7RZ18zkpk7N%lg?)HK~u6;wm#L4Ihz5ht-P_@6@X~12`%mn?-_@98L{D)jp z1G47K%Z{uLH${*kD*IFG_|qvA`fIfp9l-pk#~I}rmZQl)om+ke?_5ap9PV?ApZI3& z*vwNlZ*ukZb;NSQMCil96N7%w^?*|Y#ZD^=$#W@Dw8}Xa%VKP<8|0-;;kQJ&<{q-e zuZpT*tz^obC9ELwf`-h^bjQ94yc0k6n)aoVV52TQW&Y#5X1%9SP>8obExm1Q zf=X*j+ zB6B%UKKQV1+V9gXRBuLS^PINavz#cMHZ^e4-5NiNuhk?mxEW`>wSGp0!L;7aeK{{i z&<7hXXd;+9gMosx%-Ol8p&$VJHZNIct`*rbtvQ6&nc4>G}9X^RTB{oe1oI1nNRt69XA&~qq zrE;B@U_7+MdKzhujHW|7-Wy7Q3UfYNepU_`7cy!o@$6__PSEy1@22|tq4?HV{=u)G z{}Y~!{uysV&-dikX_}B+t^fL3e0zN6k#~i_&D>V4xoFUJ?Ctik;^FS&kfwm{qJAJH zME~yf=ZJ9NdXD&L>0_`<0m6(*vk#?-eW=8L!i?U z32oMEyO8 zQBhe}@~g=|<%xXe!Pc%|3cRXcH(py%{^IdPQ==`f8nW(sl?>ZKZ1MSv{>p<}=?y8} zGy(S_B5HG%(Vxm6evceNoZ?e?lUgLT*O5*g6X8#Ub zXO1mrih65xWFBV{o(?q5F=%oI9l>g_J{xR@bJ^*c*5E^;1#R2LnF`i1r+c(o0&&$z z^zw+d*LveFiw%a?_avSHg~{7_6cw9mkJE_HnWIR^!3vc3Xd_AuP&TR@g56SL9Y z^;=Vi)QL&1r;V$=o`H?cJPjGcc^5Gz(}Lv#9ry+0L2P$p6xkm1!Pa4#*g5@^iFq}z zrq9hRd9HRjqXd$ z=@>?$r+n2ccH`ogY*YzhT^EI}B+VNlFpH2N^sf9@bWHCDXO3t+h2? zpugi{Y8H_P!CBARF!I-g4uR{Qmb69ud*X=FVC`F@m+n?(a(l$=`&eoSffM;`Rw9&A zqo}{5(uKg?eMqOLvd11$#B-GTnz(QHj2D zL<^jtkG1`N#n{;Y2>R2wfW?FO_%tMKd0Ns=%6-SRY}MtgFX>454}-ekIEg=GDyfco zptIonFo~4ihs3%hda?bHs|)s%7m?hnrnZ7^ltwVZT$Ag2Uz26J_@Uh6Kc-KD8k$FY za+pK3ftz}MhvWfqO)hRG+l8Cb`6Xo?tCKpB2(81zWGzb=C9ZxZXx^axFM%LwUR`-z z;7^vd$>{j9VnJ0-ezq4ssnW$YxqQGXHTYZepsCX??TGD4!>W|HSw2wRjYn!)@!S>g=tJ6pgBKT|EVmOJ^d?N@UKWMHWTO1C8` zm4{~Y50kKMo9txwpxK(yEdzc<-)|lr(4)izuAeboEtT81XgfMq#2$wptwnOJ?I%V5 zD|isNf>}*6ye>bJz0VI{7vQ@w0&ZOQQ#-q{c`5hnFnBm15YwMK_#}hKZK!KeKE*|#+Zs!tLUFEtqo71Go z@AZ*^prmk_-D@d}XJYqUYFhd|_Anm39bZ)Tg6F3&^oQxp#Pn`yAAw6HJ~E2Paco&l zplR5$_#N z&y#hS++u5W|DN?`O2^3u(`B87_EIKVrP7_B>*Ad{)`!wz$yIe*=iM zoPrwjcEde53fI0S@`H3g`q1Ro{NQ2D6=AK~cm(jCev&&A(z6WGKuj&`2mB(E*YS-B~)dC1S?x_4MfeRMM;mt6V(s zTvJFDC8sSSy_I$Vsk?l`hc9lt=?TR#+rV8-E3uhWMhtOc#?}JU~G~B~xG5H5;wT?A6yDu9uptz^}R$C5Jy%-~~P;}1AcSk>uejuVld z3jq{l?ia1cchTM8#6pV)xbSsi2}tVW$Et_CK=4_|^0(ZCg9@4KD33w?`;URQT|Pv* zdaGJ#frrT`C+pH;RUA;+LnT?ZTwMA~?%PA6wuA;z$8$5PvV^0?X7CcIv!v+|TlCu4 zP0*o<3DddM{ZZ;mQFxW-W@o{Zz|K>VvIZVr%+L=1=kWe#GoM`Tdwrf{KwLCRC}ITx z#cZo3L{OiD$MiU9eFVjE+)$`ot=jbWg`CO2RY%sgo-dI)At(NOHw9sV=DYa)#?~A2 zHD0-5>T#UIjwZ-phkVUhuirK+2p>mFD9F#G=g&5U0!^&PtnphM~%#zWQyH%pIynv4*fC911eHkH4z`fPSYMZQcN-+)P0sjMOR+L zgAI$$W$xJ4oMv!dlfUamq*9i|^r2P;6b{dHuld=+xQ8_&%eliL1EK)b2S!8;ZL#lK(mR<{>Hj`>tLwu;q-OQ8_TaNI$P zMTnUqrJnMwC^}2srA5_>6(*n}2<+EZpMAD@#2cCmlJpF-Tm$Ye`b2IB)Y_5lm;Tz0 zE}9@^Cq?tlj-(zN|16?4C_757P2klK0l^C`ocrGNRg;N(fU<;kWrCw@vzG+!#>QSvL-~v968bF??D;{XFRPE3|%l2j+ZJ`0if<>U8-CV$(N= z_g+KBHhBMAV_Tb$uJ3_ZYheArw#bZ?tz}vvbN$}ee5(W8u6vFGv@* z8Q~|8cg!#ByXk{9$anjU!*hGdVSICi7 z5ZDxmmH#-owj#8GyQ6D)CI8I&DPII5m0T%-y)fZayQa>lPVQIbbCgF(Jj?#~>R)vE z+u|6~oVkwvZ2p*AcjGL^Psjt6C^U_qS$6(x@^!~RdAI-cw%qvS(ibgQnn$jPh%(T6 zvKK#|h=Im=JMsI!EcL_O#L~UDfR2}$n;9t3E1Ig_HM}n#|76_vr)8AZk)gQ}(|7xA@Cf*;--h;T6934+$|f`zdY%9*m}H@dqNxA|*65#tr@)5?-v=^m!x2|(pQDl+CaAygeKu;9=o-Su5s4Itl;mTgG1!=+yf!8YFV2SnBJ zuBX3@Nr*q_li1!(sY%uDDa@uztKB0^uxB8c@~_8|Y=LOf$0H$U?Nu9r>^r>rUqrWjT@mT~^&z{rj#2QKSqJTt7lQ=KSp%#?U~eA}Bh z?ZpWzsq9)!AFTR?VwlJ>jkeo1@HnsrM^DuhX~uWjSoP(d{*vq%2R2Aaku=*1`T2;> zJ$XwN7!Z&sAcnmNhOxHRJgNJ=R{d+1FLpvY*YPRXcvhda^sw|;(56A0sD%}Y&zajyQu<)$yQ`-6v%f6FcH{a)kL&nfddd4iAC#lys(_XWP8V zJ6qD=;29s?FZnn+$ED*>RaaajJ?m=o;OhK!CJj4c1?G1YTh17jAgMDYn|Mbyur7se zE%Dl`fj@|lBjceKw=CWfdv8W#<;L*-=5O5#+FLlixJNJUNyiLym6MKge~3@Ny0Oqf z37$>ue99*;d1m|5D5A)lm(jsF{EtQJw5kU^C^~Mtv=3`V+ewM`T0^&z4>}~%A#qPG zisqZ0$gRxPvc~$*vTSdDhY2}jPnD)#r@U1?huFDT%oIa5AKZfdfC19PZYw1Rd~kaQ z;#O5B6%Fc_*+fr-#uUBo(;I|r9EfV}Pz~(RS0(lK9kK}SoEI0i+5Eo7|5VSpVGEvQ@hrM> z#+giQTc$jEAzxsYB+uMT7i{>$eTc847A94*E~I{Rm`*OheNX|m(pO&?GGLW{Q~l{j zmuhhn>(Ro=g?ajNH4T>rAXo^P?X~tl+K>ONa*{>p=~7dyl#s&1$kwT{(zliFer=J0wrGk9gfyCz0+xgo*B_*43v8 z+vZ_wDu*8;M!ywp7@;37aHvhj9W5SH<+q=fO)IoKb;W~&b`#PYMe37YWZ$C)H(ZnV ztm~QI9ny)b1b?04obqHIe4X~`BNDGt<(PJ*aSuAkE+unC?WOd`&M-2y!xO^x=|_Zc zVcOwczo!rMHQ{2+dgqD1Me(6K7%hv4Cesq0YTO;15;~q%Ix#K8l4oLko9Jcc<;7)A zbg{YXeU~foojv(R%1PZq`B?>7w(B6&H(V;Lg|~Dny5jz}hmUptKHQr+ao~pgAaokp z@7H5gT(Kb}AWfV+C7(8$)GnLyx6-+1p`n=?zNg2CBvjk|1FBr&U5fIckm@h=TGKR^ z@HT@+Fvf!AR%iQHhZs_fbgK%}e1W)wdqxIc&Qp{{1L-FSC${+*1BMjVXRi+MTklVi z8w_tnS-@zRbkvO^`u{)w$%$LoTUu53HGYz2`|&}bt^}DJ6r$&M^@QX`0Y8?;rD!0{ zeSvZhS;*A~>ntWonvYZvhqGP9+|1PDN^qA`DdOX4`wS;?xp(p6mUz@UN(u zlb-qU$JO|kC{=muVa0%rhV%{Z-BZ0Dd0+-K-feNX_R1AWr-}`-Oo67S;imvhfmEg2 zc8`SwxRgm%A8&%6;;kN@@2d7Wqu>S8$jO3rnj|W@x)+>Mk_<^w-wEB1(oF->Mg5M_ zA>w%1t5}#I#Y|vorZb|{20}0R)nSsoBfn$bHnR|@gAu=~P`B@U&+|g#a#MZ#dpSb; z_r!o{Th%KVffWjJF0SCtL4iR9tAg4uK9?IX>6~jlH)PrSOOTI?>T>tAY|}=u+td40 zs*O8Xf2ax4ObG5XE}SjnY1W`+YBaQdHLX%ky1<}Ex)XouR=FuA_!ciRUw<@?e}NX_ zgxnc^1xblhLoL;et#BNA%w%ZSnYWsC%i>bg$C4`Ln@%!_8;i$L(^PzF+UBC)69uWN zO|?GNKI}dqC*tq7RBuAx?$~sX6+!z@$UhyUtKbKgA0{Ffx;pPaD33TBQekh%|7b4k z2toJ{EVC6G9sGDe>-t~1t7Cr>z~E!I1_HznTdlZ;`%7HhAp z#A(tyyPi^Y{jLjYuM@P<|9y6u=dCYHKpEY$lGlT|)4r@PKvFVw98XDWBX#7>+i#-J zRdx7`QUF9=Ci*}|n4L2yn=qfK{In19qwY<$ND)fYaNCplvuD_shJ>^vsWHQo7`WnO zjsI_bm-=m2tdOSsLMbg&&%ODAjM{_e)BdV93LC&c};IvLp0mieWFkizVk;<+->Q5!bQ9MgD7) zy1Rh{?n9Enm*YQ_RvjHN zxpfuAVv_KGX^2_aL!_!FoQaWJEvX}=mGWda5qwIa#(+%u+!7s#pH7?o`H|qERekob z-=x#8b))jfwc#2U2Lg6&pfk&8q-AH}W_MM&k~wK*9K3o(AokwO^&4GxN7Nx2zgU`8 z43G)4<%^PcwjbWj3*26gG@F8eqv{n@X=2KAEWA1z7bdsUbF zM=(XZBb)I#{3bbu*`5FT@V&mo*fMF!m@Q;kgGk+dhvqSBag zo~Gw@=e5bY3@>_Ruo?rL!@<|5dJngmOKZjz`cw3RMz-%1d~FHJK`&WPodJn+<12EC zNyh<CoW86Ru8Oo+MSyJhuoi!GV??Q4PlqmSQ2KNhAzp$&FtNHoIaDPC(;qj~} zRqts4#Uq2LASXCgthZZT5HD8jpyJ&5s**W$75o)JS#5V8VgtS0pxM?f+^=HorY4lu z-87RQ3jlYaKW*s;MwYC&C?*L2g6}$ipK_kdwl$5{{?$RiOrsgaE_i3pvpJ2)bf>>P zuU<*zAK3PPT4HYEV5_7!{y1j_Zzr>6Mlt1T&DnmA8hvke;+G~BO_0Y;=EFBPKcVJj znDL|*MyO&*jc(M2(nYy@$X5q9eVtq8{+tY0LinII-Jp@sq0yS70KS2~OQZO?aFEsu zA|%oEPF|Xb_`LHa0>c4vlf9x&g#-CDM{E{tv2_miwdq1X)*GaYj(K*00;B9$x59>J z2fSGOh64IuJ?n&J%%>=8gGaCJ>iyqll&0Ju-8m9wB}G%{aE8S2;7dgoR6#a)2$KUE za&%KuMNf;Fl+aS}^5k2>S;QAEFFWcKoAFf(UJEKcD=pB;47bLTd5LeNZ-8WOQ^b%h z8?kWWZ73CxHEz-_jfz6-j@3|q|4bUTva>c_RmiSi5p`hQzqF1Ln|Gh&aypD&IWA9UdEl>N&3S%GvKq5;s$S%Kt@DGA$J%#D@;ihc zWG^~~IxR{xeGd_OnLnhrJ^=-r8-tyMHG)%f($h?ZE0PY>X-^pi4wKnoVIOt1zZ$}b zZ>CdCaH;GRsvAbKQ({8lXZ?-+okoR0WgCb>x*yW7x%V^LdK*$0hZJ2@a4@c+oR+4( zVQbNZKTI~${o6TqdP9XjTFZMm78qLU{f_qzVWs83OzPpib%o`Q_p@ZOWO<)95ervvf`!MBZ5kf6hqOIgAOAt!we^Kba#U{j)?Zt2-nb}DJjpr zl7!{m>efIEU=-+6L=$7UF`LuX-A54(N~42ZPdf+`+V&H#vz_ns5r(K{CbYhmP8QI9 zyPW81Q&0(u#7tFCekt{x2SfPr%bA-#4@|jtO3V5ZbA`jxW7npp1#E}qYP!jpxDx^&g`7cCP|y? zZ&VG%?b&9%ZRfq_gBhG$Y=)uGkLL?ClhDVZW$Wj91kWYipSaD=`D<=|9q|zL#S`vSq1Obn^-Y7MkDLbgbggb^*op~);`#>Q2W*8_ zCy6o6QuXfrx6GvY72`ElQ;67JU+JBsZT5mz_t9E<6m?0@AhsE^A5Cyz)bl%ra4o_( z62F0b!p?WSxtJ0^T_;|y-;lY1EKbU>6fL_rdXMrL1Ym!D>QriMoW9;v({hIsl929# zk;TZSI&A9hxR|XMdQfh?HcedWw9Ki#@B@P7X5C+z(Y&H{S4vRPeMHw>e<{F%G$k*j z`=#3G(cZEm`0q{9BVf-8I5r`Q(^X0RuC`K@E5Kua#)?hncbJ&1zRsz5;c*g4d)+5~(xrErUR5za zRSWf7eJbSoJwE=}Rs}angSu2o5!oqhL!ZlqNlZu1+W(9h-7SWFJL0Sx?02SoqrowP zAwtunI#I8Hc<&UUs-JkB`E|^66&1&%f0`xk@!a?#(EAa;V6*;Ucl*o^4eG9W&RC2* z!A=3+Tu0udemix?asUH9~6C$6|)qUYoD7U&q|j17c7o zPCQw~mI7Oa>j(4f&3)l1QcB>h^Z9DgCuTNDkMy3a$FtGi*_q6TX83c!^*ao&vE(gW ztfQAjIPUMOw>R{`ggtUDtMu7C6^Fj_LT9MZCH0^YBazD->(BZs`g7mNq!Bq=M~FL) z0I`hs3YG1+!p7nm-NdvzDmPc4kYGyH#v3Awo|p0DeOlGZ`gBKfrxGrQm9t3h`)>ET zP@3Iy#pCkx(!UY`7GZV7y18Xv?KGg42y?{aC3Vh;G`-Y;Iq1f14yg$dH{ zmQP((6c#hkFV=@dbgu4|yZR}r0c^7|JH#X;F1q`eaN&k>~kfvL??&Rm5V z1PjlM@N@l^)M^LE5ec;(j4&}ipr`2g1Mab9pH;t{_80mfbTbo$XTz1UyFw1Q=$LN~ zFFJofbiw5FqK7gn=|kC9{1*Wyb~PhLqY^l$3+-)b@0ep^*dNZRxlpZeU-x3t)av}( zGcHrV3%C07F}<_xPs=eCNO{)EZ|bh99=1+hOVwH;q>tIaRYo39*Vq^J(7HvRaxKn* zb^wgSvTV4bOhTDcX^!jSjVx_xX+EboQL7uF?+b}zEw^}FRMM&S#EU7OF=AR~&mPYI z;la@kAWaZrYNYMuLSrLz*RzE+JjMS^1N>>!-J|vU=uw=b{d2mX)FVsCpe*AGGnb^O9Y~Tf%eU#T%QT(Bnk5^x(&5xeP7&-0=~w^E~omNy(||PdG-xLe)C%#PXik&f{TTUNdnNfn=^Y;ljlL9 zo}clACUP8#8kE_rNv?R1BB*ACNeOOdxKhcwb&7)PPN*<HP@3t_gMP)bojv3F4)eDZf3h2HvT>H zivk@(q$I@w++U{|%R=jV{TS6GM;D58+NNugNj-bCLBh=j~o7V zJPOjnz&?HCp_yj0W(3Tn^AT)Ku6|kES0)sHh!uQV+W0$C%tKZ~8Ac+5g|jTm*8Hf| zBz>%9IWjf9Cu|zft#1u^oZZ}Hrn^C-6>bqJ<~rs>HN?QoNClmvp#5>2l!QL5W_SEP zhSKiqZLqU z3e?AqSBD-&P0XNKb_wRT7f3$YOhFDFWxv6%3}-9!fQ*Qsiio@h8TTGwj*X61wm=YH zYGXmkf-ZW)iY(_HPimO%sE_)s+Jzp&WpwWY-Ng;ctbR27LIO-{JPdr0D4Wb^TS0 zmLNp=N8i1SganBeXw~uKu;-AOLjCMfZ~1PzFl+3Gq2>3}hu9XuA19M`jhCGvSCvSy zsdyf1M=tK^^q;;3b%lI>C`6gSKFwu*({i0n$and?sp?~|JC~JV4uobNP9DFbJkC+F zUg=zH?}q`m(pvfBJ;0uP)C?ZeaV74wbyj!1jT>XtQ(JPJMK^mwu~pv$*dqN;E%>4^ z1hmes3y@AphHuy^;b|U?YKiSs-(u{nkN=P4VW-|W>tvEF3ogbv6J`;i| zDN9>u{KG!|=MWKd)n5Xp<7dl)nX1)?>ijS4^vpN=`Ug-&mPbcv0opy;+i(LcE&ZSu zPNV!>Z`^we!{|EDLHY}-+h%%Uz3{UbrGUt72O zBK%wl;`OF$eum?NGQw;!I>qe_gZ84R(wIPZV~#RnZuAt*oH4mS9qR}#@BAuhrdr<= z-x_PI`EE9fip2_2&&~Z2@FE3oDkAJ3+{t(?Dmn=68K|XD=^a+U z{MKz+6CxDj^1-2{y7^*Y<)_;{2fW0ALHDl==_m`_C~Ckj=FsUB|LuSGCId>t?&uD~2t0b9s?> zc8&RnHNolS@8=@F+jpawd0o_KEfbD7%*D7Y@h*%?q+TD#*X#R^G}DCzr%KP0T<3P7kHd6RUSCJaO%LRaUQb6S zg*oh;1RHwLYrpjLxSN@M3*iPyz*WrBzp7JREb#_P9}hWvFD|1yo+=uCK$qt1v*TD#?uB*hfpvMyaq7pgmxpBYUJAhseeOqTliR=5i8q>hc&6NA2#$_Yb@iLP z$#-0lCKS|fWJHamCEg4+&Ie~ava8$K64ViAQ2i&}{%<3$n@<3Na&4~gl$Z$w|FHG9 z=mn3NneB-?##)&t?4=p0bro3C? zuu5hC__?rt5HLS09(+JDI`AdE;prc>z6ufU&GxF~N!@&&+5Q`%-^IKeL5Dg61$Xyw zuZ8l6@a7eD5RM-{m>K4|clp71)x@%3VtkkYsJE7^inKKWdGr&`?x&UNat^Q33i&n3 zmI}ka^#7^J`%^-@{*YaVi1;<;DNlmRi230Niv+a@ts@}5b(BR;N^9S zF0s~~g(Z?})8^g%(b58cG#qAR(y_KzYJ%sUWxRnZtk_a$;Bi{mWto7b z1fJd0P{_#_y4W4JFN)SCdg+d$lNS z%Emqm^B@HB^|kN`)5q0GNeYRep8)7|5duV>1^tmHu~jKw1m!gyf#=~Rg_jk`^pzBM zHq#lN{R&cEf@-R-@JgMML#{&Df#WFheh_TPQCb$2ZbHXEQFUgi^A!xU{mlmGx{wo$ zpLz0da8j?^BFIcW$~(q#;YfrR4r1)E=`LJA&7IDifa|e{xJcsV()h?6r}qx1J2j>P zy48{bg+j_=Z#@NGdr>`eoJM2=46QQi(#k8FQ?YoFw~57kC3aWBC-^NS4yugJ%T z*gGAl1Rp)ExYvd3<7undzI}@jMV>tQ&^Mm9oLI%sz{DB5CP+5uBD+p&Li>og6cur0 z4j{z5t$PuX@(Pq}#4pmAqjdF_`hTqb{!b1pJ|?FuDVakeDezjaxQF)uX8MGCN5I=8 zGwdz1A1B)3+o>|>MQ840txM*R*uiNe#Z4Oew(_Bc$4sNQrRcdCgx`eMJL6d;DVnfx zHxWHYsOjUo2#KPyMfMeVRFAKEPft{EqAMJzn)%W~Zq(?-Ltj*hN^>RJ*_8?n3vZlF zWtP`@=;YB|2cy_y6w-b!z7iU&<+qD1&FlQ~+WO(pK-L;Rce!PjJ6Yx#U48#6OzleeBe1V*doM_>7~f z$MvzHS44{gQIH+SRf%hzna(H*RF zcI~qFS#EOQmAZHWF#AjJM1VSpSY-EJd>7zat%sM;L02ovozAw&oTxB*rs>D?A+;!u z$Wm`T+8@mFPpu1IOhJ*8R&UU}8V%|qO#&N&DLqpJkUc@d$(p8?Hi$x?n3yu)aYAUs zjHwvNsq`?mwBb8K<)ik(e_7z{V^QYKX()56u3!|YM_mXBirstS=uBxe6k{a)Oo}7;P9pE-qeRepC?scppgTz)M@1Jqd1BN6b=Jl}d6~S8YBuaZ*W4DEBePLS#b=Mk>AqkFu-o9S3 zm-CvWu@SLYmD@AdZ}Da~nb9-9qZ%_|Ri5??KO!{e#6AVCCcxac9kU9YHEn#K$NdtU z>AJzPy0OX&*Cw9LXE^{?rcLs^%Bf`|;(a=oWbS{O1br>l)Y?xombC|{1$$H8Qw$|# zxq3w#hP%|gRk%?R6|dp(eQ`gDPgot|7*!9r76lPrPZ7K)AUfgl1<{M%_Ig6Vwl-nkcU;qG2ze8oc$19(oSNmaIiDku_6ZK;~Bd zNu&QqKsN3{s_3#|a_Ud4!B>e(??M$Was}=J#3pxOU2?`c&n*i?s%6s*n6RMDBHbs) zHzS+P%oJ5VJR(7R9;3#-rgMJD5e7#xr&pbe#DTyVYnt z#7}T=(WDbA&`_Rm1~qjGp)|9Nm=?>-1#;v86}EVJ94 z9=;}h|H9EPPU`q+Rx${3ANOcmT8CGQYO1I6v6~jH#;Jdm&*w&tt6^*@cTi*!0p9 z&0%gE!k;v3@)^&5|P4YtWl{eGXgz*F0x26WGmBYo{Lu^Q)A{{@#mSq~u zagdQ6CX0F`&PtDUS0vZWcwjfLW=#nFG+0+9Dg$7vIhfz(Ja%uLW{d6Llk5DkLDcF3 z2M#JnS}6qB$Y+(Oc%)=Z`ZJ9xKU}e(1@9?EG_v5?5W2q7!dM$TbD%Mj3aU!rA8oXf zYAG>ih)#Kmxd{0w301df;gcpE-Z5J_3B6K0XiIvO%qlY%Fx0HDBB(6S0+L8Fw&*OZ$itVLNw3phSR4uUok97NM~iRm?OH?WwUvj_r_!+!(uH{1jV}t!bw5>S z*hrZ!`_rYw5ixx)PBYWOPu{5amsi+5M@R_?kqPqOwRvyUFJV`s#@>_~4-fs2zrq0o zf98ZS-!ylvk2B9$io~!+2kdCw9K7@JFDPU%u`i|$|#>aoyh5r<@qz6q?4dvXL8lHDU*> z90zC;_#FS_rk!}=C%gjhur{y*vENU--z~*-WA?{QD{jo8(VO{A4y%Ik3EQ;FPNcIO zyoj*iZQH6*+JJdI!$`L-eXz?~%#aYAPHMx7z+zk86gPSEQtmQH|1^S6OH39J9>6#V zU)}L+@Z?=|&-j*q+ppA$D#+G`=ejpD>CWc(Gi=^yo!QTd@brU(BZ_dV4Tg?cfz8qx zzDpwLN=$?~?j0+JkW682Y3`$bGXtj_K<+3-D_$ieV*OXO_EZ2h%~87W49A6|V|+Sf zmCzJiPslCgx1d-Dlj>H(3YIU_EO^rS4i#$iJ7Ud8b=sZ69j7M^@skN{R#zk~^c1^B z5)|tUHQHOXL6!Nh)w9+dCP*>I>Bgc&49_Lvl4XY(deZ&-UXtG2OIbbBm@B)+d~+Sg z<)v6%y~{270z=nK&m0_%&(3$JLQ^~_R!J~UC$aQIRWa)~P9*qLE_KOL(h(Gqs({ja73oC;=^)YtRLYC@ z{`Nh4-*eBNvuF3+y?ghUf1cz#lP5FZ`Q%$>zBAK3zUw_uCP6WUy-6$igBxNs{Uj)H ziPwL1p=$x!Z=d{X(rjkfI4DM}xY})M0pk0Zt5<7o{d;g*%BRnlcAM4HwEOT+L)dN` zZmxma|9)H}dX+O`A^m**LmfcGcj_mDf8_d7>Q()9@-|23BYWl1dB?&tTxNmXK$o|h z#~h!IQhw8`)hA~;GbGx+PnhG*>0$2P{V_KNHut2to(^IVN!2h9d^$5ffAHPxny6PW` zs=5Kra3%fIFLx`^llh(%Ke&Tb&#EP)_n>_Z4mlRj=t}adSZbIpG%+Tp9~4hFzqIRa zetXoEUYFWt9UO5K!0j?%_h@Ax__Ea3^i%%GcT=TF*-wRJE!Y}V1Ql}!U;TRVc(m>2 zwzg#)UVgh!iP1A}DZne1I(_kprv5T(Xjg;jT&FAGD*|U+nid7r6Xtgwryq)|i^@=b z2}Z~cn2Pjzg?8hJ#Se# zASx{OR@}s>D*V99rRZ4jp*kxzO$9^l!-obE_hLtA%dZSIaBZac?=_E(3%)Jr(JrH& zn(FWhjFJ0jo)vlk_{g&D#gBj(KGw%qnYF}gd;Fs#|LqL4m`oPxmsr?tj@)#j_Q^D# z2t2Rztb4x_F*d4hU?P~)ci92NsFr+vIcaC-q3xc_w9Wy?m0ZY zSl_2f#P-301&F(q)AAx+!8MO&S^gcqV+H zchIKju4u<~o}=XdD01|Ic3}ixVb0(?&kL)Q7dAxv-EA2x#qY8%iP9Q-Dt_N15!F?z z^bREA7vQ!v<*5zh{A?{(!0oM(>^jTi2MHWl2>bB0uCR#1Tqu`Nx9ZK1$lR~&BVO%S ze{^+b7!2OB#rD4TK!czBPDZFVZC2FWt18vjEX!RDavAe#D)7r4aQ6K7hkCu9dGLt4 zCH&;B^>`aJQP ztZhl$0NisrZ6CWQvj#(W+8S5NTUpyP4mLJ6EOo-;76K)4z%{Ic1&)Hv0lM5K=XVP8 zKG6)w=1Z5}k6mt1#EZf-*MH0>K| zzeXfRtIg9E1RLzgUc59PPm$oY5YY9R3)C)&7PlTX7vKnzx-5%(B;k9vjm>+y^`PS3 zqVVKm-tUQCi(q$NnLsg}+s~J;JQUxiZu{H1Sl%xAxlPF)$|P@c+4g@vX$cN-t&G3c zy~yCfYP#sTRPec~%Op8#7m6{nS<(XM`QQAj z@{F7?NtkVCwmW8p&a@RyWNaG*AxReM@)yJ=yx{!e&)(6(g|-fiXYnW2#LY=kSowC3 z_YuMWDZQX<{gYakot-O0A)TB6*5g18P7H~J8DX~)>g!B+P-75MA9)0ShJ7qjWnkeP zpbI*WC4Pk;NLN}=y0WM1Xt6@L+Dj&wP}##ef0+5^wBv@MGeS&9Nw+&eiadDG(fhDB zMonRAmDKe@W9BzHBoW<3`?9#gs{j8K@%;DI^DlDc7+@doeYj~Sg6^B~p$!!)c!2vW z{M$OlKk^ni>7@U@C;n$FOMx5yE( zf>-_(xhL5FMDAa@Txdg5_fURSCTCz^AOoiVj>sKxoX^SXtwH&zu261axSEJn{ zXm7Qg-hWm6|I)Wm3*uj*XdOI!kf`kA z)y$<&a6O@nmNav=(y4G(L)NWk9zVImpK>(_TkNDf!8&}yT=<*M^kGJ+W}7Y=^haV! zL5C^#SuU^0X|UjBHYD(fI^wdj7M2?IIw$FoN}G2E-X`t1Y<2fOeU>ZyPa*z)MGizn zAQmnjfMhBvKXf2shv*7_C9qoWh=S(cBA4}_qM`pojli#tADQZG%I?^h$z|kK5;PH9 zbg&q%E}L|3l6AkMZSyAV>AD$T5E_zcsAv5OKl3zFfz%sK18wpU45i zys9Otg|E16-Y9G_Tc0Wz}FO!B6Q-{B!QyJ&CcRV|xS35!EC%2#1Imr{)Bw zkZfLDidP>AM6e--$nkN_!%+8N6Nai4+0q}>nGIQSedai^zO~tt=qg7>T36`4Q;lF# zjEzYhZYXAxmrh>_!|>6r{KtUge{L=QVOWoDeL3+?1pRIJ;BXs0e7W`I)*u+)TVE~? z{m<|LfaqJSvPSyR=q_u6t;>nT*Valb!+<;TVe-QKYQ7-y3MAZ0-vD#Ji7hrudMz;_ zAeb0vAtEIy3e>29TNENa!D8t-%-sA)2kJ-t5iOIz8x#C5blXE=&x@${NYDb-k)bcM zgyP=YbXhuKj9*9Y6MEaB{^xe|f1IcN1G(eB$ORzii*Av-4Ikpz|7y8gUvBYM%O(HE zsqO!+n(!f#hUtuvJ{vFf6jwOcX>?(b3H(}znLagB%Wy7in#!IiB@s$(tOQv$0Hclm zK>5VgHa&_6OI}OBAg@PL5gV&e-In1F9*e9FzN<4Qp6Mp5ss^~HKkAh{ga#XxHXzMM zt{hxWIX9(CUfc1%tCjzYe)k{9&EozGax|f${}QocIB&y;bNzxli@2|?73Imi&OV{|%tGA; zgjK_?+CK3WIqX`gh0XTANAi`O>6t0%I1qewlzEGe95s#EL9Sxh6GmilEczR%sd?wx zx2LY%Y|Pm1Zcqyb&oc{`)U;-}a+vTAcHP`ueY&~1Txk9DgU#G-PjvB4`_0W^j*?nr z7?y|p4!&XEh6oLM03@$#8>PO^-Z5cjX#7-{nlLOBQ05Yto{(bcTHrvwv+3<=WRT+^ z5}2^IQFE3KE$%`eN)h9VcYk*N+(+8?f={tgT5BW(Z0epB@GO>6!1Xli;758!KSRQi zcB=RBu;bOVq*iIo8WF;UherBHt|~H))&D7{nQ|iXR7Gc_($r&<5}CXzk&ucvb$jbu z_lWdq!Oaz&6?11{;Zx3kMGjjwSON^y@T?%pL|VZf-LJjEqlf~)U)zi1YYqX^+r^N8 z9#omi|Fk?rQmfl*GU-e6K$3!sM%1%Sr~~#9T}Nl%ni<~DT^wPzkL%DdbI)%!yT7`0 zH94itNRNxw2$8!#H$8p~+D#TsI(~gnksb{p35+H;9T^jO4v8daOUk~#ZX&K83la99 zPUo&=VSl_jI)u#kN_)L8Cf6~}K{O^2ZD^62l1fBeNKxT4|3?)ez=2jYU^w25MaGC2R!6RDv53ZB&E z_Q}lbbkfDQKvIz%yovOo!sR4Ghz9HquZWM_#hDcxoOO{*ahlv~p%3O#wOZZvdFl2H^!M;Y z*9CTWV81`~F1$6!=rf{E-Mar`j-fBisi`13-9VnfmyZ6SRFDuWwxTo=`Q{}ZZE3GY2Gc; z-eF@a_+(ONdRKYT`Isqz zh?BZz64RCC0D}}#MH6?p#$`UM&JnqOeX!uKZe1+h6I^});Iag=GlDvqliX(+l zd%3PM(?`Ul!l6E1bHTxdOOPVE*Ez+hF?gn3L-(?2h?P0F-Ak(kK zl#J92V2;3v`1S1ON{!Yt;uAA^PRS6V7+*=w(9;7} zB1~7UU8`36Xt<-ug!0sFqO%NFq4*9G*w{E!mT8)5z2OO7oVD}V>MEopT*?sa`Mwe+ z!ZNNjUDefYY)I7ly_+%SZVPmCuW9FuK>M{4j>*lNPa4EOqM%YWCyV+*y#8A^Y%w&ha}_TOf;dhA+ZOK6svH1GPN8e>q+vV;}gl< z@@RuZa3io2suYLlKtQc);c zERVNSqd%$J=NSEZeGIQE?%umk-42-8kvemMo7$sd`n42 zr29v)8_>na!-&IxzsM0`AZmA2!E-d$fosi7eqePH+iX@Hw3FOn66^FA0?y7AcY`0@ zNU9%Kp-SZrY7)EsvClkMGC=epfz;JFvvKu5*1r0 z$(xZLa;J39utCxd1Jq}8XQymq>3SE2#?QE;19=4tN(KA&bU9|dIf9?5G%>CNy3!V& zG*hoYf-;3eR;zNV5yD&1sIbl=q3^F!ipE2GKWWkRs0tb6t=&aGcKK>aKYUoJT&i6P zaMj#YN-yij9ZFV>Um|9eZ?QRFB})x?eObjl9cA!`f$xCmUwU?Tq{q zgTChI`hI<4-e>Q7`11p7VLav=Gr&on7MqVTPZ_45X<)d6VD%cpBXt3#6JhbRsADunfVvdiqFbdLfGAc7cb4^@N{sk#8Y@E_N0WipuA4 zmFGO(Be@F6wquiSTd()v$u4^OG-bLni4UX1gJ8kp{Z`NPQ(C$RR*Wr&97JC*W3XLR zk4;KrKXG;{jM4C^7XL*~003{@Q6rnD?ub+6A`V!y(91ev06{_B&{QC`bs<6@=p$ zQBVLW2u4aU>n0r$h z8Fz*=zx(I`7@CdKXNLtZ&$DZ>`(g|gETXZRqO=E4{^&Lwc03Fx8UU0bs7KhKV$~=` zZZ?x3hREHX!tB|FrQD32`pplC*HTwj3GG4pvR)UKoZgjD&3(@{CN9jWtIX5szBF0B z=XC9BVTv3r)X!Ak7fx5Nl6*%&>G8Qdja%F2gzTrP9nF-C-kY>X&;1o8ov7S5HPNw7 zGzOa{z2r7{B;_<9Ycw3E4}Zni2M@ra?n6&PA`~Eza@-&0PWg>3NixDp2CY^lNk4ppojtYt0B3?BWr5BWp1YO}FQlna8s*zk z#PImV&@C29haE1ysZTN*Y!cp>Pa=7PZa9$Joxu4XdKKS#Yn7)y8b}>;$u{{XMU3r- zo?m`yZax>ovUjWAgjH%n$x{vUmK|1N)aV9owVeLbN?ZY-H475&b`E}9r_i&E4eBrPNSHJ` zXyW=wBo-!YReTnXpFO=FSmqZS_~1{UOKwQsmU$n(=Dwm&aV=R$MvIE|g1hy+;YX$d z$qlY2tj#P!PlN>Te&cTEQ5#D>wQHahJIEyDv_Pf0$bTi4@A_u3lX(W0eizlJx@C~u zUIhSX<0K(N@l;CPA3~vU8)c>vflw$57T|_d#n!E1!~y0pEBJYBc-0T0APHJ*Q4K00 zztdHgOz{%-_R4*8g8Vm@etJ)AxBuWR)1jeTN|PAc{oGM61`a(^ zN=Oi_-Au8XU^VQm)oA3sKlD&My}H--{AZkiFrYD%u5;YZ!NO&HzUISdXZ|U54y>U!M)@D?H{SO1FKfTsl!$-f zv0q1%aYOPn&~8%b3BjZg6C_L4lsj2mbEW8g#8}z$%GhWvGI3F_dW*c3^U8~*3?7by z>)7Cpm~AZhnQ~UgQNAWpwe%Cp9**jA?{tK!q0k+wpmE0z7#1}`7?8g!u1*M%Q878M zA377yevvhEVtHUbbNAyPgRSqAqS6x-QhhJqT77Mh=U~&9|5nSjzR;zN8OdqBQeKQ! ztIqu3CcVt^W+l>{>NCbi+^s_Nhs}Iv@tCz?iUVP!GtyZ#UycIb{JE}!`Q1L;5gMyH zIr~RlU#!MRqHsIpQCNLgC_-)kg~3t)a3ajzUohaF1^0(w;e_4nFT}Vm2fd^-l1Cbu z^)bJ(*Lu5O?d)3}0+VCx&j&~HFMNZZqi%(J(b zEJJO-|7?$iif9VQeCaHch{qzd?M5)had!@M1{25O166z=cAOPc$C9iSjp*i)R+Q1O z;8UwCrp>a~gyZHj<^z0gJ(VTXI{`Tv4p{{|qS3Dg5(myG`nfKyn8fonzVca}X})J& zl`1QLwz$Xf&6$q6b@_3@!Sa!;&2=BjetG;y@Yju(Vq$KWeajaw*!Hp`8O_2E&$Gk} z1+u?A%`yRpMsNyHafTL!5)(%&NTEfVL5QiO!DUlp^QBUOY*y(almc=Dw<~+othdJt z-(2ucNeQPq4U0igZ^@Lz#mBf|@}(WZ#CjDC;NW8vs+Q_{>vg4Dh;QlSy%WFl8@Y*_ z=$q`f2iH>{>aT7D-rwkuo}q*~xq$kftX{Z4F)Ee{17(V+aLVEcr4olnVh|k=tZoc} z9h*%kR=B&CS^mhZM|VcX#&$1Ysq#il!H2|Z@)Ue6<}6WE zbwWi_)2x+}d3{~UGQ{)PA^YY}4G@ZfBwDC~M`%6Mcw&Hn@JD)dxvV<2PV%bMwsKqX z9ui>r^0u?0X8YhTcVuS{_ckx1I~Kn)<)r_**_S^9vq_{_8e_m5J@*Ax5cM*UQUNjQ zdTNXtk{SpH5bO*%EC4`-=>TVdtjVR3g*!Ml7^hw|r`&Bt1kU?gg z zLa|e2*E8IY%8hb0d-W}^T}0X@swcrwwE)V!=4oR=SPotxM#HfcQI}{qxDN{Op+%ux z9m?7;aSqM2Z|R2L^`!&~XZT)ko-OfLG|xOOw2pI(Q&v^>nQ>XjKX%?-e^^UBsiAt4 zt|DMzGclng7VOvQ`>cc{_Fm$>_fYfEdS^lE?3wR4UeNZ{hj*s&oF#=H96M zntPksekSA~QG#PkAyg_5s#`A*2r2>9h$AIIs|aEks2&86BH;iw+M7Ou9YEf>V?^A0 zyB9Iv;%Ta-RqTf-=~r(Vz3j{$n;_jj%!s{<4W*0r>zQ2O{%RM=d~uMdS453HO6w(} zB6)W|Na-<)43W_B$PMpMaO-rEMmNoDr)wO}yt=`_@X*rb4fN?|%7lwuoASLe$#)`| z9M^3&e57BI_jOPBnZxiPPB9&LRM?>&p*1ja0C307f&fTyEQ%fhY6mnS{(3({(3fPt zl&J}OAHvU@an*QYO6Rn}1fv;rgPyKKh`MfV>rgQIrD{fRrRsT9F{iNLyQ=2o+I-0; zhY>x_$20R^12Sc}9~343iz^omK&E_E!Ut$QGlsc<0PrINf}aQ?gj<&pW9eVwMs)

9+GI1 zgCaWIp%C;_^dnFo7-g&p#vf^{bA^O46*dghA)m~xt58ked5IG{#q3tqph6}l8vfQ9 zrZI2_42Or}VbLUuA)s?z^)2k%X$*mD|CB>e}W-DNdzhMzf3dp07Vf_~jl1 z*HK(u>6G`(<^(9Tf62)gSg#1i`Ya=S$O+a@0U>2zskkc%zBm}50g8oYM-u{^ zd~d$fXQr|T62X0!%NGug@3T;Sq}qN_E?arBdHpMW*KH`3*`;d@Sr`S|pRP9kSRIS| zWjgxB64ffTpZ7wss?D_)lL71f<2MRSiGogYn~^PBy=z~-xoefz^;L$jnJ2yc_@k^` z(dfQ`P-*{jCN;9K$K80^WNSI$-)B;Blu~J+s>MmVU=YfktY_qXYQsnIqo0JyKcbJm z8OX9|j@@SRv7hBpBTOT$a@WGGqu!c<=kV)v4DTb@dWtqJQ**JF?O>WpHRR9ov$QJW zc(kWI**_1gD9P3dXMOcF{A*V+03cT|rcqH-HDZd?bg5j5{3 zsg|wa>Tc0uYC%hnm}=htIY~XQXJm5_1}GFBGtUYK@pelg^SYjI-)++wbpBAn!E@GUll`vkzKC>mWaRApK|9&l0%v_x zkN(vaH)*oA>$OJn`ET{7f=!P^Z)Sd%C7!63y|TY#mTvzwky_>ulsxKIrOG#%dG_;e zRl3930k*)po`H0zuxK22ssd@2fF7g-v_go~!?FPoAS5pM))~Y`VuK+352`9>Sh?(z z1vltNyvf#sT&2_+iGr%kp7ttl1i!ml>(v13n=xfCe<#Q;^a;|UmBs$Nl~Q8xKu@!o zKQw!wU(M^aw)+>eoyP?(xn!T>BC|hMc;{8?#e0iAv>VdGm2^YV`ZT)ixY@qG_uijV zpj`uuYfC76NZ75S+b=+mO@@UQLn?subfMZO019yabR{+!x{qWbdrmY&x0b_L#81chv)%cjH{XREdz3x*l&7OnsPsg(Xwk zjyS;lM6eDmD zc_%Nl4@FJ531A>!n|*hO#zdyco0BYJddkzB9O!~s);Gl$W(_r&E4`%-vR}_pFLNe1 z!B$w<35%I#<6@r_J&;=3zEdUaz&(>?-FcSV7M|Vt&`VapvQN(H6o1|BE%b`%@k_&S zstO%#dm7>#EjU8B1V`mBaGsttbHez?ZfH7c$jx%);{RLIA4hI%bB@?c{8F9Uaa#XeOvz6IN+bEeqnSH41f}cLP#wrW86M zHT8yKj~cnRwD(nrHD2$8;cKd;Al^&GM7YZ@)>dxd2w4(UPsiXcI^fXCjGW7s=}rC6 ze=o!}nefQH^^!V{bSb-yx~QD=G#eJ`5d$NKU^yBBZ~z7GP{maT?lRvmf!^-npjMS7 z=s4CC`lz&d?+n0&j-g+8P)G zW;b8OP=uMuCgkOJiKYRqT-t}6=#LkM)hcR%8?%vGj8a6rLu|8rlySj__5?dMHs~m* zXx9+>t9V!k>4Q!b6FC+b3xTI1ssm>L!kEDx_NpdvW@W5F(>;paHEpAQ1vsR2u``CPIj zG_7zfmJ`MBS#>x;>8j+N3PrF{vq(~CHy+|4atliVjG@8CY7QNMPGnXz|3HG}U{3(K z&~A~HD+QT`E2yS+{q|?JOgW!BS=#b~H=HQcDpIJ%Fx8-}7MOD$Z} zik_Fd!DyG}p^}AmIFaC4JJ_p41h;d@ByImu~xC(n;J2c&BRVz(nQ2r6WWiVcC@ zDUu(rLd*=39J)7~J7?&`Y$GdF;(B;QRXj^;M%r~s{l$3!!e7>3AZ zX^w2vWC7{QOpwYe-psc7?DM=c83OYu-)7o9H=r{zmYzy4kr=`b?*mjjKJsglgco*g zi)!-9HFX&hk8p!b;k4!OLTQ~HlHRcHJLRr|=Wn_V#El4Cn`z%ZZjVgx%DeU=+Kdzk z>4~z8Z>=4j$#lAsZDbp$=K;wMWjs=}FYSV?L=($)ZvumKX1wIAlP7%4pvoPnMfY>i zYpu#4=c&cN$i)GW=iF)v3pD7|EnKD`77Nn7cy^;50P6{UX;do)^p>FS8eB<@*HXrGxoX2Lu!1{mB}X1YFr6@|5n_;TcLeJQEr>OX4urZuM}t@j0gM3% zO{jW?Bto@@LEySV3P?_7Zd5cVSzIAa-)u|J23t_0cyD`l=4adcN<6411OVz$P(pI@ zIAM`W!LU*!1L0Kk7qXM-OM7%IQLrCKn0bZqh3^P ziMtgc7gQir9WBbRDJ1WY#xR!Sc2rsIlW!6N3B1Hm+}lBzLeN?v?7ji0keI(Efy4tw zQA9!a(!)>Ls=~TK?`%D#HE+Yz;z#a7-+j)=S9l|2Kpq(IMDR{AZ@MGX3! zUhhSa_Xv3Ug?h(q>~G`0-5tESKCkZ|ewgJqy34umQoVfR^1b&A%6P-ZC-l32;_r;P z6ze;evfGs=zw6LQD?=g_16Y6rNY#Y%MSz(JJ36d%g|~U(fs`OLjDWP;7`i~~9;Zx| zozm@iDF)f|N-`aH&zAV0V-}}hXP;gehAYt+_*#o&s&pz$bJyHiI+ZJ}I%RB!ee#vL z=C+{A8+F^)zDr4Zvj(N*trJ>q`uRMThGgbyeA8UFGe7_Kbz3)(1KXGuYdQuvcP~VG zNE5*rz;}rND|;xJ3@9;b1x=hqV@L>Wfcr4z1$+U*gHiL-gw9F0p*o+QOw1?Hm=kG!&8w^kWA{U8)Y$s{Y7!?99 zCU6&q8UU(mIc_s204$)&rg9enc&AzK%0YDAV1G@~{LnVwSQ`Gue6}tll`A8?YUKA5 zwcDxXfAhNJ^vOa2Yp(2Iksd&e*cs7`W)gP{w|SE3 zS@4RgNoWKygwhZSL+JvcHRt{Q@!e32IED^~la_d#k`$((ge8m(l~@n;u5ji}<={&n zC01M#et@>kIMYwEd*l4Zh_)^M*HUU_PnL5jK+Gyoa*oG*YQ5w)sfCd4}Vc2 za0niZQ%ABOJhEs)A zw@N;5Sl=22CL8%U8GIdUeG%YuuWr10bXw=;{&w}7B|poXGjF5mh1hhHYrJYO5fRzX z#Wn58?m$%q)roGtq*NiDHA;Ceo^UWd#D)m91^}K+JspK4OiEGM$j2zoF8JLJDs)4{ z9KTJ22QfT`{|XsP6>cG%M4}u6atZ)0iOE}0LjPu?EG$9 zxW8iT)RsQ3FpNq@ws&-4+3pFij2WP(J={m?#rs$H22$p_#|`^Yj=iYDE_dR((k@jh zz*HacEls9Sulu3^?5eDg#%?>ITA0kNsoJ8gMn-00j(fWYmC=)=Ant)hOR^XAGS z9Q~9^HLhF-2{(0n<(HKslUkx~IQkBTVX68~)P{u8-`j7NcPDS`z~4e02Xh`I2*h6q zQF~zqQ#qTc(qebQb*-qH!)g;t3Q`&u8v0i^T!++P_G0lN>j2luCG;&*a z5;FtV9v~6?=p@e0GC0lzNsgN1D`JTl;=+m|)X*1?urNR?wyowZSU)qw#OB=cat_-J z3|jKq)D3sl6)GAf%NP!Ss#0$n;ib~6z^R-{)I){?)NsPw<$(5A6_CPOr*%Ko^t6YK zs~uDnbb7TDpJY6!jViUt?k%)uh(6IeKRrpBx7y5M(J{?4z6!ZabejnFyj~;6C-JfG zP{~+(`qtCib!TqDng6_HxzJ2)_VsC)C4lWCwZ1Ci{Yjknwb<=n&ec*lT1F^BUXWZ5 zImIs=35ft_QT{%HCJ7sr5$PJ8rnvTYI2n5VMa~j{;EE2Bwa~1D*){9?v1k$%zUI{4 zb_x?^wjKNu@1AIxz0GG}K5c0miMlc%#&DSbYw&cYHde)lp@-%q#9zuLD|ZB1*x zy)MK~;#S$}%yV-${7Mkl9zw@D1gkH6!E-2QX_&YFey`(pzZe8L_f6)cQ~U zu6hZVji2Qq%(|M&&Xz#Gn_2nq_VipCeac~6-IkD?fzQ$}*dQ+I**6p*GjsA`Wl8&i zpd{hO>LX8{%rS>V90iUIge~(K+r%vCbI#YIynb)YBl*6Q8@2CJftsBz*z`I*xhjr# zLSJXsyUNA%!?;Jq3gY%`@X*TeF*2#naTwu@ja*9yqEueL!kDF$WiMM*FN!e%B-SLpbhzhO==q`qUBAIS*uu$e;!tLdFl~iJ68rg4RYAly@ zPRej|kHwE`HfcR&CU)ZLm38G;*QO8VarqY`rMWzYsvP^AT7 zck#sPnq!jcDsn!J;B@ zD=oqCQ-;)0Quv7DZK@2SMweG3be^`BE6^I7LhfD>COO_pKh#m=CH-1nuyg-yRU{M5 z_uJ{uv!a9)X$&Z zFO1ILQC)qQ_FLp_E8O10V-#F2dGe?I`_)azpT&YKs4npC*qb`V&4StE+wZ5}etYQx zkFqP!fV@lDi(Y$J3s|$JTNEQM5XS(OqN8CBjR}sE|0XQEu479)epoGe>$9@tCP@j1GeJOQ zza)Ug2tp(s-Q3EN{x_>MrDyebj4tE#wM@d$j4@hI&HEXz=j%A-XzSS)qXMO$W-`Fpy}fL9$!q;&FZn_<=1* zYiyWr4J#fxb6xHSfk0r1LPZTw^d)YYAPO-OZ3@|v!#T}y5 zUbxNCSqc@9t9-{e5od$Dg^3o^ z_Y<&cvuAis(kA9ocR0Mt%LrG=Pr9uwWyh!YmuVyOO@e8CwKN~hewD3x4jw7x zH#?n7P##~Vz(M%))7|7c74IDM?}gMhzrMa_u@yUq1Qfg|J}V)=;Y|u)B?%2t+Azxn zAh*E^6z=3Ap5S^cL1HVGm7Nn7S_NXg74hUtl$9Svv-q=YmOJ`^N6ckiTof$RJOu31 zesTH^{SVx{FX1O|8k#`{b{n86A%&m(l~Y;S{HEPwXD1K$HZ{{X?e(O}bAB8;NS~hw z$BS0_8aOZ1hwO)&oE&;J8{TXG(;O6E+M|IbMy9PRbXS$Eps2ei1?){zsjX%lj&17whE7KQ8a(kuHBbXk6}iD6`7g=Up!F7r6)k(tJlXd@-Oa*@g?@ z_gGwa+k=&FeNf((jaBYTd_Zo1(berv%mFaS98z? zO)`_UU!OH0N#|rv-H}A~&M8+_KZ}Nv3Xg*-8W(awfmkv<*OgfPUL?z0J2jeBJ(ElB z8b2g*&S%^$rxlR>Q%&?X&nno9N|cT$4U15iH`Q(k7AChcj{P>Uvz9aN4vffAbvsVQ zh0>*2lKi!Evzbt=e{o~=tk{GKXXfa_fmVQ?o?RiyuWaS6tW{aMWMArN4)%cO-y_=#_42&_;#{p+Rp=Tj>@aIiLsWbQ)11+U)H`i4}Hg3RI`>`0ENkK zd&JQ(ZK7f_L>-ARFJ9tVA?o?2Qt(_lRP$OWUMJSwp2rvz)KaCYcIBmkX+J}$Z0#@{ zqplL1wV(TmkoL2-eO(oP_)BH7oY(v2K+XX3bLNTgRz<5KCL;j>R-G! zXIINtv)4T!=z`6vxs}t3Yagd;<%NrCj{}o4T{TrSJ{1Nhiw-A?6}Le2DV}>d={@lo zmT@w3mrqkIZ?a#xImF+eKL|L;x{>I-bB~2(ZO8(uJ9|!cS)jqk8(;qFp{>R{kCgSt z(fu9uyc9G?)|>gNN3m2&)eL3wKh!7~i1IkBZ4A@Xh^xElA}{_l{zdK?0O6*%HLO-$ zu?|kR>2emtA9LA6K4b7)xYw~hB0(jp0HDi_aRx&JJ>}hF_HDKwx&KPD!UJq{IfW51 zAUTjbnzfZw3DYK+c~()lg)IgzmxZAesazyH$Rcn(=MfaiPu2}9WFW*&a=!x(6{mv?R?q=lLv!|)3wl)`v|KS_ukd>li#(SdU-Mrip)&J~_0XNZ9Rqr9vekl*u*I z0q6sqfDmg6Y6K+@MmVOy^MNF3Sx3wSx)eYk^J6-ju&_|7aH-J4-tgdGmSpm1t_;pT zNwA?esUd30nnH!Z){wVH(Li$-D?`7&!dBNg(t2(zwJ4jgPcS`c%#Z?ZVnAHo-%PSJ zJ#gl_SLQ@DECW~TtYsS;8>+hBGObWPp_?PCKR)DKL^3jB`NI0cpkeLuO2tKp@2)0^Lu z8fJf+vn5w@NS5Ut)0u6$oRu1zoiN@?MS=|qQ&r5u#nfUBxv98-%{UxXBo}~kH#_B4 zg<1=#QOn8`oe%bjiC84Og^}UGh%lY?KTHe1A$8$UD-Z@M5l0f%XK{oZ$v~FlgxF5H zqpHkmG*RhHFhIG;s|95suMuYTdT4V;A&GK6!N2z<4UqEr-ksph-&&%8fA<&TxGySG zy>L@a3>4kb#0#hBvhA?IiNg(b?{vp`O+^yQO@EtwgAI^#t75Mb6^RiuVMzVxAlZ&v zo-rp*!GmC^WGQ93kfM*|q}KLpH7CKUFjcdPyELzTtF23(JCtr)(rp!D?x7z?zh!@W z?*g=#%ue5uhog23Kc;J;PMlBLW7qs-W_TPpyk+n8he&-Rgn_A@#HH6`KU zOB+qF5`5&sc|Bsso7z0iQAE%EXPpXa7Q9=Xi|qN zfWbgafiPkamhI{t=Hm(t#?)%pUws84*9&K3jkzuz=QlZ)x+RJ(eEa7=Q@#s^QPkpkvv05Vskc z28ufzw;7sYZW*k9eYp)Y^VFELul-wXUxO=NPdECuwO`+aT)!3vz{Dekj!jDCoZ(~k zn-ow!g-cr$ZWQi{s1*%f6fR00EsM<#S&^jzD?>4GmMjW619LQmb4x$l>Sy}_fo;*kU$Aj;aeH1;}T75$^?v=Q`H*1^{c)E*X8?Jvr%)uQ` z!TB8on>p(=8SuRv9>xy*e({hb0Dgvi{3Xt5jdEWxsWHW_7i{YT&%H3e$WVU0$ftg7+Wqq?hlk&^Yv<1%d^u@Q8 z%zrORx;}ouCBu*}E(Lo7^G|0nr4W`sg}M=P|4aa!L_(SBg(j4cb^KmB#X17J*lRe4 zvNW(mOcXI@AP%GnVBAnikbX*#v6^P7LJApuLT#l8LO^5T6F!%R*1S*F-=fxRG&)&-UW$~ zkGh{LgllddRJpY$j#@s*cftBKr^cVQyl4lzd~csT8IWK0@@Oh}`DS@De|Z}KV8G{^ zDa``Xh#@yT0x$t*FGf}qPp+Y}0xH*Ki1x>g!p2yj#jy$tABn>VkB9K+HDa;eQ!mD_ z#j6$m8ho%}{CMtVbE`vCP5;9`fh1G|?b5TOE`p+}546=oAfEqEd)F1!)Yh$cLI?p8 z2u+G~0#X75M5P@KJwQ~NfCuTJD@8z1I2uBSfb=Fm5}H9N2NdZT1S!%&v(bwIR6Ho6 z6d^aBJI>>IzvGU7KCQji%baU`d#<_m`sSR1*H6g4(P8f0s)@=k*f)G>gv6xY|NKz( zsJ`Zp%@Z@9q>am{=MQEMrVnl8$_{omkHvldM>Q>1?oc5c2h4|K0tz4>j4sqY;~3@w zm`LN30pfy10Tj_kAe{;j>Fe4Jd}+R&5A(fBZZ1WRmH5qUv;4v0MW zUY(YVR;m;0C&`~cBoU(UJB~$O3QkXCK`!=+O>mmL5Nl(-z+aKj@h7M(@ZGtsIjLfo zBbl+2N7~IwZ*F$UJklMn^?@aS2x_JjtP%m~X!z%}p99tA_~Kl}!6bV!p$8SmNoF@d zcW-f%AqEf{gku;&i=^S$XwncjIG}vJjJwt!QtCM99^`iA>8a;U*PLE4qh2Zb2QeQl z*njT|Ptw{Sn-6_fT}04li%H=!RFRbcrB+%5do;e3ju*6$ykh<2?AhGQrfwE_jFXJZ zE(Z~qnY_SQ&Ya+crr$;@6i+6c%$6c7Y1&*Um!lYJL~xH26*0V!Y!0j%MgSrJAVo0< zP&u%O022Wio8#9c%nclzr(r7(r=h44SHxmMR5DzC^cEK+j_oWU>YOA(9UkB&-L~EN z^ft{OAUD@Nhs|cY=2pJGdbBdgRGkj+4Vrv3T=|~dS8#X{U9vD7^?AH*&vp9fL(o>3 z)L)Wow`pvOi*Mk=i5NZ86yhu2HnvS|Y4~E=7C+dO2!dqVgL;P5u9iA0OdvWvKy1Bm z+k&rs(tRG7);cfXV-bOhVtDfx*?4Oz=n0tcjocqSVZtAm+D8I zCJrg~0Ccbxpv{wkL8~HUhT#kw;qYfeG*^pi66uDnaR(pe> z7~3$6sSA-E)qegWO!}5>!>liA`>k+*N=KzP4Ca9|EUJbVsvT*z^ zd7EKz=K4|9wVKy=JM%eP5pthO9*X>d)8}j)Bcz{bG3QbOf@L*GxKd>gF}IjtocKY2O>}Jwx;{)@q!FU z1$Ytum_0=aE>?_+MUsiuFphi;i?>D*UH}B_4n)hs`clOlyDQWQjz7FpwO8aP#+My( z3vaEZwjMF+7|um9yDK5|$q&u;ul2vY>zGFCJ4d)~e$g16#y4oF*ITQ0^kMu2WpXQI z!`vtg8BRyv%bO~Kwfz=rrOyinzPICK;fwv)AuCsM4^)wkG9w#e!*FmA_Pa6cOMKDF zQ_xL#L%YMd<4d1DM@3zXI+r#i!_n6M%{uFhZRoBU*TSjBLfe6)J==POPa$F460HTV zJvqnzKEWvcnzpXb_4sFV2RvOnK1mazI^RTR zk)yDG623Vxsy5;_Aqg7Z z?Fi@R(?xH6MptS-9{Ej*rX5aSFz>gkv9(@66>o871Db``+~;_0+y|_2GSg8ZeE7*OF1aZnsBl+4M)Qk_JI%#f%lqV_~TNl1ESnHoty(kAsR8D0$FOuOZ# zAZ%&4kL9U8%zMnzh$V&1`P~%v4bfd_W`Scpn3R*Gjt3Fxk#KA}$5s}vd7!?`h~w70 zJ**-^7{N$&UPhy=kWTq!+wscs!euJOoF4B+W5U<)dI7KH({uETiNd!5Kh&J4_#N)~ zTMzfe#3K?q)oQ1ihN$n`Lw`MYndfaoD?N+&{^Q0$^I3Hr=0d@@{emMN*q+D0rVvb< zB8$dD;T)t)H+W|N(0hey+=0~a=!MTeg2G1vd+jD{`4dPk|JiESD_KoZq_9s>w7x*G zA*kSB(WcpKynX+0 zj|fA0Jqct8_MrV%GZcJfy1-~F7)pdI#xEAbR=`VKM1c|j1UK*-`=L)~-$WMqrx`{e5B19%v>MEytIkgueq$F06N579xzrEYKsvkR0m70eV4yJt(xkZ=)aQi;izV5ww2jA{|cgK zWavNRVCaC3Hjhr-cDx^OB-h7F^C|rK${6#}K*L zi1uX8H~lC)k>3N7Ob}-4G~N&T$`3*1M|oaYZ9@NGI=L4)fu&GNRreMk0xx zF*dy$A~p5fYp1aAo~=+X5;;@dPO~#y>zK&gM()hWK-vJ`fvLhaVjL?8bd^NDS7MIx@9D{+SJo!Zh$^Anjfx2b_x*X9pRE5f#M& zZB?O;Nj?4aEy)i)eSw38Ps$yu2ecWU$1g_P+Ae>dICCx_2jdR0o0UKrqi1EIy{S_M z-R#Q{nYNi7d}G;_<9<*2S8wE?D?H|fLSBcSg;t8@2il+F9$4)!(E!%Yq`-ecE{(+r zjW%Or(dCNNleT^_G*h@GfAP>mnpXK%5G_Scxa{;IKB=tC^~ASx__XC`^blz+{9}V8 zdmkK0!D2{@5H?sPv_f^K@>j8_M{YL|>>ySG6q^Ew$a6&+!_Eb$Si3)mrEtf_#%b74 zvMh>6Ym<{++OB&(f4$yRH0P@KX<(y$m&=Lx1;P$obcE|)jEVae50aXkR=Shz&ZA9+^#_iC?+Q37%K(H56g)vRb&@M9qV192D2@7XWS`z zdF5!TirnothFYosEPgu zB|`?dM3o@OEkhCWSf$uqt$1P*t&|haON&baa;&7mVubVceH2>yu!dPX+HPjbR7du$ z4M#}X3rpR}g#hy=WBP`X{XU_DV`KXFg(uNA%-W%kx)%E*mc8;?J-;Bwx?*iQH=ueG z0A~$~BI~6AR|^A^R#yJZG)}QunGT@HZG|7gB*!S0w>ztY;_d#NZQ5-7Bi8gdqw`H? zN@Eu3DEmg|{nvXBn9Oreo3H=?24QJ#7!f!W0H96Sm@F{O7f7;tCys%Eh^6|Di6IJ5 zI5)8cmU%s606>bUQRe_?fy$TJdx{o=My2OT{#+lGlhwH0$byi*^IT~GwI|sOPKqx_ z%NY$CXU$#G=@_1M=P1qR8IM;ibFR&;nQ-&f84T;^ME#bQrOu;bweRh{LT?!m5#vWy zuUeK*6}v<@Ea3Ccgbj}jC6HrCCQF|@oDKLlWC_=pKj~ffs=NzBxS+7(RgY!ApgjD` z`8UD_arxc^4tn#H34H&)_>!PQWcIr;2+AI$TW1WXsBFo#f2O zj@DnLTimQLB3w{#3=sfTUz8?bGeifDaf=#B07MuU_(e-Hznfbrm7B;_XCn^4!H8BZ z;1ubZ6qV|gm1uB*+gG*r_rc&NoH1BDfxh~P;Xg6B{npgEcNb;2iTrGBN^X^rO`9xFY~YOXsYYcUz<-Ass(OT z6$X!I82qyDAIlHH`(cy#mUsyA%y~cI)INoW#%<2#|JYsRou*{~nGd{II%$l&s9yDc z)!I5atS!5sP&jpMl^isd=cK1`>xk_F7Nu!sWi*Fz6&TF zC}pD(TzX7P7uvm@at}NNM~D7hkwk@Y@YW$3G2`1)Xi$w zcpY&y)7HJn{{1C?_IN7AJD2L8;N6O{pLPhyZN20o@&Q^BW{eTjmq(E5^yMYFT8tx2 zbP;f9iM+0ShtS{8X|#!$YTFJWM-_BbtOfMZ)+M+4#fjKwq(_L`2#GcdivaHkcChY2 zpK`ZXJx&^REy}D8e=}r}kz#_b;RF2TeR;!ABRz?ipL1$45h20=8M8}Mm6 z_zfD8B}PuIvW)&_mktjGe|k<3B=Zp~RJiRzuKyyM7!9p|nfl+?V7~tW<0N0n diff --git a/core/artwork/reader_album.go b/core/artwork/folders_album.go similarity index 63% rename from core/artwork/reader_album.go rename to core/artwork/folders_album.go index 58a47c8ae..88f0181a3 100644 --- a/core/artwork/reader_album.go +++ b/core/artwork/folders_album.go @@ -3,107 +3,19 @@ package artwork import ( "cmp" "context" - "crypto/md5" "errors" - "fmt" - "io" "path" "slices" "strings" "time" "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core/external" - "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils" "github.com/navidrome/navidrome/utils/natural" "github.com/navidrome/navidrome/utils/slice" ) -type albumArtworkReader struct { - cacheKey - a *artwork - provider external.Provider - album model.Album - updatedAt *time.Time - imgFiles []string // library-relative, forward-slash, no leading slash - lib libraryView -} - -func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*albumArtworkReader, error) { - al, err := artwork.ds.Album(ctx).Get(artID.ID) - if err != nil { - return nil, err - } - _, imgFiles, imagesUpdateAt, err := loadAlbumFoldersPaths(ctx, artwork.ds, *al) - if err != nil { - return nil, err - } - lib, err := loadLibraryView(ctx, artwork.ds, al.LibraryID) - if err != nil { - return nil, err - } - a := &albumArtworkReader{ - a: artwork, - provider: provider, - album: *al, - updatedAt: imagesUpdateAt, - imgFiles: imgFiles, - lib: lib, - } - a.cacheKey.artID = artID - a.cacheKey.lastUpdate = utils.TimeNewest(al.UpdatedAt, al.ImportedAt) - if imagesUpdateAt != nil { - a.cacheKey.lastUpdate = utils.TimeNewest(a.cacheKey.lastUpdate, *imagesUpdateAt) - } - return a, nil -} - -func (a *albumArtworkReader) Key() string { - hashInput := conf.Server.CoverArtPriority - if conf.Server.EnableExternalServices { - hashInput = conf.Server.Agents + hashInput - } - hash := md5.Sum([]byte(hashInput)) - return fmt.Sprintf( - "%s.%x.%t", - a.cacheKey.Key(), - hash, - conf.Server.EnableExternalServices, - ) -} -func (a *albumArtworkReader) LastUpdated() time.Time { - return a.lastUpdate -} - -func (a *albumArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) { - var ff = a.fromCoverArtPriority(ctx, a.a.ffmpeg, conf.Server.CoverArtPriority) - return selectImageReader(ctx, a.artID, ff...) -} - -func (a *albumArtworkReader) fromCoverArtPriority(ctx context.Context, ffmpeg ffmpeg.FFmpeg, priority string) []sourceFunc { - var ff []sourceFunc - for pattern := range strings.SplitSeq(strings.ToLower(priority), ",") { - pattern = strings.TrimSpace(pattern) - switch { - case pattern == "embedded": - embedRel := a.album.EmbedArtPath - ff = append(ff, - fromTag(ctx, a.lib.FS, embedRel), - fromFFmpegTag(ctx, ffmpeg, a.lib.Abs(embedRel)), - ) - case pattern == "external": - ff = append(ff, fromAlbumExternalSource(ctx, a.album, a.provider)) - case len(a.imgFiles) > 0: - ff = append(ff, fromExternalFile(ctx, a.lib.FS, a.imgFiles, pattern)) - } - } - return ff -} - func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, album model.Album) ([]string, []string, *time.Time, error) { folders, err := loadFolders(ctx, ds, album.FolderIDs) if err != nil { @@ -169,7 +81,7 @@ func albumRootParent(ctx context.Context, ds model.DataStore, folders []model.Fo } parent, err := ds.Folder(ctx).Get(commonParentID) if errors.Is(err, model.ErrNotFound) { - log.Warn(ctx, "Parent folder not found for album cover art lookup", "parentID", commonParentID) + log.Warn(ctx, "Artwork: Parent folder not found for album cover art lookup", "parentID", commonParentID) return nil, nil } if err != nil { diff --git a/core/artwork/folders_album_test.go b/core/artwork/folders_album_test.go new file mode 100644 index 000000000..a263dbaaf --- /dev/null +++ b/core/artwork/folders_album_test.go @@ -0,0 +1,214 @@ +package artwork + +import ( + "context" + "errors" + "time" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// The e2e specs cover which image wins for a layout; these pin what a layout cannot reach: that +// the album-root parent is only fetched when it could qualify, and what happens when that fails. +var _ = Describe("loadAlbumFoldersPaths", func() { + var ( + ctx context.Context + ds *tests.MockDataStore + repo *fakeFolderRepo + album model.Album + now time.Time + ) + + BeforeEach(func() { + ctx = context.Background() + now = time.Now().Truncate(time.Second) + repo = &fakeFolderRepo{} + ds = &tests.MockDataStore{MockedFolder: repo} + album = model.Album{ + ID: "album1", + Name: "Album", + FolderIDs: []string{"folder1", "folder2", "folder3"}, + } + }) + + It("does not query the parent when it is already one of the album's folders", func() { + repo.result = []model.Folder{ + {ID: "folder1", Path: "Artist", Name: "Album", ParentID: "folder2", + ImagesUpdatedAt: now, ImageFiles: []string{"cover.jpg"}}, + {ID: "folder2", Path: "", Name: "Artist", ImagesUpdatedAt: now}, + } + + _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) + + Expect(err).ToNot(HaveOccurred()) + Expect(imgFiles).To(ConsistOf("Artist/Album/cover.jpg")) + Expect(repo.getCallCount).To(BeZero()) + }) + + It("does not query the parent when the album's folders have different parents", func() { + repo.result = []model.Folder{ + {ID: "folder1", Path: "Artist1/Album", Name: "part1", ParentID: "parentA", + ImagesUpdatedAt: now, ImageFiles: []string{"cover.jpg"}}, + {ID: "folder2", Path: "Artist2/Album", Name: "part2", ParentID: "parentB", + ImagesUpdatedAt: now}, + } + + _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) + + Expect(err).ToNot(HaveOccurred()) + Expect(imgFiles).To(ConsistOf("Artist1/Album/part1/cover.jpg")) + Expect(repo.getCallCount).To(BeZero()) + }) + + It("does not query the parent for a single-folder album that has images of its own", func() { + repo.result = []model.Folder{ + {ID: "folder1", Path: "Artist", Name: "Album", ParentID: "artistFolder", + ImagesUpdatedAt: now, ImageFiles: []string{"cover.jpg"}}, + } + + _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) + + Expect(err).ToNot(HaveOccurred()) + Expect(imgFiles).To(ConsistOf("Artist/Album/cover.jpg")) + Expect(repo.getCallCount).To(BeZero()) + }) + + It("does not promote the library root, so its images never become album art", func() { + repo.result = []model.Folder{ + {ID: "folder1", Path: ".", Name: "AlbumPart1", ParentID: "rootFolder", + ImagesUpdatedAt: now, ImageFiles: []string{"cover.jpg"}}, + {ID: "folder2", Path: ".", Name: "AlbumPart2", ParentID: "rootFolder", + ImagesUpdatedAt: now}, + } + repo.parentResult = &model.Folder{ID: "rootFolder", Name: ".", ImageFiles: []string{"unrelated.jpg"}} + + _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) + + Expect(err).ToNot(HaveOccurred()) + Expect(imgFiles).To(ConsistOf("AlbumPart1/cover.jpg")) + Expect(repo.getCallCount).To(Equal(1)) + }) + + It("does not promote a parent that holds another album's audio", func() { + repo.result = []model.Folder{ + {ID: "folder1", Path: "Artist/Album", Name: "CD1", ParentID: "albumFolder", + ImagesUpdatedAt: now, ImageFiles: []string{"cover.jpg"}}, + {ID: "folder2", Path: "Artist/Album", Name: "CD2", ParentID: "albumFolder", + ImagesUpdatedAt: now}, + } + repo.parentResult = &model.Folder{ID: "albumFolder", Path: "Artist", Name: "Album", + ParentID: "artistFolder", ImageFiles: []string{"artist.jpg"}} + repo.hasOtherAudio = true + + _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) + + Expect(err).ToNot(HaveOccurred()) + Expect(imgFiles).To(ConsistOf("Artist/Album/CD1/cover.jpg")) + }) + + It("promotes the album root parent into the returned paths", func() { + repo.result = []model.Folder{ + {ID: "folder1", Path: "Artist/Album", Name: "CD1", ParentID: "albumFolder", + ImagesUpdatedAt: now}, + {ID: "folder2", Path: "Artist/Album", Name: "CD2", ParentID: "albumFolder", + ImagesUpdatedAt: now}, + } + repo.parentResult = &model.Folder{ID: "albumFolder", Path: "Artist", Name: "Album", + ParentID: "artistFolder", ImageFiles: []string{"cover.jpg"}} + + paths, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) + + Expect(err).ToNot(HaveOccurred()) + Expect(imgFiles).To(ConsistOf("Artist/Album/cover.jpg")) + Expect(paths).To(HaveLen(3)) + }) + + It("propagates errors from the album-root check", func() { + repo.result = []model.Folder{ + {ID: "folder1", Path: "Artist/Album", Name: "disc1", ParentID: "albumFolder", + ImagesUpdatedAt: now}, + } + repo.parentResult = &model.Folder{ID: "albumFolder", Path: "Artist", Name: "Album", + ParentID: "artistFolder", ImageFiles: []string{"cover.jpg"}} + repo.otherAudioErr = errors.New("db connection failed") + + _, _, _, err := loadAlbumFoldersPaths(ctx, ds, album) + + Expect(err).To(MatchError("db connection failed")) + }) + + It("propagates non-ErrNotFound errors from the parent folder lookup", func() { + repo.result = []model.Folder{ + {ID: "folder1", Path: "Artist/Album", Name: "CD1", ParentID: "parentFolder", + ImagesUpdatedAt: now, ImageFiles: []string{"cover.jpg"}}, + {ID: "folder2", Path: "Artist/Album", Name: "CD2", ParentID: "parentFolder", + ImagesUpdatedAt: now}, + } + repo.getErr = errors.New("db connection failed") + + _, _, _, err := loadAlbumFoldersPaths(ctx, ds, album) + + Expect(err).To(MatchError("db connection failed")) + Expect(repo.getCallCount).To(Equal(1)) + }) + + It("continues when the parent folder has been deleted", func() { + repo.result = []model.Folder{ + {ID: "folder1", Path: "Artist/Album", Name: "CD1", ParentID: "missingParent", + ImagesUpdatedAt: now, ImageFiles: []string{"cover.jpg"}}, + {ID: "folder2", Path: "Artist/Album", Name: "CD2", ParentID: "missingParent", + ImagesUpdatedAt: now}, + } + + _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) + + Expect(err).ToNot(HaveOccurred()) + Expect(imgFiles).To(ConsistOf("Artist/Album/CD1/cover.jpg")) + Expect(repo.getCallCount).To(Equal(1)) + }) +}) + +// folderImages is the sort that decides which of several same-named images wins, so it is pinned +// directly rather than through a layout that can only show the winner. +var _ = Describe("folderImages", func() { + It("prefers base filenames over numeric-suffixed ones", func() { + imgFiles, _ := folderImages([]model.Folder{ + {Path: "Artist", Name: "Album", ImageFiles: []string{"cover.1.jpg", "cover.jpg", "cover.2.jpg"}}, + }) + + Expect(imgFiles).To(HaveExactElements( + "Artist/Album/cover.jpg", "Artist/Album/cover.1.jpg", "Artist/Album/cover.2.jpg")) + }) + + It("prefers shallower paths when the base filenames tie", func() { + imgFiles, _ := folderImages([]model.Folder{ + {Path: "Artist/Album", Name: "CD1", ImageFiles: []string{"cover.jpg"}}, + {Path: "Artist", Name: "Album", ImageFiles: []string{"cover.jpg"}}, + }) + + Expect(imgFiles).To(HaveExactElements("Artist/Album/cover.jpg", "Artist/Album/CD1/cover.jpg")) + }) + + It("sorts case-insensitively", func() { + imgFiles, _ := folderImages([]model.Folder{ + {Path: "Artist", Name: "Album", ImageFiles: []string{"Cover.jpg", "back.JPG"}}, + }) + + Expect(imgFiles).To(HaveExactElements("Artist/Album/back.JPG", "Artist/Album/Cover.jpg")) + }) + + It("reports the newest ImagesUpdatedAt across the folders", func() { + now := time.Now().Truncate(time.Second) + newest := now.Add(5 * time.Minute) + + _, updatedAt := folderImages([]model.Folder{ + {Path: "Artist", Name: "Album", ImagesUpdatedAt: now}, + {Path: "Artist/Album", Name: "CD1", ImagesUpdatedAt: newest}, + }) + + Expect(updatedAt).To(Equal(newest)) + }) +}) diff --git a/core/artwork/folders_artist.go b/core/artwork/folders_artist.go new file mode 100644 index 000000000..1ca1ce034 --- /dev/null +++ b/core/artwork/folders_artist.go @@ -0,0 +1,210 @@ +package artwork + +import ( + "context" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path" + "path/filepath" + "slices" + "strings" + "time" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils" + "github.com/navidrome/navidrome/utils/slice" + "github.com/navidrome/navidrome/utils/str" +) + +const ( + maxArtistFolderTraversalDepth = 3 +) + +func fromArtistFolder(ctx context.Context, libFS fs.FS, libPath, artistFolder, pattern string) sourceFunc { + return func() (io.ReadCloser, string, error) { + if libFS == nil { + return nil, "", fmt.Errorf("artist folder lookup unavailable") + } + rel, err := filepath.Rel(libPath, artistFolder) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return nil, "", fmt.Errorf(`artist folder '%s' is outside library '%s'`, artistFolder, libPath) + } + // fs.Glob needs forward slashes; filepath.Rel returns backslashes on Windows. + rel = filepath.ToSlash(rel) + current := artistFolder + var unreadable error + for range maxArtistFolderTraversalDepth { + reader, hit, err := findImageInFolder(ctx, libFS, rel, current, pattern) + if err == nil { + return reader, hit, nil + } + if errors.Is(err, errSourceUnreadable) { + unreadable = err + } + if rel == "." { + break // reached library root + } + rel = path.Dir(rel) + current = filepath.Dir(current) + } + if unreadable != nil { + return nil, "", unreadable + } + return nil, "", fmt.Errorf(`no matches for '%s' in '%s' or its parent directories (within library)`, pattern, artistFolder) + } +} + +// findImageInFolder returns the first image matching pattern; absFolder is only used for +// the returned display path and log messages. +func findImageInFolder(ctx context.Context, libFS fs.FS, relFolder, absFolder, pattern string) (io.ReadCloser, string, error) { + log.Trace(ctx, "Artwork: Looking for artist image", "pattern", pattern, "folder", absFolder) + globPattern := pattern + if relFolder != "." { + globPattern = path.Join(escapeGlobLiteral(relFolder), pattern) + } + matches, err := fs.Glob(libFS, globPattern) + if err != nil { + log.Warn(ctx, "Artwork: Error matching artist image pattern", "pattern", pattern, "folder", absFolder, err) + return nil, "", err + } + + imagePaths := slice.Filter(matches, model.IsImageFile) + + // Prefer base filenames over numeric-suffixed ones (artist.jpg before artist.1.jpg) + slices.SortFunc(imagePaths, compareImageFiles) + + var openErr error + for _, p := range imagePaths { + f, err := libFS.Open(p) + if err != nil { + log.Warn(ctx, "Artwork: Could not open cover art file", "file", p, err) + openErr = fmt.Errorf("%w: %s: %w", errSourceUnreadable, p, err) + continue + } + _, name := path.Split(p) + return f, filepath.Join(absFolder, name), nil + } + if openErr != nil { + return nil, "", openErr + } + + return nil, "", fmt.Errorf(`no matches for '%s' in '%s'`, pattern, absFolder) +} + +func escapeGlobLiteral(s string) string { + var b strings.Builder + b.Grow(len(s)) + for _, r := range s { + switch r { + case '\\', '*', '?', '[', ']': + b.WriteByte('\\') + } + b.WriteRune(r) + } + return b.String() +} + +// loadArtistAlbumRoots returns one path per album — the deepest folder holding +// all of that album's tracks — so an album split into disc subfolders can't +// pull the artist folder's common prefix below the artist level. +func loadArtistAlbumRoots(ctx context.Context, ds model.DataStore, albums model.Albums) ([]string, []string, *time.Time, error) { + var folderIDs []string + for _, album := range albums { + folderIDs = append(folderIDs, album.FolderIDs...) + } + folders, err := loadFolders(ctx, ds, folderIDs) + if err != nil { + return nil, nil, nil, err + } + + pathByID := slice.ToMap(folders, func(f model.Folder) (string, string) { + return f.ID, f.AbsolutePath() + }) + var roots []string + for _, album := range albums { + var albumPaths []string + for _, fid := range album.FolderIDs { + if p, ok := pathByID[fid]; ok { + albumPaths = append(albumPaths, p) + } + } + if len(albumPaths) > 0 { + roots = append(roots, commonDir(albumPaths)) + } + } + + imgFiles, updatedAt := folderImages(folders) + return roots, imgFiles, &updatedAt, nil +} + +// commonDir returns the deepest directory containing all paths. Trailing +// separators keep the comparison on segment boundaries, so a shared name +// fragment (".../Album" and ".../Album2") is never read as a shared directory. +func commonDir(paths []string) string { + sep := string(filepath.Separator) + common := str.LongestCommonPrefix(slice.Map(paths, func(p string) string { return p + sep })) + if !strings.HasSuffix(common, sep) { + common, _ = filepath.Split(common) + } + return filepath.Clean(common) +} + +func loadArtistFolder(ctx context.Context, ds model.DataStore, albums model.Albums, paths []string) (string, time.Time, error) { + if len(albums) == 0 { + return "", time.Time{}, nil + } + libID := albums[0].LibraryID // TODO: Support albums spanning multiple libraries + + // paths holds one root per album: two or more distinct roots already meet at + // the artist folder, while a single root is an album folder needing a climb. + roots := slices.Compact(slices.Sorted(slices.Values(paths))) + folderPath := commonDir(roots) + if len(roots) < 2 { + folderPath = filepath.Dir(folderPath) + } + + // TODO: Hacky, but the easiest way to get the folder ID ATM + libPath := core.AbsolutePath(ctx, ds, libID, "") + folderID := model.FolderID(model.Library{ID: libID, Path: libPath}, folderPath) + + log.Trace(ctx, "Artwork: Calculating artist folder details", "folderPath", folderPath, "folderID", folderID, + "libPath", libPath, "libID", libID, "albumPaths", paths) + + folders, err := ds.Folder(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"folder.id": folderID, "missing": false}}) + if err != nil || len(folders) == 0 { + log.Warn(ctx, "Artwork: Could not find folder for artist", "folderPath", folderPath, "id", folderID, + "libPath", libPath, "libID", libID, err) + return "", time.Time{}, err + } + return folderPath, folders[0].ImagesUpdatedAt, nil +} + +// findImageInArtistFolder matches an image by MBID or artist name (case-insensitive), "" if none. +func findImageInArtistFolder(folder, mbzArtistID, artistName string) string { + entries, err := os.ReadDir(folder) + if err != nil { + return "" + } + for _, candidate := range []string{mbzArtistID, artistName} { + if candidate == "" { + continue + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + base := utils.BaseName(name) + if strings.EqualFold(base, candidate) && model.IsImageFile(name) { + return filepath.Join(folder, name) + } + } + } + return "" +} diff --git a/core/artwork/folders_artist_paths_test.go b/core/artwork/folders_artist_paths_test.go new file mode 100644 index 000000000..cc8af3e63 --- /dev/null +++ b/core/artwork/folders_artist_paths_test.go @@ -0,0 +1,117 @@ +package artwork + +import ( + "context" + "errors" + "path/filepath" + "time" + + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// commonDir and loadArtistFolder decide how far above the albums the artist folder sits. A layout +// can only show the image that won, so the arithmetic is pinned here. +var _ = Describe("commonDir", func() { + It("returns the folder itself for a single path", func() { + Expect(commonDir([]string{filepath.FromSlash("/music/artist/album")})). + To(Equal(filepath.FromSlash("/music/artist/album"))) + }) + + It("returns the deepest shared folder", func() { + Expect(commonDir([]string{ + filepath.FromSlash("/music/artist/album/cd1"), + filepath.FromSlash("/music/artist/album/cd2"), + })).To(Equal(filepath.FromSlash("/music/artist/album"))) + }) + + It("does not read a shared name fragment as a shared folder", func() { + Expect(commonDir([]string{ + filepath.FromSlash("/music/artist/Album"), + filepath.FromSlash("/music/artist/Album2"), + })).To(Equal(filepath.FromSlash("/music/artist"))) + }) +}) + +var _ = Describe("loadArtistFolder", func() { + var ( + ctx context.Context + ds *tests.MockDataStore + repo *fakeFolderRepo + albums model.Albums + updatedAt time.Time + ) + + BeforeEach(func() { + ctx = context.Background() + DeferCleanup(stubCoreAbsolutePath()) + + updatedAt = time.Now().Truncate(time.Second).Add(5 * time.Minute) + repo = &fakeFolderRepo{result: []model.Folder{{ImagesUpdatedAt: updatedAt}}} + ds = &tests.MockDataStore{MockedFolder: repo} + albums = model.Albums{{LibraryID: 1, ID: "album1", Name: "Album 1"}} + }) + + It("returns empty when the artist has no albums", func() { + folder, upd, err := loadArtistFolder(ctx, ds, model.Albums{}, []string{"/dummy/path"}) + + Expect(err).ToNot(HaveOccurred()) + Expect(folder).To(BeEmpty()) + Expect(upd).To(BeZero()) + }) + + It("climbs above the album folder when the artist has a single album root", func() { + folder, upd, err := loadArtistFolder(ctx, ds, albums, + []string{filepath.FromSlash("/music/artist/album1")}) + + Expect(err).ToNot(HaveOccurred()) + Expect(folder).To(Equal(filepath.FromSlash("/music/artist"))) + Expect(upd).To(Equal(updatedAt)) + }) + + It("climbs above the shared folder when two albums live in the same one", func() { + folder, upd, err := loadArtistFolder(ctx, ds, albums, []string{ + filepath.FromSlash("/music/artist/split"), + filepath.FromSlash("/music/artist/split"), + }) + + Expect(err).ToNot(HaveOccurred()) + Expect(folder).To(Equal(filepath.FromSlash("/music/artist"))) + Expect(upd).To(Equal(updatedAt)) + }) + + It("stops at the folder where distinct album roots already meet", func() { + folder, upd, err := loadArtistFolder(ctx, ds, albums, []string{ + filepath.FromSlash("/music/artist/album1"), + filepath.FromSlash("/music/artist/album2"), + }) + + Expect(err).ToNot(HaveOccurred()) + Expect(folder).To(Equal(filepath.FromSlash("/music/artist"))) + Expect(upd).To(Equal(updatedAt)) + }) + + It("returns the error when the folder lookup fails", func() { + repo.err = errors.New("fake error") + + folder, upd, err := loadArtistFolder(ctx, ds, albums, []string{ + filepath.FromSlash("/music/artist/album1"), + filepath.FromSlash("/music/artist/album2"), + }) + + Expect(err).To(MatchError(ContainSubstring("fake error"))) + Expect(folder).To(BeEmpty()) + Expect(upd).To(BeZero()) + }) +}) + +func stubCoreAbsolutePath() func() { + original := core.AbsolutePath + core.AbsolutePath = func(context.Context, model.DataStore, int, string) string { + return filepath.FromSlash("/music") + } + return func() { core.AbsolutePath = original } +} diff --git a/core/artwork/folders_artist_test.go b/core/artwork/folders_artist_test.go new file mode 100644 index 000000000..a35e40232 --- /dev/null +++ b/core/artwork/folders_artist_test.go @@ -0,0 +1,47 @@ +package artwork + +import ( + "context" + "errors" + "io/fs" + "testing/fstest" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// unreadableFS globs like its embedded MapFS but refuses to open anything. Injecting the error +// keeps this independent of the filesystem: os.Chmod does not restrict read access on Windows. +type unreadableFS struct{ fstest.MapFS } + +func (u unreadableFS) Open(string) (fs.File, error) { return nil, fs.ErrPermission } + +var _ = Describe("findImageInFolder", func() { + var ctx context.Context + var files fstest.MapFS + + BeforeEach(func() { + ctx = context.Background() + files = fstest.MapFS{"artist.jpg": &fstest.MapFile{Data: []byte("img")}} + }) + + It("returns the first matching image", func() { + r, hit, err := findImageInFolder(ctx, files, ".", "/lib", "artist.*") + Expect(err).ToNot(HaveOccurred()) + defer r.Close() + Expect(hit).To(HaveSuffix("artist.jpg")) + }) + + // The glob matched, so the image exists; failing to open it says nothing about whether the + // artist has one, and must not let the resolver settle on absent. + It("reports a matched but unreadable image as unreadable, not as a miss", func() { + _, _, err := findImageInFolder(ctx, unreadableFS{files}, ".", "/lib", "artist.*") + Expect(err).To(MatchError(errSourceUnreadable)) + }) + + It("reports a plain miss when nothing matches", func() { + _, _, err := findImageInFolder(ctx, files, ".", "/lib", "nothing.*") + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, errSourceUnreadable)).To(BeFalse(), "no match is definitive, not transient") + }) +}) diff --git a/core/artwork/gate.go b/core/artwork/gate.go new file mode 100644 index 000000000..a64c11a20 --- /dev/null +++ b/core/artwork/gate.go @@ -0,0 +1,120 @@ +package artwork + +import ( + "errors" + "io" + "sync" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "golang.org/x/time/rate" +) + +const ( + breakerThreshold = 5 + breakerProbeAfter = time.Minute +) + +var errBreakerOpen = errors.New("artwork: external circuit breaker open") + +// gateFunc gates one named external fetch (rate limit + circuit breaker per name). +type gateFunc = func(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) + +func passthroughGate(_ string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { + return f() +} + +// isTransientExternal reports whether an external failure is worth retrying; a not-found +// (from either package) is a definitive answer, not a fault. +func isTransientExternal(err error) bool { + return err != nil && !errors.Is(err, agents.ErrNotFound) && !errors.Is(err, model.ErrNotFound) +} + +// extGate is one agent's rate limiter and circuit breaker, so a failing provider backs off +// in isolation from the others. +type extGate struct { + limiter *rate.Limiter + breaker *breaker +} + +// gate runs a named external step through that agent's rate limiter and circuit breaker. +func (w *Worker) gate(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { + g := w.gateFor(name) + if !g.breaker.allow() { + log.Debug(w.runCtx, "Artwork: Skipping agent, circuit breaker open", "agent", name) + return nil, "", errBreakerOpen + } + // Timed separately so a throttled agent isn't mistaken for a slow provider. + waitStart := time.Now() + if err := g.limiter.Wait(w.runCtx); err != nil { + return nil, "", err + } + callStart := time.Now() + r, path, err := f() + g.breaker.record(name, err) + log.Trace(w.runCtx, "Artwork: External agent call", "agent", name, "hit", r != nil, + "limiterWait", callStart.Sub(waitStart), "elapsed", time.Since(callStart), err) + return r, path, err +} + +// gateFor lazily creates the per-name gate on first use. +func (w *Worker) gateFor(name string) *extGate { + w.gatesMu.Lock() + defer w.gatesMu.Unlock() + if g, ok := w.gates[name]; ok { + return g + } + rps := conf.Server.DevArtworkExternalMaxRPS + limit := rate.Inf + if rps > 0 { + limit = rate.Limit(rps) + } + g := &extGate{limiter: rate.NewLimiter(limit, max(1, rps)), breaker: newBreaker()} + w.gates[name] = g + return g +} + +// breaker opens after breakerThreshold consecutive errors and admits a single probe once +// breakerProbeAfter has elapsed; a success re-closes it. +type breaker struct { + mu sync.Mutex + failures int + openedAt time.Time +} + +func newBreaker() *breaker { return &breaker{} } + +func (b *breaker) allow() bool { + b.mu.Lock() + defer b.mu.Unlock() + if b.failures < breakerThreshold { + return true + } + if time.Since(b.openedAt) >= breakerProbeAfter { + b.openedAt = time.Now() // start a fresh probe window so only one caller passes + return true + } + return false +} + +func (b *breaker) record(name string, err error) { + b.mu.Lock() + defer b.mu.Unlock() + // A not-found is a definitive answer, not a fault; keep in sync with isTransientExternal. + if err == nil || errors.Is(err, model.ErrNotFound) || errors.Is(err, agents.ErrNotFound) { + if b.failures >= breakerThreshold { + log.Info("Artwork: Circuit breaker closed for agent", "agent", name) + } + b.failures = 0 + return + } + b.failures++ + if b.failures == breakerThreshold { + b.openedAt = time.Now() + log.Warn("Artwork: Circuit breaker opened for agent", "agent", name, + "consecutiveFailures", b.failures, "probeAfter", breakerProbeAfter, err) + } +} diff --git a/core/artwork/hash_encoder_bench_test.go b/core/artwork/hash_encoder_bench_test.go new file mode 100644 index 000000000..2bc4b90ba --- /dev/null +++ b/core/artwork/hash_encoder_bench_test.go @@ -0,0 +1,51 @@ +package artwork + +import ( + "fmt" + "image" + "testing" + + "github.com/navidrome/navidrome/core/artwork/blurhash" + "github.com/navidrome/navidrome/core/artwork/thumbhash" +) + +// hashEncoders are the two placeholder hashes decodeArtwork computes from one shared thumbnail. +var hashEncoders = []struct { + name string + encode func(image.Image) error +}{ + {"blurhash", func(img image.Image) error { _, err := blurhash.Encode(img); return err }}, + {"thumbhash", func(img image.Image) error { _, err := thumbhash.Encode(img); return err }}, +} + +func benchEncoder(b *testing.B, encode func(image.Image) error, img image.Image) { + b.Helper() + b.ReportAllocs() + for b.Loop() { + if err := encode(img); err != nil { + b.Fatal(err) + } + } +} + +// BenchmarkHashEncodersAtInputSize is the bar: both encoders are handed the identical image +// makeThumbnail produces, so neither is measured with a conversion the other avoids. +func BenchmarkHashEncodersAtInputSize(b *testing.B) { + img := gradientNRGBA(thumbnailSize) + for _, e := range hashEncoders { + b.Run(e.name, func(b *testing.B) { benchEncoder(b, e.encode, img) }) + } +} + +// BenchmarkHashEncoders sweeps past the pipeline's input size, where each package's own defensive +// downscale starts to dominate. thumbnailSize itself is covered by the benchmark above. +func BenchmarkHashEncoders(b *testing.B) { + for _, size := range []int{300, 600, 900, 1200, 1500} { + img := gradientNRGBA(size) + for _, e := range hashEncoders { + b.Run(fmt.Sprintf("%s/%dx%d", e.name, size, size), func(b *testing.B) { + benchEncoder(b, e.encode, img) + }) + } + } +} diff --git a/core/artwork/housekeeping.go b/core/artwork/housekeeping.go new file mode 100644 index 000000000..ce72a6c8e --- /dev/null +++ b/core/artwork/housekeeping.go @@ -0,0 +1,129 @@ +package artwork + +import ( + "context" + "crypto/md5" + "encoding/hex" + "fmt" + "slices" + "time" + + "github.com/navidrome/navidrome/conf" + "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/utils/slice" +) + +const staleAbsentAge = 24 * time.Hour + +// recheckKinds omits media files: they resolve embedded-only, at scan or on view. +var recheckKinds = []model.Kind{ + model.KindArtistArtwork, model.KindAlbumArtwork, model.KindPlaylistArtwork, model.KindRadioArtwork, +} + +// hasRecheckPath reports whether a periodic job will revisit this kind, making an absent settle recoverable. +func hasRecheckPath(prefix string) bool { + kind, ok := model.ParseKind(prefix) + return ok && slices.Contains(recheckKinds, kind) +} + +// artworkEpoch invalidates all resolution state when bumped; bump it whenever resolution semantics change. +const artworkEpoch = 1 + +// fingerprint covers the inputs that affect resolution outcomes; a change invalidates stored state. +func fingerprint() string { + raw := fmt.Sprintf("%s|%s|%s|%s|%t|%t|%d", + conf.Server.CoverArtPriority, conf.Server.ArtistArtPriority, conf.Server.ArtistImageFolder, + conf.Server.Agents, conf.Server.EnableExternalServices, conf.Server.EnableM3UExternalAlbumArt, artworkEpoch) + sum := md5.Sum([]byte(raw)) //nolint:gosec // fingerprint, not security-sensitive + return hex.EncodeToString(sum[:]) +} + +// backfill enqueues artwork resolution for every entity when the config fingerprint changed. +func backfill(ctx context.Context, ds model.DataStore) (bool, error) { + start := time.Now() + ctx = auth.WithAdminUser(ctx, ds) + current := fingerprint() + props := ds.Property(ctx) + stored, err := props.DefaultGet(consts.ArtConfFingerprintPropertyKey, "") + if err != nil { + return false, err + } + if stored == current { + return false, nil + } + + // Artists first: few entities, most external-dependent, so they get a queue headstart. + kinds := []struct { + kind model.Kind + fetch func() ([]string, error) + }{ + {model.KindArtistArtwork, func() ([]string, error) { return ds.Artist(ctx).GetAllIDs() }}, + {model.KindAlbumArtwork, func() ([]string, error) { return ds.Album(ctx).GetAllIDs() }}, + {model.KindPlaylistArtwork, func() ([]string, error) { return ds.Playlist(ctx).GetAllIDs() }}, + {model.KindRadioArtwork, func() ([]string, error) { return ds.Radio(ctx).GetAllIDs() }}, + } + for _, k := range kinds { + ids, err := k.fetch() + if err != nil { + return false, err + } + if err := enqueueBackfillKind(ctx, ds, k.kind, ids); err != nil { + return false, err + } + } + + if err := props.Put(consts.ArtConfFingerprintPropertyKey, current); err != nil { + return false, err + } + log.Info(ctx, "Artwork: Config fingerprint changed, backfill enqueued", "elapsed", time.Since(start)) + return true, nil +} + +func enqueueBackfillKind(ctx context.Context, ds model.DataStore, kind model.Kind, ids []string) error { + if len(ids) == 0 { + return nil + } + items := slice.Map(ids, func(id string) model.ArtworkQueueItem { + return model.ArtworkQueueItem{ + ItemKind: kind.Prefix(), ItemID: id, ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBackfill, + } + }) + return ds.ArtworkQueue(ctx).Enqueue(items...) +} + +func enqueueStaleAbsentAll(ctx context.Context, ds model.DataStore) error { + cutoff := time.Now().Add(-staleAbsentAge) + queue := ds.ArtworkQueue(ctx) + for _, kind := range recheckKinds { + if _, err := queue.EnqueueStaleAbsent(kind, cutoff); err != nil { + return err + } + } + return nil +} + +// enqueueMissingAll is the safety net for entities a scan never enqueued (added between scans, or scanner off). +func enqueueMissingAll(ctx context.Context, ds model.DataStore) error { + queue := ds.ArtworkQueue(ctx) + for _, kind := range recheckKinds { + if _, err := queue.EnqueueAllMissing(kind, model.ArtworkPriorityRecheck); err != nil { + return err + } + } + return nil +} + +// Refresh drops an item's resolved artwork state and re-queues it at Bump priority. +func Refresh(ctx context.Context, ds model.DataStore, kind model.Kind, id string) error { + if err := ds.Artwork(ctx).DeleteForItems(kind, []string{id}); err != nil { + return fmt.Errorf("clearing artwork state: %w", err) + } + item := model.ArtworkQueueItem{ItemKind: kind.Prefix(), ItemID: id, ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBump} + if err := ds.ArtworkQueue(ctx).Enqueue(item); err != nil { + return fmt.Errorf("enqueuing artwork refresh: %w", err) + } + return nil +} diff --git a/core/artwork/housekeeping_test.go b/core/artwork/housekeeping_test.go new file mode 100644 index 000000000..3b75c5186 --- /dev/null +++ b/core/artwork/housekeeping_test.go @@ -0,0 +1,261 @@ +package artwork + +import ( + "context" + "slices" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" + "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" +) + +// visibilityPlaylistDS models playlist_repository's userFilter: a private playlist is only +// visible when the ctx carries an admin, so headless work must wrap ctx with one first. +type visibilityPlaylistDS struct { + *tests.MockDataStore + private model.Playlist + tracks model.PlaylistTrackRepository +} + +func (v *visibilityPlaylistDS) Playlist(ctx context.Context) model.PlaylistRepository { + repo := tests.CreateMockPlaylistRepo() + repo.TracksRepo = v.tracks + if u, ok := request.UserFrom(ctx); ok && u.IsAdmin { + repo.SetData(model.Playlists{v.private}) + } + return repo +} + +func adminUserRepo() *tests.MockedUserRepo { + repo := tests.CreateMockUserRepo() + Expect(repo.Put(&model.User{ID: "admin", UserName: "admin", IsAdmin: true})).To(Succeed()) + return repo +} + +// orderTrackingQueueRepo records the item kind of each Enqueue call, so tests can +// assert phase ordering (artists-first) that same-priority timestamps can't guarantee. +type orderTrackingQueueRepo struct { + *tests.MockArtworkQueueRepo + callKinds []string +} + +func (o *orderTrackingQueueRepo) Enqueue(items ...model.ArtworkQueueItem) error { + if len(items) > 0 { + o.callKinds = append(o.callKinds, items[0].ItemKind) + } + return o.MockArtworkQueueRepo.Enqueue(items...) +} + +var _ = Describe("Housekeeping", func() { + var ( + ctx context.Context + ds *tests.MockDataStore + queueRepo *orderTrackingQueueRepo + propRepo *tests.MockedPropertyRepo + ) + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + ctx = context.Background() + conf.Server.CoverArtPriority = "embedded, folder" + conf.Server.ArtistArtPriority = "artist.jpg" + conf.Server.Agents = "spotify" + conf.Server.EnableExternalServices = true + + queueRepo = &orderTrackingQueueRepo{MockArtworkQueueRepo: tests.CreateMockArtworkQueueRepo()} + propRepo = &tests.MockedPropertyRepo{} + ds = &tests.MockDataStore{MockedArtworkQueue: queueRepo, MockedProperty: propRepo} + }) + + seedEntities := func() { + artistRepo := tests.CreateMockArtistRepo() + artistRepo.SetData(model.Artists{{ID: "ar1"}, {ID: "ar2"}}) + ds.MockedArtist = artistRepo + + albumRepo := tests.CreateMockAlbumRepo() + albumRepo.SetData(model.Albums{{ID: "al1"}}) + ds.MockedAlbum = albumRepo + + playlistRepo := tests.CreateMockPlaylistRepo() + playlistRepo.SetData(model.Playlists{{ID: "pl1"}}) + ds.MockedPlaylist = playlistRepo + + radioRepo := tests.CreateMockedRadioRepo() + radioRepo.All = model.Radios{{ID: "ra1"}} + ds.MockedRadio = radioRepo + } + + Describe("Fingerprint", func() { + It("changes when a fingerprint-affecting config value changes", func() { + f1 := fingerprint() + conf.Server.CoverArtPriority = "folder, embedded" + f2 := fingerprint() + Expect(f1).NotTo(Equal(f2)) + }) + + It("changes when ArtistImageFolder changes", func() { + conf.Server.ArtistImageFolder = "/before" + f1 := fingerprint() + conf.Server.ArtistImageFolder = "/after" + Expect(fingerprint()).NotTo(Equal(f1)) + }) + + It("changes when EnableM3UExternalAlbumArt is toggled", func() { + conf.Server.EnableM3UExternalAlbumArt = false + f1 := fingerprint() + conf.Server.EnableM3UExternalAlbumArt = true + Expect(fingerprint()).NotTo(Equal(f1)) + }) + + It("does not change when the server version changes", func() { + original := consts.Version + DeferCleanup(func() { consts.Version = original }) + f1 := fingerprint() + consts.Version = original + "-next" + Expect(fingerprint()).To(Equal(f1), + "the version must not invalidate artwork state: it would re-resolve every entity on every build") + }) + }) + + Describe("Backfill", func() { + It("enqueues nothing and returns false when the stored fingerprint matches", func() { + seedEntities() + Expect(propRepo.Put(consts.ArtConfFingerprintPropertyKey, fingerprint())).To(Succeed()) + + did, err := backfill(ctx, ds) + Expect(err).ToNot(HaveOccurred()) + Expect(did).To(BeFalse()) + + count, err := queueRepo.Count() + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(BeZero()) + }) + + It("runs the backfill when no fingerprint was ever stored", func() { + seedEntities() + + did, err := backfill(ctx, ds) + Expect(err).ToNot(HaveOccurred()) + Expect(did).To(BeTrue()) + + count, err := queueRepo.Count() + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(Equal(int64(5))) // 2 artists + 1 album + 1 playlist + 1 radio + + stored, err := propRepo.Get(consts.ArtConfFingerprintPropertyKey) + Expect(err).ToNot(HaveOccurred()) + Expect(stored).To(Equal(fingerprint())) + }) + + It("enqueues a private playlist by resolving it under an admin context", func() { + ds.MockedUser = adminUserRepo() + vds := &visibilityPlaylistDS{ + MockDataStore: ds, + private: model.Playlist{ID: "plPrivate", OwnerID: "admin"}, + tracks: &tests.MockPlaylistTrackRepo{}, + } + + did, err := backfill(ctx, vds) + Expect(err).ToNot(HaveOccurred()) + Expect(did).To(BeTrue()) + Expect(findQueued(queueRepo.MockArtworkQueueRepo, "pl", "plPrivate")).ToNot(BeNil()) + }) + + It("enqueues artists before albums/playlists/radios, all at Backfill priority", func() { + seedEntities() + Expect(propRepo.Put(consts.ArtConfFingerprintPropertyKey, "stale-fingerprint")).To(Succeed()) + + did, err := backfill(ctx, ds) + Expect(err).ToNot(HaveOccurred()) + Expect(did).To(BeTrue()) + + Expect(queueRepo.callKinds).ToNot(BeEmpty()) + firstOther := slices.IndexFunc(queueRepo.callKinds, func(k string) bool { return k != "ar" }) + Expect(firstOther).ToNot(Equal(0), "artists must be the first Enqueue call") + if firstOther >= 0 { + Expect(queueRepo.callKinds[firstOther:]).ToNot(ContainElement("ar"), + "no artist Enqueue may follow another kind") + } + + for _, it := range queueRepo.Data { + Expect(it.Priority).To(Equal(model.ArtworkPriorityBackfill)) + Expect(it.ItemKind).To(BeElementOf("ar", "al", "pl", "ra")) + } + }) + }) + + Describe("EnqueueStaleAbsentAll", func() { + var artRepo *tests.MockArtworkRepo + + BeforeEach(func() { + artRepo = tests.CreateMockArtworkRepo() + ds.MockedArtwork = artRepo + queueRepo.ItemArtworkSource = artRepo + }) + + It("enqueues only absent entries older than the recheck window, across all kinds", func() { + old := time.Now().Add(-48 * time.Hour) + recent := time.Now().Add(-time.Hour) + + artRepo.ItemData["ar-stale"] = model.ItemArtwork{ItemKind: "ar", ItemID: "ar1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old} + artRepo.ItemData["al-stale"] = model.ItemArtwork{ItemKind: "al", ItemID: "al1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old} + artRepo.ItemData["pl-stale"] = model.ItemArtwork{ItemKind: "pl", ItemID: "pl1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old} + artRepo.ItemData["ra-stale"] = model.ItemArtwork{ItemKind: "ra", ItemID: "ra1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old} + artRepo.ItemData["ar-recent"] = model.ItemArtwork{ItemKind: "ar", ItemID: "ar2", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: recent} + artRepo.ItemData["al-resolved"] = model.ItemArtwork{ItemKind: "al", ItemID: "al2", ImageType: model.ImageTypePrimary, Hash: "somehash", AttemptedAt: old} + + err := enqueueStaleAbsentAll(ctx, ds) + Expect(err).ToNot(HaveOccurred()) + + Expect(queueRepo.Data).To(HaveLen(4)) + for _, it := range queueRepo.Data { + Expect(it.Priority).To(Equal(model.ArtworkPriorityRecheck)) + } + Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ar", "ar1")).ToNot(BeNil()) + Expect(findQueued(queueRepo.MockArtworkQueueRepo, "al", "al1")).ToNot(BeNil()) + Expect(findQueued(queueRepo.MockArtworkQueueRepo, "pl", "pl1")).ToNot(BeNil()) + Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ra", "ra1")).ToNot(BeNil()) + Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ar", "ar2")).To(BeNil()) + Expect(findQueued(queueRepo.MockArtworkQueueRepo, "al", "al2")).To(BeNil()) + }) + }) + + Describe("EnqueueMissingAll", func() { + var artRepo *tests.MockArtworkRepo + + BeforeEach(func() { + artRepo = tests.CreateMockArtworkRepo() + ds.MockedArtwork = artRepo + queueRepo.ItemArtworkSource = artRepo + queueRepo.ExistingIDs = map[string]map[string]bool{ + "al": {"al1": true, "al2": true}, + "ar": {"ar1": true}, + "pl": {"pl1": true}, + "ra": {"ra1": true}, + } + }) + + It("enqueues only entities that have no item_artwork row, across all kinds", func() { + artRepo.ItemData["al-resolved"] = model.ItemArtwork{ItemKind: "al", ItemID: "al1", ImageType: model.ImageTypePrimary, Hash: "somehash", AttemptedAt: time.Now()} + artRepo.ItemData["ar-absent"] = model.ItemArtwork{ItemKind: "ar", ItemID: "ar1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: time.Now()} + + err := enqueueMissingAll(ctx, ds) + Expect(err).ToNot(HaveOccurred()) + + for _, it := range queueRepo.Data { + Expect(it.Priority).To(Equal(model.ArtworkPriorityRecheck)) + } + Expect(findQueued(queueRepo.MockArtworkQueueRepo, "al", "al2")).ToNot(BeNil()) + Expect(findQueued(queueRepo.MockArtworkQueueRepo, "pl", "pl1")).ToNot(BeNil()) + Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ra", "ra1")).ToNot(BeNil()) + Expect(findQueued(queueRepo.MockArtworkQueueRepo, "al", "al1")).To(BeNil()) + Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ar", "ar1")).To(BeNil()) + }) + }) +}) diff --git a/core/artwork/image_cache.go b/core/artwork/image_cache.go index ac0f63794..39938a755 100644 --- a/core/artwork/image_cache.go +++ b/core/artwork/image_cache.go @@ -1,30 +1,22 @@ package artwork import ( + "bytes" "context" - "fmt" "io" - "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/utils/cache" "github.com/navidrome/navidrome/utils/singleton" ) -type cacheKey struct { - artID model.ArtworkID - lastUpdate time.Time -} - -func (k *cacheKey) Key() string { - return fmt.Sprintf( - "%s-%s.%d", - k.artID.Kind, - k.artID.ID, - k.lastUpdate.UnixMilli(), - ) +// artworkReader is the cache.Item the image cache loader dispatches on: Reader +// produces the (possibly resized) bytes to store under Key. +type artworkReader interface { + cache.Item + Reader(ctx context.Context) (io.ReadCloser, error) } type imageCache struct { @@ -36,9 +28,45 @@ func GetImageCache() cache.FileCache { return &imageCache{ FileCache: cache.NewFileCache("Image", conf.Server.ImageCacheSize, consts.ImageCacheDir, consts.DefaultImageCacheMaxItems, func(ctx context.Context, arg cache.Item) (io.Reader, error) { - r, _, err := arg.(artworkReader).Reader(ctx) - return r, err + return arg.(artworkReader).Reader(ctx) }), } }) } + +// resizedItem is an artworkReader that resizes bytes opened by open() and caches the +// result under a hash-derived key. +type resizedItem struct { + hash string + size int + square bool + ffmpeg ffmpeg.FFmpeg + open func() (io.ReadCloser, error) +} + +// Key is the ETag namespaced for the cache, so the validator a client holds and the entry it +// validates can never drift apart. +func (r *resizedItem) Key() string { + return "h-" + representationTag(r.hash, r.size, r.square) +} + +func (r *resizedItem) Reader(ctx context.Context) (io.ReadCloser, error) { + orig, err := r.open() + if err != nil { + return nil, err + } + defer orig.Close() + data, err := readCapped(orig) + if err != nil { + return nil, err + } + resized, _, err := resizeImageData(ctx, r.ffmpeg, data, r.size, r.square) + if err != nil || resized == nil { + // Resize failed or image already within bounds: serve the original bytes. + return io.NopCloser(bytes.NewReader(data)), nil + } + if rc, ok := resized.(io.ReadCloser); ok { + return rc, nil + } + return io.NopCloser(resized), nil +} diff --git a/core/artwork/image_store.go b/core/artwork/image_store.go new file mode 100644 index 000000000..5dbe727e4 --- /dev/null +++ b/core/artwork/image_store.go @@ -0,0 +1,155 @@ +package artwork + +import ( + "context" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/log" + "github.com/zeebo/xxh3" +) + +// ImageStore is the content-addressed store for artwork images with no library file backing them. +type ImageStore struct { + root string +} + +func NewImageStore(rootDir string) *ImageStore { + return &ImageStore{root: rootDir} +} + +func GetImageStore() *ImageStore { + return NewImageStore(filepath.Join(conf.Server.DataFolder.String(), consts.ArtworkFolder, consts.HashedArtworkFolder)) +} + +// extForMime must stay stable across OSes: extensions are baked into stored paths and re-derived on Open. +func extForMime(m string) string { + switch m { + case "image/jpeg": + return ".jpg" + case "image/png": + return ".png" + case "image/gif": + return ".gif" + case "image/webp": + return ".webp" + } + return ".img" +} + +func hashImage(r io.Reader) (string, error) { + d := xxh3.New() + if _, err := io.Copy(d, r); err != nil { + return "", err + } + return fmt.Sprintf("%016x", d.Sum64()), nil +} + +// validHash guards path sharding: a malformed hash would slice-panic or inject path separators. +func validHash(hash string) bool { + if len(hash) != 16 { + return false + } + for _, c := range []byte(hash) { + if (c < '0' || c > '9') && (c < 'a' || c > 'f') { + return false + } + } + return true +} + +func (s *ImageStore) path(hash, mimeType string) string { + return filepath.Join(s.root, hash[0:2], hash[2:4], hash+extForMime(mimeType)) +} + +func (s *ImageStore) Write(hash, mimeType string, r io.Reader) error { + if !validHash(hash) { + return fmt.Errorf("imagestore: invalid hash %q", hash) + } + dst := s.path(hash, mimeType) + if _, err := os.Stat(dst); err == nil { + // A touched mtime marks the file live so a concurrent prune spares it. + now := time.Now() + if err := os.Chtimes(dst, now, now); err == nil { + return nil + } + // touch failed (likely pruned concurrently) — fall through and rewrite it + } + if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(dst), "."+hash+".tmp*") + if err != nil { + return err + } + defer os.Remove(tmp.Name()) + if _, err := io.Copy(tmp, r); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmp.Name(), dst) +} + +func (s *ImageStore) Open(hash, mimeType string) (io.ReadCloser, error) { + if !validHash(hash) { + return nil, fmt.Errorf("imagestore: invalid hash %q", hash) + } + return os.Open(s.path(hash, mimeType)) +} + +// Sweep removes store files not accepted by keep. Files modified after cutoff are always +// kept: their acquisition row may not be committed yet. +func (s *ImageStore) Sweep(ctx context.Context, cutoff time.Time, keep func(hash, ext string) bool) (int, error) { + removed, failed := 0, 0 + var lastErr error + err := filepath.WalkDir(s.root, func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + if err := ctx.Err(); err != nil { + return err + } + info, err := d.Info() + if err != nil { + return err + } + if info.ModTime().After(cutoff) { + return nil + } + name := d.Name() + remove := strings.HasPrefix(name, ".") // abandoned temp file past the grace window + if !remove { + ext := filepath.Ext(name) + remove = !keep(strings.TrimSuffix(name, ext), ext) + } + if remove { + // #nosec G122 -- path comes from WalkDir over our own store root, no attacker-controlled symlinks + if err := os.Remove(path); err != nil { + // One unremovable file must not strand the rest of the store until the next prune. + failed, lastErr = failed+1, err + return nil //nolint:nilerr // counted and reported in aggregate below + } + removed++ + } + return nil + }) + // Aggregated: a store that has gone read-only would otherwise warn once per file, every prune. + if failed > 0 { + log.Warn(ctx, "Artwork: Could not remove store files", "count", failed, "swept", removed, lastErr) + } + if errors.Is(err, fs.ErrNotExist) { + return removed, nil + } + return removed, err +} diff --git a/core/artwork/image_store_test.go b/core/artwork/image_store_test.go new file mode 100644 index 000000000..f7b2467ca --- /dev/null +++ b/core/artwork/image_store_test.go @@ -0,0 +1,228 @@ +package artwork + +import ( + "bytes" + "context" + "io" + "os" + "path/filepath" + "time" + + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("ImageStore", func() { + var store *ImageStore + var root string + var ctx context.Context + + BeforeEach(func() { + root = GinkgoT().TempDir() + store = NewImageStore(root) + ctx = context.Background() + }) + + It("hashes deterministically", func() { + h1, err := hashImage(bytes.NewReader([]byte("some image bytes"))) + Expect(err).ToNot(HaveOccurred()) + h2, _ := hashImage(bytes.NewReader([]byte("some image bytes"))) + Expect(h1).To(Equal(h2)) + Expect(h1).To(HaveLen(16)) + h3, _ := hashImage(bytes.NewReader([]byte("other bytes"))) + Expect(h3).ToNot(Equal(h1)) + }) + + It("writes sharded and reads back", func() { + data := []byte("jpeg-bytes") + h, _ := hashImage(bytes.NewReader(data)) + Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed()) + + Expect(filepath.Join(root, h[0:2], h[2:4], h+".jpg")).To(BeAnExistingFile()) + + rc, err := store.Open(h, "image/jpeg") + Expect(err).ToNot(HaveOccurred()) + defer rc.Close() + got, _ := io.ReadAll(rc) + Expect(got).To(Equal(data)) + }) + + It("is idempotent on duplicate writes and preserves the original content", func() { + data := []byte("dup") + h, _ := hashImage(bytes.NewReader(data)) + Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed()) + // A duplicate write only touches mtime; passing different bytes under the same + // hash proves the second reader is never consumed to overwrite the file. + Expect(store.Write(h, "image/png", bytes.NewReader([]byte("not-dup")))).To(Succeed()) + + rc, err := store.Open(h, "image/png") + Expect(err).ToNot(HaveOccurred()) + defer rc.Close() + got, err := io.ReadAll(rc) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(Equal(data)) + }) + + It("refreshes the mtime on a duplicate write", func() { + data := []byte("touch-me") + h, _ := hashImage(bytes.NewReader(data)) + Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed()) + old := time.Now().Add(-2 * time.Hour) + Expect(os.Chtimes(store.path(h, "image/png"), old, old)).To(Succeed()) + + Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed()) + + info, err := os.Stat(store.path(h, "image/png")) + Expect(err).ToNot(HaveOccurred()) + Expect(info.ModTime()).To(BeTemporally(">", time.Now().Add(-time.Minute))) + }) + + It("rewrites the bytes when the existing file vanished before the liveness touch", func() { + data := []byte("vanishing") + h, _ := hashImage(bytes.NewReader(data)) + for range 10 { + Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed()) + Expect(os.Remove(store.path(h, "image/png"))).To(Succeed()) + Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed()) + + rc, err := store.Open(h, "image/png") + Expect(err).ToNot(HaveOccurred()) + got, _ := io.ReadAll(rc) + rc.Close() + Expect(got).To(Equal(data)) + } + }) + + It("returns fs.ErrNotExist for missing images", func() { + _, err := store.Open("beefbeefbeefbeef", "image/jpeg") + Expect(os.IsNotExist(err)).To(BeTrue()) + }) + + It("rejects invalid hashes instead of panicking", func() { + for _, h := range []string{"", "ab", "BEEFBEEFBEEFBEEF", "../../../../etcpw", "beefbeefbeefbee/"} { + Expect(store.Write(h, "image/jpeg", bytes.NewReader([]byte("x")))).To(MatchError(ContainSubstring("invalid hash"))) + _, err := store.Open(h, "image/jpeg") + Expect(err).To(MatchError(ContainSubstring("invalid hash"))) + } + }) + + It("sweeps unknown files, keeps known ones", func() { + d1 := []byte("keep-me") + h1, _ := hashImage(bytes.NewReader(d1)) + Expect(store.Write(h1, "image/jpeg", bytes.NewReader(d1))).To(Succeed()) + d2 := []byte("orphan") + h2, _ := hashImage(bytes.NewReader(d2)) + Expect(store.Write(h2, "image/jpeg", bytes.NewReader(d2))).To(Succeed()) + + old := time.Now().Add(-2 * time.Hour) + Expect(os.Chtimes(store.path(h2, "image/jpeg"), old, old)).To(Succeed()) + + removed, err := store.Sweep(ctx, time.Now().Add(-time.Hour), func(h, _ string) bool { return h == h1 }) + Expect(err).ToNot(HaveOccurred()) + Expect(removed).To(Equal(1)) + _, err = store.Open(h2, "image/jpeg") + Expect(os.IsNotExist(err)).To(BeTrue()) + rc, err := store.Open(h1, "image/jpeg") + Expect(err).ToNot(HaveOccurred()) + rc.Close() + }) + + It("sweeps a stale mime variant of a known hash, keeps the current one", func() { + data := []byte("same-bytes") + h, _ := hashImage(bytes.NewReader(data)) + Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed()) + Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed()) + old := time.Now().Add(-2 * time.Hour) + Expect(os.Chtimes(store.path(h, "image/png"), old, old)).To(Succeed()) + Expect(os.Chtimes(store.path(h, "image/jpeg"), old, old)).To(Succeed()) + + // The recorded mime is image/jpeg, so the .png variant is obsolete. + removed, err := store.Sweep(ctx, time.Now().Add(-time.Hour), func(hash, ext string) bool { + return hash == h && ext == ".jpg" + }) + Expect(err).ToNot(HaveOccurred()) + Expect(removed).To(Equal(1)) + _, err = store.Open(h, "image/png") + Expect(os.IsNotExist(err)).To(BeTrue()) + rc, err := store.Open(h, "image/jpeg") + Expect(err).ToNot(HaveOccurred()) + rc.Close() + }) + + It("keeps young unknown files inside the grace window", func() { + d := []byte("fresh-orphan") + h, _ := hashImage(bytes.NewReader(d)) + Expect(store.Write(h, "image/jpeg", bytes.NewReader(d))).To(Succeed()) + + removed, err := store.Sweep(ctx, time.Now().Add(-time.Hour), func(string, string) bool { return false }) + Expect(err).ToNot(HaveOccurred()) + Expect(removed).To(Equal(0)) + rc, err := store.Open(h, "image/jpeg") + Expect(err).ToNot(HaveOccurred()) + rc.Close() + }) + + It("removes abandoned temp files past the grace window, keeps fresh ones", func() { + oldTmp := filepath.Join(root, ".old.tmp") + Expect(os.WriteFile(oldTmp, []byte("x"), 0600)).To(Succeed()) + old := time.Now().Add(-2 * time.Hour) + Expect(os.Chtimes(oldTmp, old, old)).To(Succeed()) + + freshTmp := filepath.Join(root, ".fresh.tmp") + Expect(os.WriteFile(freshTmp, []byte("y"), 0600)).To(Succeed()) + + removed, err := store.Sweep(ctx, time.Now().Add(-time.Hour), func(string, string) bool { return true }) + Expect(err).ToNot(HaveOccurred()) + Expect(removed).To(Equal(1)) + Expect(oldTmp).ToNot(BeAnExistingFile()) + Expect(freshTmp).To(BeAnExistingFile()) + }) + + It("keeps sweeping past a file it cannot remove", func() { + tests.SkipOnWindows("uses Unix file permission bits") + if os.Geteuid() == 0 { + Skip("read-only dir cannot block root (e.g. tests in a container)") + } + old := time.Now().Add(-2 * time.Hour) + // "blocked" sorts before "ok", so the walk hits the unremovable file first. + blockedDir := filepath.Join(root, "blocked") + Expect(os.MkdirAll(blockedDir, 0755)).To(Succeed()) + blocked := filepath.Join(blockedDir, "a.jpg") + Expect(os.WriteFile(blocked, []byte("x"), 0600)).To(Succeed()) + Expect(os.Chtimes(blocked, old, old)).To(Succeed()) + + okDir := filepath.Join(root, "ok") + Expect(os.MkdirAll(okDir, 0755)).To(Succeed()) + reachable := filepath.Join(okDir, "b.jpg") + Expect(os.WriteFile(reachable, []byte("y"), 0600)).To(Succeed()) + Expect(os.Chtimes(reachable, old, old)).To(Succeed()) + + Expect(os.Chmod(blockedDir, 0500)).To(Succeed()) + DeferCleanup(func() { _ = os.Chmod(blockedDir, 0755) }) + + removed, err := store.Sweep(ctx, time.Now().Add(-time.Hour), func(string, string) bool { return false }) + Expect(err).ToNot(HaveOccurred()) + Expect(removed).To(Equal(1)) + Expect(blocked).To(BeAnExistingFile()) + Expect(reachable).ToNot(BeAnExistingFile()) + }) + + // Prune holds the worker's write lock for the whole sweep, and shutdown waits on the + // worker, so an uncancellable walk over a large store stalls it until SIGKILL. + It("abandons the walk when the context is cancelled", func() { + old := time.Now().Add(-2 * time.Hour) + for _, name := range []string{"a", "b", "c", "d"} { + p := filepath.Join(root, name+".jpg") + Expect(os.WriteFile(p, []byte("x"), 0600)).To(Succeed()) + Expect(os.Chtimes(p, old, old)).To(Succeed()) + } + cancelCtx, cancel := context.WithCancel(ctx) + cancel() + + _, err := store.Sweep(cancelCtx, time.Now().Add(-time.Hour), func(string, string) bool { return false }) + Expect(err).To(MatchError(context.Canceled)) + matches, _ := filepath.Glob(filepath.Join(root, "*.jpg")) + Expect(matches).To(HaveLen(4), "a cancelled sweep must not keep deleting") + }) +}) diff --git a/core/artwork/library_fs.go b/core/artwork/library_fs.go index ff557294e..6e6099325 100644 --- a/core/artwork/library_fs.go +++ b/core/artwork/library_fs.go @@ -2,7 +2,9 @@ package artwork import ( "context" + "net/url" "path/filepath" + "strings" "github.com/navidrome/navidrome/core/storage" "github.com/navidrome/navidrome/model" @@ -40,5 +42,23 @@ func loadLibraryView(ctx context.Context, ds model.DataStore, libID int) (librar if err != nil { return libraryView{}, err } - return libraryView{FS: fs, absRoot: lib.Path}, nil + return libraryView{FS: fs, absRoot: localOSRoot(lib.Path)}, nil +} + +// localOSRoot maps a library path to its on-disk root so Abs() yields paths os.Open/os.Stat accept: +// a file:// URL becomes its parsed OS path (bare paths already are; non-local schemes stay unchanged). +func localOSRoot(libPath string) string { + if !strings.Contains(libPath, "://") { + return libPath + } + u, err := url.Parse(libPath) + if err != nil || u.Scheme != storage.LocalSchemaID { + return libPath + } + // Windows drive URLs (file://C:/Music) put the volume in Host; rejoin it, matching + // core/storage/local's newLocalStorage so os.Open/os.Stat get a valid path. + if filepath.VolumeName(u.Host) != "" { + return filepath.Join(u.Host, u.Path) + } + return u.Path } diff --git a/core/artwork/library_fs_test.go b/core/artwork/library_fs_test.go index acf08fda3..22498e7a1 100644 --- a/core/artwork/library_fs_test.go +++ b/core/artwork/library_fs_test.go @@ -32,6 +32,13 @@ var _ = Describe("loadLibraryView", Ordered, func() { Expect(lib.absRoot).To(Equal("fake:///music")) }) + It("normalizes a library path to an OS root that Abs can join for os.Open/os.Stat", func() { + // file:// URLs become their parsed OS path; bare paths and non-local schemes are unchanged. + Expect(localOSRoot("file:///music/library")).To(Equal("/music/library")) + Expect(localOSRoot("/music/library")).To(Equal("/music/library")) + Expect(localOSRoot("fake:///music")).To(Equal("fake:///music")) + }) + It("returns an error when the library does not exist", func() { _, err := loadLibraryView(ctx, ds, 999) Expect(err).To(HaveOccurred()) diff --git a/core/artwork/playlist_cover.go b/core/artwork/playlist_cover.go new file mode 100644 index 000000000..e74b64808 --- /dev/null +++ b/core/artwork/playlist_cover.go @@ -0,0 +1,116 @@ +package artwork + +import ( + "bytes" + "context" + "image" + "image/draw" + "image/png" + "io" + "os" + "path/filepath" + "strings" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils" + xdraw "golang.org/x/image/draw" +) + +const tileSize = 600 + +// findPlaylistSidecarPath finds an image beside plsPath with the same base name (case-insensitive). +func findPlaylistSidecarPath(ctx context.Context, plsPath string) string { + if plsPath == "" { + return "" + } + dir := filepath.Dir(plsPath) + base := strings.TrimSuffix(filepath.Base(plsPath), filepath.Ext(plsPath)) + + entries, err := os.ReadDir(dir) + if err != nil { + log.Warn(ctx, "Artwork: Could not read directory for playlist sidecar", "dir", dir, err) + return "" + } + for _, entry := range entries { + name := entry.Name() + nameBase := utils.BaseName(name) + if !entry.IsDir() && strings.EqualFold(nameBase, base) && model.IsImageFile(name) { + return filepath.Join(dir, name) + } + } + return "" +} + +func rect(pos int) image.Rectangle { + r := image.Rectangle{} + switch pos { + case 1: + r.Min.X = tileSize / 2 + case 2: + r.Min.Y = tileSize / 2 + case 3: + r.Min.X = tileSize / 2 + r.Min.Y = tileSize / 2 + } + r.Max.X = r.Min.X + tileSize/2 + r.Max.Y = r.Min.Y + tileSize/2 + return r +} + +// fillCenter center-crops src and scales it to fill dstW x dstH exactly. +func fillCenter(src image.Image, dstW, dstH int) image.Image { + srcBounds := src.Bounds() + srcW := srcBounds.Dx() + srcH := srcBounds.Dy() + + srcAspect := float64(srcW) / float64(srcH) + dstAspect := float64(dstW) / float64(dstH) + + var cropRect image.Rectangle + if srcAspect > dstAspect { + cropW := int(float64(srcH) * dstAspect) + cropX := (srcW - cropW) / 2 + cropRect = image.Rect(srcBounds.Min.X+cropX, srcBounds.Min.Y, srcBounds.Min.X+cropX+cropW, srcBounds.Max.Y) + } else { + cropH := int(float64(srcW) / dstAspect) + cropY := (srcH - cropH) / 2 + cropRect = image.Rect(srcBounds.Min.X, srcBounds.Min.Y+cropY, srcBounds.Max.X, srcBounds.Min.Y+cropY+cropH) + } + + dst := image.NewNRGBA(image.Rect(0, 0, dstW, dstH)) + xdraw.CatmullRom.Scale(dst, dst.Bounds(), src, cropRect, draw.Src, nil) + return dst +} + +// decodeTile runs before the processor's size guards apply, so it enforces the caps itself. +func decodeTile(r io.ReadCloser) (image.Image, error) { + data, err := readCapped(r) + if err != nil { + return nil, err + } + img, _, err := decodeCapped(data) + if err != nil { + return nil, err + } + return fillCenter(img, tileSize/2, tileSize/2), nil +} + +func assembleTiles(tiles []image.Image) (io.ReadCloser, error) { + buf := new(bytes.Buffer) + var err error + if len(tiles) == 4 { + rgba := image.NewRGBA(image.Rectangle{Max: image.Point{X: tileSize - 1, Y: tileSize - 1}}) + draw.Draw(rgba, rect(0), tiles[0], image.Point{}, draw.Src) + draw.Draw(rgba, rect(1), tiles[1], image.Point{}, draw.Src) + draw.Draw(rgba, rect(2), tiles[2], image.Point{}, draw.Src) + draw.Draw(rgba, rect(3), tiles[3], image.Point{}, draw.Src) + err = png.Encode(buf, rgba) + } else { + err = png.Encode(buf, tiles[0]) + } + if err != nil { + return nil, err + } + return io.NopCloser(buf), nil +} diff --git a/core/artwork/processor.go b/core/artwork/processor.go new file mode 100644 index 000000000..c3cf15b71 --- /dev/null +++ b/core/artwork/processor.go @@ -0,0 +1,311 @@ +package artwork + +import ( + "bytes" + "context" + "encoding/base64" + "errors" + "fmt" + "image" + "image/draw" + _ "image/gif" // the only artwork format with no other importer in this package + "io" + "sync" + "time" + + "github.com/navidrome/navidrome/core/artwork/blurhash" + "github.com/navidrome/navidrome/core/artwork/dominant" + "github.com/navidrome/navidrome/core/artwork/thumbhash" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + xdraw "golang.org/x/image/draw" +) + +// outcome tells the worker whether to delete the queue row (found/absent) or reschedule it. +type outcome int + +const ( + outcomeFound outcome = iota + // outcomeFoundStale: state was written and is served, but a higher-priority external + // source failed, so the row must retry to give it another chance. + outcomeFoundStale + outcomeAbsent + outcomeFailed +) + +func (o outcome) String() string { + switch o { + case outcomeFound: + return "found" + case outcomeFoundStale: + return "foundStale" + case outcomeAbsent: + return "absent" + default: + return "failed" + } +} + +// thumbnailSize is the max dimension fed to both hash encoders; thumbhash rejects anything larger. +const thumbnailSize = 100 + +// maxImageBytes caps a resolved image read: a user-editable ExternalImageURL could point at +// an arbitrarily large endpoint. +const maxImageBytes = 20 << 20 + +// maxImagePixels guards against decompression bombs: a tiny file can declare a canvas that +// image.Decode would expand into gigabytes. +const maxImagePixels = 64 << 20 + +// acquired is what a successful acquire persisted, handed back so the caller can warm the resize +// cache without re-reading the rows and the file it just wrote. +type acquired struct { + ia *model.ItemArtwork + mime string + data []byte +} + +// processor turns one queue item into stored artwork; settling the queue row is the Worker's job. +type processor struct { + ds model.DataStore + store *ImageStore + resolver *resolver + pruneLock sync.Locker +} + +// acquire resolves one queue item end to end: find an image, hash/decode/ +// blurhash it, place its bytes, and persist the resulting state. +func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (out outcome, got *acquired) { + repo := p.ds.Artwork(ctx) + start := time.Now() + defer func() { + log.Debug(ctx, "Artwork: Acquisition finished", "kind", item.ItemKind, "id", item.ItemID, + "outcome", out, "elapsed", time.Since(start)) + }() + + res, err := p.resolver.resolve(ctx, item) + if err != nil { + log.Warn(ctx, "Artwork: Could not resolve item", "kind", item.ItemKind, "id", item.ItemID, err) + return outcomeFailed, nil + } + if res.reader == nil { + if res.extError || res.localError { + // A fault is not a definitive "no image": never settle absent, keep serving old state. + log.Debug(ctx, "Artwork: No image, but a source faulted; keeping previous state", + "kind", item.ItemKind, "id", item.ItemID, "extError", res.extError, "localError", res.localError) + return outcomeFailed, nil + } + return writeAbsent(ctx, repo, item), nil + } + defer res.reader.Close() + + readStart := time.Now() + data, err := readCapped(res.reader) + if err != nil { + log.Warn(ctx, "Artwork: Failed to read resolved image", "kind", item.ItemKind, "id", item.ItemID, "source", res.source, err) + return outcomeFailed, nil + } + log.Debug(ctx, "Artwork: Read resolved image", "kind", item.ItemKind, "id", item.ItemID, + "source", res.source, "bytes", len(data), "elapsed", time.Since(readStart)) + + hashStart := time.Now() + hash, err := hashImage(bytes.NewReader(data)) + if err != nil { + log.Warn(ctx, "Artwork: Failed to hash image", "kind", item.ItemKind, "id", item.ItemID, err) + return outcomeFailed, nil + } + log.Trace(ctx, "Artwork: Hashed image", "kind", item.ItemKind, "id", item.ItemID, + "hash", hash, "bytes", len(data), "elapsed", time.Since(hashStart)) + + art, err := repo.GetImage(hash) + switch { + case err == nil: + log.Debug(ctx, "Artwork: Reusing a known image, skipping decode", "kind", item.ItemKind, + "id", item.ItemID, "hash", hash) + case errors.Is(err, model.ErrNotFound): + decodeStart := time.Now() + art, err = decodeArtwork(ctx, hash, data) + if err != nil { + log.Warn(ctx, "Artwork: Failed to decode resolved image", "kind", item.ItemKind, "id", item.ItemID, err) + return outcomeFailed, nil + } + log.Debug(ctx, "Artwork: Decoded new image", "kind", item.ItemKind, "id", item.ItemID, "hash", hash, + "width", art.Width, "height", art.Height, "mime", art.Mime, "elapsed", time.Since(decodeStart)) + default: + log.Warn(ctx, "Artwork: Failed to look up image hash", "kind", item.ItemKind, "id", item.ItemID, err) + return outcomeFailed, nil + } + art.SizeBytes = int64(len(data)) + + ia, err := p.persist(repo, item, art, res, data) + if err != nil { + log.Warn(ctx, "Artwork: Failed to persist resolved image", "kind", item.ItemKind, "id", item.ItemID, err) + return outcomeFailed, nil + } + got = &acquired{ia: ia, mime: art.Mime, data: data} + if res.extError { + log.Debug(ctx, "Artwork: Serving a lower-priority source after an external failure", + "kind", item.ItemKind, "id", item.ItemID, "source", res.source) + return outcomeFoundStale, got + } + return outcomeFound, got +} + +// persist places the bytes and commits the rows referencing them, excluding Prune for that +// window only so a slow resolution can never hold it off. +func (p *processor) persist(repo model.ArtworkRepository, item model.ArtworkQueueItem, + art *model.Artwork, res resolution, data []byte, +) (*model.ItemArtwork, error) { + if p.pruneLock != nil { + p.pruneLock.Lock() + defer p.pruneLock.Unlock() + } + sourcePath, refMtime, err := placeBytes(p.store, art, res, data) + if err != nil { + return nil, fmt.Errorf("writing image store: %w", err) + } + if err := repo.PutImage(art); err != nil { + return nil, fmt.Errorf("persisting artwork image: %w", err) + } + ia := &model.ItemArtwork{ + ItemKind: item.ItemKind, + ItemID: item.ItemID, + ImageType: item.ImageType, + Hash: art.Hash, + Source: res.source, + SourcePath: sourcePath, + RefMtime: refMtime, + AttemptedAt: time.Now(), + } + // PutItemArtwork stamps UpdatedAt on ia, so the returned struct matches the persisted row. + if err := repo.PutItemArtwork(ia); err != nil { + return nil, fmt.Errorf("persisting item artwork state: %w", err) + } + return ia, nil +} + +// writeAbsent records a known-absent state: every source answered definitively "no". +func writeAbsent(ctx context.Context, repo model.ArtworkRepository, item model.ArtworkQueueItem) outcome { + err := repo.PutItemArtwork(&model.ItemArtwork{ + ItemKind: item.ItemKind, + ItemID: item.ItemID, + ImageType: item.ImageType, + AttemptedAt: time.Now(), + }) + if err != nil { + log.Warn(ctx, "Artwork: Failed to persist absent state", "kind", item.ItemKind, "id", item.ItemID, err) + return outcomeFailed + } + log.Debug(ctx, "Artwork: Settled absent, every source answered definitively", + "kind", item.ItemKind, "id", item.ItemID) + return outcomeAbsent +} + +func readCapped(r io.Reader) ([]byte, error) { + data, err := io.ReadAll(io.LimitReader(r, maxImageBytes+1)) + if err != nil { + return nil, err + } + if len(data) > maxImageBytes { + return nil, fmt.Errorf("image exceeds size cap %d", maxImageBytes) + } + return data, nil +} + +// decodeCapped rejects declared dimensions over maxImagePixels before the full-decode allocation. +func decodeCapped(data []byte) (image.Image, string, error) { + cfg, format, err := image.DecodeConfig(bytes.NewReader(data)) + if err != nil { + return nil, "", fmt.Errorf("decode image config: %w", err) + } + // Compared by division so the cap cannot be defeated by an int64 overflow. + if cfg.Width <= 0 || cfg.Height <= 0 || cfg.Width > maxImagePixels/cfg.Height { + return nil, "", fmt.Errorf("image dimensions %dx%d exceed pixel cap %d", cfg.Width, cfg.Height, maxImagePixels) + } + img, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + return nil, "", fmt.Errorf("decode image: %w", err) + } + return img, format, nil +} + +// decodeArtwork builds a new Artwork row from raw bytes: dimensions, mime and the two +// placeholder hashes, both encoded from one shared downscaled thumbnail. +func decodeArtwork(ctx context.Context, hash string, data []byte) (*model.Artwork, error) { + img, format, err := decodeCapped(data) + if err != nil { + return nil, err + } + + thumb := makeThumbnail(img, thumbnailSize) + bh, err := blurhash.Encode(thumb) + if err != nil { + log.Warn(ctx, "Artwork: Blurhash encoding failed", "hash", hash, err) + bh = "" + } + + var th string + if raw, err := thumbhash.Encode(thumb); err != nil { + log.Warn(ctx, "Artwork: Thumbhash encoding failed", "hash", hash, err) + } else { + th = base64.StdEncoding.EncodeToString(raw) + } + + return &model.Artwork{ + Hash: hash, + Mime: mimeForFormat(format), + Width: img.Bounds().Dx(), + Height: img.Bounds().Dy(), + BlurHash: bh, + ThumbHash: th, + DominantColor: dominant.Color(thumb), + }, nil +} + +// makeThumbnail downscales img to fit within maxSize on its longest side; it never upscales. +func makeThumbnail(img image.Image, maxSize int) image.Image { + b := img.Bounds() + w, h := b.Dx(), b.Dy() + if w <= maxSize && h <= maxSize { + return toFastScaleType(img) + } + scale := float64(maxSize) / float64(max(w, h)) + // NRGBA, not RGBA: thumbhash requires straight alpha, and blurhash reads this type without + // converting, so neither encoder allocates a second copy of the thumbnail. + dst := image.NewNRGBA(image.Rect(0, 0, max(1, int(float64(w)*scale)), max(1, int(float64(h)*scale)))) + xdraw.CatmullRom.Scale(dst, dst.Bounds(), toFastScaleType(img), b, draw.Src, nil) + return dst +} + +// isFileBacked reports whether the bytes already live in a library/upload file, so the +// content-addressed store must not duplicate them. +func isFileBacked(source string) bool { + return source == "folder" || source == "upload" +} + +// placeBytes reports the item's backing-file provenance and writes the bytes into the store +// for the sources that have none. +func placeBytes(store *ImageStore, art *model.Artwork, res resolution, data []byte) (sourcePath string, refMtime int64, err error) { + if isFileBacked(res.source) { + return res.sourcePath, res.refMtime, nil + } + if res.source == "embedded" { + sourcePath, refMtime = res.sourcePath, res.refMtime + } + return sourcePath, refMtime, store.Write(art.Hash, art.Mime, bytes.NewReader(data)) +} + +// mimeForFormat maps an image.Decode format name to its MIME type; extForMime is the inverse. +func mimeForFormat(format string) string { + switch format { + case "jpeg": + return "image/jpeg" + case "png": + return "image/png" + case "gif": + return "image/gif" + case "webp": + return "image/webp" + } + return "application/octet-stream" +} diff --git a/core/artwork/processor_test.go b/core/artwork/processor_test.go new file mode 100644 index 000000000..d326dde09 --- /dev/null +++ b/core/artwork/processor_test.go @@ -0,0 +1,495 @@ +package artwork + +import ( + "context" + "encoding/binary" + "errors" + "hash/crc32" + "image" + "image/color" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// DecodeConfig reads only the header, so the pixel data can be omitted entirely. +func pngHeaderWithDims(w, h uint32) []byte { + ihdr := make([]byte, 13) + binary.BigEndian.PutUint32(ihdr[0:], w) + binary.BigEndian.PutUint32(ihdr[4:], h) + ihdr[8] = 8 // bit depth + ihdr[9] = 2 // color type: truecolor + chunk := append([]byte("IHDR"), ihdr...) + out := []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a} + out = binary.BigEndian.AppendUint32(out, uint32(len(ihdr))) + out = append(out, chunk...) + return binary.BigEndian.AppendUint32(out, crc32.ChecksumIEEE(chunk)) +} + +var _ = Describe("processor.acquire", func() { + var ( + ctx context.Context + ds *tests.MockDataStore + folderRepo *fakeFolderRepo + libRepo *tests.MockLibraryRepo + ffm *tests.MockFFmpeg + ag *agents.Agents + store *ImageStore + artRepo *tests.MockArtworkRepo + repoRoot string + proc *processor + ) + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + ctx = context.Background() + var err error + repoRoot, err = os.Getwd() + Expect(err).ToNot(HaveOccurred()) + + folderRepo = &fakeFolderRepo{} + libRepo = &tests.MockLibraryRepo{} + libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) + ffm = tests.NewMockFFmpeg("") + ag = agents.GetAgents(&tests.MockDataStore{}, nil) + artRepo = tests.CreateMockArtworkRepo() + ds = &tests.MockDataStore{ + MockedFolder: folderRepo, + MockedLibrary: libRepo, + MockedArtwork: artRepo, + } + ds.MockedAlbum = tests.CreateMockAlbumRepo() + store = NewImageStore(GinkgoT().TempDir()) + proc = &processor{ds: ds, store: store, resolver: newResolver(ds, ag, ffm, nil)} + + conf.Server.CoverArtPriority = "cover.jpg, embedded" + }) + + It("found-folder: persists state from a folder image, writes no store file, keeps sourcePath/refMtime", func() { + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"cover.jpg"}, + }} + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "al1", Name: "Album", FolderIDs: []string{"f1"}}, + }) + + out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1"}) + Expect(out).To(Equal(outcomeFound)) + + ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Hash).ToNot(BeEmpty()) + Expect(ia.Source).To(Equal("folder")) + Expect(filepath.ToSlash(ia.SourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/cover.jpg")) + Expect(ia.RefMtime).To(BeNumerically(">", 0)) + + art, err := artRepo.GetImage(ia.Hash) + Expect(err).ToNot(HaveOccurred()) + // Every placeholder is derived from the one shared thumbnail, so all three land together. + Expect(art.BlurHash).ToNot(BeEmpty()) + Expect(art.ThumbHash).ToNot(BeEmpty()) + Expect(art.DominantColor).To(MatchRegexp(`^#[0-9a-f]{6}$`)) + _, err = store.Open(ia.Hash, art.Mime) + Expect(os.IsNotExist(err)).To(BeTrue(), "folder-backed art must not be duplicated into the store") + }) + + Describe("prune lock scope", func() { + var lock *countingLocker + + BeforeEach(func() { + lock = &countingLocker{} + proc.pruneLock = lock + }) + + It("holds it once across the write window", func() { + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"cover.jpg"}, + }} + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "alL1", Name: "Album", FolderIDs: []string{"f1"}}, + }) + + out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alL1"}) + Expect(out).To(Equal(outcomeFound)) + Expect(lock.locks).To(BeNumerically(">", 0), "the write window must exclude prune") + Expect(lock.held()).To(BeFalse(), "the window must close before acquire returns") + }) + + // Resolution can reach the network, so holding the lock across it would let one slow + // provider block prune, and every drain queued behind prune's writer. + It("never takes it while only resolving", func() { + folderRepo.result = nil + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "alL2", Name: "Album", FolderIDs: []string{"f1"}}, + }) + + out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alL2"}) + Expect(out).To(Equal(outcomeAbsent)) + Expect(lock.locks).To(BeZero()) + }) + }) + + It("found-embedded: writes a store file and computes a non-empty blurhash from a real fixture", func() { + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "al2", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}}, + }) + folderRepo.result = nil + + out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2"}) + Expect(out).To(Equal(outcomeFound)) + + ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al2", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Source).To(Equal("embedded")) + Expect(filepath.ToSlash(ia.SourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/test.mp3")) + + art, err := artRepo.GetImage(ia.Hash) + Expect(err).ToNot(HaveOccurred()) + Expect(art.BlurHash).ToNot(BeEmpty()) + + rc, err := store.Open(ia.Hash, art.Mime) + Expect(err).ToNot(HaveOccurred()) + rc.Close() + }) + + It("absent: no local source and no external error persists a known-absent state", func() { + folderRepo.result = nil + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "al3", Name: "Album"}, + }) + + out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3"}) + Expect(out).To(Equal(outcomeAbsent)) + + ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al3", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Hash).To(BeEmpty()) + Expect(ia.Source).To(BeEmpty()) + Expect(ia.AttemptedAt).To(BeTemporally("~", time.Now(), time.Second)) + }) + + It("failed-on-unreadable-local: a listed cover that will not open never records absent", func() { + conf.Server.CoverArtPriority = "cover.jpg" + // A folder listing that names a cover the FS will not hand over: a stale mount. + libRoot := GinkgoT().TempDir() + Expect(os.MkdirAll(filepath.Join(libRoot, "an-album"), 0o755)).To(Succeed()) + libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(libRoot)}}) + folderRepo.result = []model.Folder{{Path: "an-album", ImageFiles: []string{"cover.jpg"}}} + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "al-io", Name: "Album", FolderIDs: []string{"f1"}}, + }) + + out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al-io"}) + Expect(out).To(Equal(outcomeFailed)) + + _, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al-io", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound), "an I/O fault must not be recorded as absent") + }) + + // An upload outranks every source, so an unreadable one must not let a lower one take over. + It("failed-on-unreadable-upload: an upload that will not open never records absent", func() { + if runtime.GOOS == "windows" { + // The file would still open, so the spec would pass on the decode error instead. + Skip("chmod does not restrict read access on Windows") + } + radioRepo := tests.CreateMockedRadioRepo() + radioRepo.Data = map[string]*model.Radio{} + ds.MockedRadio = radioRepo + dir := GinkgoT().TempDir() + conf.Server.DataFolder = conf.NewDir(dir) + upload := model.UploadedImagePath(consts.EntityRadio, "ra-io.jpg") + Expect(os.MkdirAll(filepath.Dir(upload), 0o755)).To(Succeed()) + Expect(os.WriteFile(upload, []byte("x"), 0o600)).To(Succeed()) + Expect(os.Chmod(upload, 0o000)).To(Succeed()) // present, but unreadable + DeferCleanup(func() { _ = os.Chmod(upload, 0o600) }) + radioRepo.Data["ra-io"] = &model.Radio{ID: "ra-io", Name: "Station", UploadedImage: "ra-io.jpg"} + + out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra-io"}) + Expect(out).To(Equal(outcomeFailed)) + + _, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra-io", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound), "an unreadable upload must not be recorded as absent") + }) + + It("failed-on-extError: leaves the item's state untouched", func() { + conf.Server.CoverArtPriority = "external" + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "al4", Name: "Album"}, + }) + imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) + + out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4"}) + Expect(out).To(Equal(outcomeFailed)) + + _, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al4", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("found-stale: a fallback hit after a transient external failure persists state and returns outcomeFoundStale", func() { + conf.Server.CoverArtPriority = "external, cover.jpg" + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"cover.jpg"}, + }} + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "alstale", Name: "Album", FolderIDs: []string{"f1"}}, + }) + imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) + + out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alstale"}) + Expect(out).To(Equal(outcomeFoundStale)) + + ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alstale", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Hash).ToNot(BeEmpty()) + Expect(ia.Source).To(Equal("folder")) + }) + + It("found-external: persists source as external: and stores the fetched bytes", func() { + conf.Server.CoverArtPriority = "external" + imgBytes, err := os.ReadFile(filepath.Join(repoRoot, "tests/fixtures/artist/an-album/cover.jpg")) + Expect(err).ToNot(HaveOccurred()) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(imgBytes) + })) + DeferCleanup(srv.Close) + + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alext", Name: "Album"}}) + imageAgents(&fakeImageAgent{name: "deezerFake", imgs: []agents.ExternalImage{{URL: srv.URL, Size: 500}}}) + + out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alext"}) + Expect(out).To(Equal(outcomeFound)) + + ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alext", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Source).To(Equal("external:deezerFake")) + Expect(ia.Hash).ToNot(BeEmpty()) + + art, err := artRepo.GetImage(ia.Hash) + Expect(err).ToNot(HaveOccurred()) + rc, err := store.Open(ia.Hash, art.Mime) + Expect(err).ToNot(HaveOccurred()) + rc.Close() + }) + + It("dedup: a second item with identical bytes skips decode and reuses the artwork row", func() { + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"cover.jpg"}, + }} + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "al5", Name: "Album A", FolderIDs: []string{"f1"}}, + {ID: "al6", Name: "Album B", FolderIDs: []string{"f1"}}, + }) + + out1, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al5"}) + Expect(out1).To(Equal(outcomeFound)) + ia1, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al5", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + + // A re-decode instead of a hash dedup would overwrite this sentinel. + poisoned := artRepo.Data[ia1.Hash] + poisoned.BlurHash = "SENTINEL" + artRepo.Data[ia1.Hash] = poisoned + + out2, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al6"}) + Expect(out2).To(Equal(outcomeFound)) + ia2, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al6", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia2.Hash).To(Equal(ia1.Hash)) + + reused, err := artRepo.GetImage(ia1.Hash) + Expect(err).ToNot(HaveOccurred()) + Expect(reused.BlurHash).To(Equal("SENTINEL")) + }) + + It("two items, two files, identical bytes: each item keeps its own provenance; the shared artwork row is written once", func() { + // Byte-identical files share one hash, but provenance is per item. + libRoot := GinkgoT().TempDir() + imgBytes, err := os.ReadFile(filepath.Join(repoRoot, "tests/fixtures/artist/an-album/cover.jpg")) + Expect(err).ToNot(HaveOccurred()) + for sub, mtime := range map[string]int64{"album-a": 1000, "album-b": 2000} { + dir := filepath.Join(libRoot, sub) + Expect(os.MkdirAll(dir, 0755)).To(Succeed()) + img := filepath.Join(dir, "cover.jpg") + Expect(os.WriteFile(img, imgBytes, 0600)).To(Succeed()) + Expect(os.Chtimes(img, time.Unix(mtime, 0), time.Unix(mtime, 0))).To(Succeed()) + } + libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(libRoot)}}) + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "alA", Name: "Album A", FolderIDs: []string{"fa"}}, + {ID: "alB", Name: "Album B", FolderIDs: []string{"fb"}}, + }) + + folderRepo.result = []model.Folder{{Path: "album-a", ImageFiles: []string{"cover.jpg"}}} + outN, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alA"}) + Expect(outN).To(Equal(outcomeFound)) + iaA, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alA", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(iaA.Source).To(Equal("folder")) + Expect(filepath.ToSlash(iaA.SourcePath)).To(HaveSuffix("album-a/cover.jpg")) + Expect(iaA.RefMtime).To(Equal(time.Unix(1000, 0).UnixNano())) + + // A re-decode instead of a hash dedup would overwrite this sentinel. + poisoned := artRepo.Data[iaA.Hash] + poisoned.BlurHash = "SENTINEL" + artRepo.Data[iaA.Hash] = poisoned + + folderRepo.result = []model.Folder{{Path: "album-b", ImageFiles: []string{"cover.jpg"}}} + outN, _ = proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alB"}) + Expect(outN).To(Equal(outcomeFound)) + iaB, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alB", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(iaB.Hash).To(Equal(iaA.Hash)) + Expect(filepath.ToSlash(iaB.SourcePath)).To(HaveSuffix("album-b/cover.jpg")) + Expect(iaB.RefMtime).To(Equal(time.Unix(2000, 0).UnixNano())) + + iaAafter, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alA", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(filepath.ToSlash(iaAafter.SourcePath)).To(HaveSuffix("album-a/cover.jpg")) + Expect(iaAafter.RefMtime).To(Equal(time.Unix(1000, 0).UnixNano())) + + Expect(artRepo.Data).To(HaveLen(1)) + reused, err := artRepo.GetImage(iaA.Hash) + Expect(err).ToNot(HaveOccurred()) + Expect(reused.BlurHash).To(Equal("SENTINEL")) + }) + + It("decode failure on found bytes: fails without writing state", func() { + tmpDir := GinkgoT().TempDir() + conf.Server.DataFolder = conf.NewDir(tmpDir) + Expect(os.MkdirAll(filepath.Join(tmpDir, "artwork", "radio"), 0755)).To(Succeed()) + imgPath := filepath.Join(tmpDir, "artwork", "radio", "ra1_test.jpg") + Expect(os.WriteFile(imgPath, []byte("not actually an image"), 0600)).To(Succeed()) + + radioRepo := tests.CreateMockedRadioRepo() + radioRepo.Data = map[string]*model.Radio{"ra1": {ID: "ra1", Name: "Radio", UploadedImage: "ra1_test.jpg"}} + ds.MockedRadio = radioRepo + + out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra1"}) + Expect(out).To(Equal(outcomeFailed)) + + _, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("oversized read: a resolved image larger than the cap fails without writing state", func() { + tmpDir := GinkgoT().TempDir() + conf.Server.DataFolder = conf.NewDir(tmpDir) + Expect(os.MkdirAll(filepath.Join(tmpDir, "artwork", "radio"), 0755)).To(Succeed()) + imgPath := filepath.Join(tmpDir, "artwork", "radio", "big_test.jpg") + f, err := os.Create(imgPath) + Expect(err).ToNot(HaveOccurred()) + Expect(f.Truncate(maxImageBytes + 1)).To(Succeed()) + Expect(f.Close()).To(Succeed()) + + radioRepo := tests.CreateMockedRadioRepo() + radioRepo.Data = map[string]*model.Radio{"big": {ID: "big", Name: "Radio", UploadedImage: "big_test.jpg"}} + ds.MockedRadio = radioRepo + + out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "big"}) + Expect(out).To(Equal(outcomeFailed)) + + _, err = artRepo.GetItemArtwork(model.KindRadioArtwork, "big", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + // The cap is compared by division so an overflowing product cannot slip past it; no supported + // format can declare these dimensions, so this pins the arithmetic, not a live hole. + DescribeTable("rejects out-of-range declared dimensions", + func(w, h uint32) { + _, err := decodeArtwork(ctx, "bomb", pngHeaderWithDims(w, h)) + Expect(err).To(HaveOccurred()) + }, + Entry("both dimensions at the 32-bit maximum", uint32(0xffffffff), uint32(0xffffffff)), + Entry("both dimensions at the signed 32-bit maximum", uint32(0x7fffffff), uint32(0x7fffffff)), + Entry("zero width", uint32(0), uint32(100)), + Entry("zero height", uint32(100), uint32(0)), + ) + + It("decompression bomb: rejects huge declared dimensions before the full decode", func() { + data := pngHeaderWithDims(50000, 50000) // 2.5 gigapixels, far above the cap + _, err := decodeArtwork(ctx, "bomb", data) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("dimensions")) + }) + + It("store write failure: fails without writing state", func() { + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "al7", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}}, + }) + folderRepo.result = nil + + // A store root that is a plain file makes every MkdirAll under it fail. + blockedRoot := filepath.Join(GinkgoT().TempDir(), "not-a-dir") + Expect(os.WriteFile(blockedRoot, []byte("x"), 0600)).To(Succeed()) + proc.store = NewImageStore(blockedRoot) + + out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al7"}) + Expect(out).To(Equal(outcomeFailed)) + + _, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al7", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + }) +}) + +type countingLocker struct { + locks int + unlocks int +} + +func (l *countingLocker) Lock() { l.locks++ } +func (l *countingLocker) Unlock() { l.unlocks++ } +func (l *countingLocker) held() bool { return l.locks != l.unlocks } + +var _ = Describe("makeThumbnail", func() { + // Both encoders read Pix directly and thumbhash needs straight alpha, so emitting NRGBA is + // what keeps either of them from allocating a converted copy of the thumbnail. + It("emits NRGBA when it downscales", func() { + src := image.NewRGBA(image.Rect(0, 0, 400, 300)) + Expect(makeThumbnail(src, 100)).To(BeAssignableToTypeOf(&image.NRGBA{})) + }) + + It("fits the longest side to maxSize and keeps the aspect ratio", func() { + src := image.NewRGBA(image.Rect(0, 0, 400, 300)) + b := makeThumbnail(src, 100).Bounds() + Expect(b.Dx()).To(Equal(100)) + Expect(b.Dy()).To(Equal(75)) + }) + + It("never upscales an image already within bounds", func() { + src := image.NewRGBA(image.Rect(0, 0, 40, 30)) + Expect(makeThumbnail(src, 100).Bounds()).To(Equal(src.Bounds())) + }) + + // A fully transparent pixel's colour cannot survive any resample, since the scaler works in + // premultiplied space and multiplying by zero is not invertible. Partial alpha is the case + // that distinguishes straight storage from premultiplied. + It("keeps partly transparent colour straight rather than premultiplied", func() { + src := image.NewNRGBA(image.Rect(0, 0, 400, 400)) + for y := range 400 { + for x := range 400 { + src.SetNRGBA(x, y, color.NRGBA{R: 200, G: 40, B: 90, A: 128}) + } + } + thumb, ok := makeThumbnail(src, 100).(*image.NRGBA) + Expect(ok).To(BeTrue()) + // Premultiplied storage would have halved this to ~100. + Expect(thumb.Pix[0]).To(BeNumerically("==", 200)) + Expect(thumb.Pix[3]).To(BeNumerically("==", 128)) + }) +}) diff --git a/core/artwork/prune.go b/core/artwork/prune.go new file mode 100644 index 000000000..c79458b8f --- /dev/null +++ b/core/artwork/prune.go @@ -0,0 +1,63 @@ +package artwork + +import ( + "context" + "time" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" +) + +// pruneMinAge guards the window between artwork insert and item_artwork upsert. +const pruneMinAge = time.Hour + +func prune(ctx context.Context, ds model.DataStore, store *ImageStore) error { + start := time.Now() + defer func() { log.Debug(ctx, "Artwork: Prune finished", "elapsed", time.Since(start)) }() + repo := ds.Artwork(ctx) + + purged, err := repo.PurgeDanglingItems() + if err != nil { + return err + } + if purged > 0 { + log.Info(ctx, "Artwork: Purged dangling item state", "count", purged) + } + + // Queue rows for deleted entities would otherwise retry forever (Get -> not found -> failed). + queuePurged, err := ds.ArtworkQueue(ctx).PurgeDangling() + if err != nil { + return err + } + if queuePurged > 0 { + log.Info(ctx, "Artwork: Purged dangling queue rows", "count", queuePurged) + } + + // Files younger than the grace window may belong to acquisitions whose rows aren't committed yet. + cutoff := time.Now().Add(-pruneMinAge) + orphans, err := repo.PurgeOrphans(cutoff) + if err != nil { + return err + } + if orphans > 0 { + log.Info(ctx, "Artwork: Removed orphan image rows", "count", orphans) + } + + // Read after the delete, so the sweep below reclaims the files of the rows just removed. + mimes, err := repo.GetMimeByHash() + if err != nil { + return err + } + removed, err := store.Sweep(ctx, cutoff, func(hash, ext string) bool { + // A known hash under a stale extension is a superseded mime variant — reclaim it. + m, ok := mimes[hash] + return ok && ext == extForMime(m) + }) + if err != nil { + return err + } + if removed > 0 { + log.Info(ctx, "Artwork: Swept stray files", "count", removed) + } + return nil +} diff --git a/core/artwork/prune_test.go b/core/artwork/prune_test.go new file mode 100644 index 000000000..8b8504f89 --- /dev/null +++ b/core/artwork/prune_test.go @@ -0,0 +1,239 @@ +package artwork + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "time" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type flakyGetArtworkRepo struct { + *tests.MockArtworkRepo +} + +func (f *flakyGetArtworkRepo) GetMimeByHash() (map[string]string, error) { + return nil, errors.New("db locked") +} + +var _ = Describe("Prune", func() { + var ds *tests.MockDataStore + var store *ImageStore + var awRepo *tests.MockArtworkRepo + + BeforeEach(func() { + ds = &tests.MockDataStore{} + awRepo = ds.Artwork(context.Background()).(*tests.MockArtworkRepo) + store = NewImageStore(GinkgoT().TempDir()) + }) + + // PutImage refreshes created_at like the SQL repo, so fixtures are aged directly. + ageArtwork := func(h string, t time.Time) { + a := awRepo.Data[h] + a.CreatedAt = t + awRepo.Data[h] = a + } + + It("purges dangling item_artwork state for gone entities, summed across kinds", func() { + Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "gone-album", ImageType: model.ImageTypePrimary})).To(Succeed()) + Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "gone-artist", ImageType: model.ImageTypePrimary})).To(Succeed()) + Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "live-artist", ImageType: model.ImageTypePrimary})).To(Succeed()) + awRepo.ExistingIDs = map[string]map[string]bool{ + "al": {}, + "ar": {"live-artist": true}, + } + + Expect(prune(context.Background(), ds, store)).To(Succeed()) + + _, err := awRepo.GetItemArtwork(model.KindAlbumArtwork, "gone-album", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + _, err = awRepo.GetItemArtwork(model.KindArtistArtwork, "gone-artist", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + _, err = awRepo.GetItemArtwork(model.KindArtistArtwork, "live-artist", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + }) + + It("purges dangling artwork_queue rows for gone entities", func() { + queueRepo := tests.CreateMockArtworkQueueRepo() + Expect(queueRepo.Enqueue( + model.ArtworkQueueItem{ItemKind: "al", ItemID: "gone-album", ImageType: model.ImageTypePrimary}, + model.ArtworkQueueItem{ItemKind: "al", ItemID: "live-album", ImageType: model.ImageTypePrimary}, + )).To(Succeed()) + queueRepo.ExistingIDs = map[string]map[string]bool{"al": {"live-album": true}} + ds.MockedArtworkQueue = queueRepo + + Expect(prune(context.Background(), ds, store)).To(Succeed()) + + Expect(findQueued(queueRepo, "al", "gone-album")).To(BeNil()) + Expect(findQueued(queueRepo, "al", "live-album")).ToNot(BeNil()) + }) + + It("deletes orphan rows and their store files, keeps referenced ones", func() { + data := []byte("orphan-bytes") + h, _ := hashImage(bytes.NewReader(data)) + Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed()) + old := time.Now().Add(-2 * time.Hour) + Expect(os.Chtimes(store.path(h, "image/jpeg"), old, old)).To(Succeed()) + Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed()) + ageArtwork(h, old) + + kept := []byte("kept-bytes") + hk, _ := hashImage(bytes.NewReader(kept)) + Expect(store.Write(hk, "image/jpeg", bytes.NewReader(kept))).To(Succeed()) + Expect(awRepo.PutImage(&model.Artwork{Hash: hk, Mime: "image/jpeg"})).To(Succeed()) + + Expect(prune(context.Background(), ds, store)).To(Succeed()) + + _, err := awRepo.GetImage(h) + Expect(err).To(MatchError(model.ErrNotFound)) + _, err = store.Open(h, "image/jpeg") + Expect(os.IsNotExist(err)).To(BeTrue()) + rc, err := store.Open(hk, "image/jpeg") + Expect(err).ToNot(HaveOccurred()) + rc.Close() + }) + + It("spares an aged row that item_artwork state still references", func() { + data := []byte("reacquired-bytes") + h, _ := hashImage(bytes.NewReader(data)) + Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed()) + Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed()) + ageArtwork(h, time.Now().Add(-2*time.Hour)) + Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "a1", + ImageType: model.ImageTypePrimary, Hash: h, Source: "folder"})).To(Succeed()) + + Expect(prune(context.Background(), ds, store)).To(Succeed()) + + _, err := awRepo.GetImage(h) + Expect(err).ToNot(HaveOccurred()) + rc, err := store.Open(h, "image/jpeg") + Expect(err).ToNot(HaveOccurred()) + rc.Close() + }) + + It("spares an unreferenced row recreated inside the grace window", func() { + data := []byte("fresh-reacquired-bytes") + h, _ := hashImage(bytes.NewReader(data)) + Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed()) + // Reacquisition refreshed created_at, so the row is unreferenced but too young to drop. + Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed()) + + Expect(prune(context.Background(), ds, store)).To(Succeed()) + + _, err := awRepo.GetImage(h) + Expect(err).ToNot(HaveOccurred()) + rc, err := store.Open(h, "image/jpeg") + Expect(err).ToNot(HaveOccurred()) + rc.Close() + }) + + It("spares an orphan file freshly touched by an overlapping acquisition", func() { + data := []byte("racing-bytes") + h, _ := hashImage(bytes.NewReader(data)) + Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed()) + Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed()) + ageArtwork(h, time.Now().Add(-2*time.Hour)) + // The row is orphaned, but a concurrent acquisition just touched the file's mtime. + + Expect(prune(context.Background(), ds, store)).To(Succeed()) + + rc, err := store.Open(h, "image/jpeg") + Expect(err).ToNot(HaveOccurred()) + rc.Close() + }) + + It("sweeps store files that have no artwork row", func() { + stray := []byte("no-row-bytes") + h, _ := hashImage(bytes.NewReader(stray)) + Expect(store.Write(h, "image/jpeg", bytes.NewReader(stray))).To(Succeed()) + old := time.Now().Add(-2 * time.Hour) + Expect(os.Chtimes(store.path(h, "image/jpeg"), old, old)).To(Succeed()) + + Expect(prune(context.Background(), ds, store)).To(Succeed()) + + _, err := store.Open(h, "image/jpeg") + Expect(os.IsNotExist(err)).To(BeTrue()) + }) + + It("sweeps an obsolete mime variant of a reacquired hash", func() { + data := []byte("variant-bytes") + h, _ := hashImage(bytes.NewReader(data)) + Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed()) + Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed()) + old := time.Now().Add(-2 * time.Hour) + Expect(os.Chtimes(store.path(h, "image/png"), old, old)).To(Succeed()) + Expect(os.Chtimes(store.path(h, "image/jpeg"), old, old)).To(Succeed()) + // The row records the current mime; the .png file is a superseded variant. + Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed()) + + Expect(prune(context.Background(), ds, store)).To(Succeed()) + + _, err := store.Open(h, "image/png") + Expect(os.IsNotExist(err)).To(BeTrue()) + rc, err := store.Open(h, "image/jpeg") + Expect(err).ToNot(HaveOccurred()) + rc.Close() + }) + + It("reclaims the other orphan files when one of them cannot be removed", func() { + tests.SkipOnWindows("uses Unix file permission bits") + if os.Geteuid() == 0 { + Skip("read-only dir cannot block root (e.g. tests in a container)") + } + old := time.Now().Add(-2 * time.Hour) + + blocked := []byte("blocked-bytes") + hb, _ := hashImage(bytes.NewReader(blocked)) + Expect(store.Write(hb, "image/jpeg", bytes.NewReader(blocked))).To(Succeed()) + Expect(os.Chtimes(store.path(hb, "image/jpeg"), old, old)).To(Succeed()) + Expect(awRepo.PutImage(&model.Artwork{Hash: hb, Mime: "image/jpeg"})).To(Succeed()) + ageArtwork(hb, old) + + good := []byte("good-bytes") + hg, _ := hashImage(bytes.NewReader(good)) + Expect(store.Write(hg, "image/jpeg", bytes.NewReader(good))).To(Succeed()) + Expect(os.Chtimes(store.path(hg, "image/jpeg"), old, old)).To(Succeed()) + Expect(awRepo.PutImage(&model.Artwork{Hash: hg, Mime: "image/jpeg"})).To(Succeed()) + ageArtwork(hg, old) + + // A read-only shard directory makes os.Remove fail (EACCES) for hb's file only. + shardDir := filepath.Dir(store.path(hb, "image/jpeg")) + Expect(shardDir).ToNot(Equal(filepath.Dir(store.path(hg, "image/jpeg"))), "fixtures must land in different shards") + Expect(os.Chmod(shardDir, 0500)).To(Succeed()) + DeferCleanup(func() { _ = os.Chmod(shardDir, 0755) }) + + Expect(prune(context.Background(), ds, store)).To(Succeed()) + + _, err := awRepo.GetImage(hg) + Expect(err).To(MatchError(model.ErrNotFound)) + _, err = store.Open(hg, "image/jpeg") + Expect(os.IsNotExist(err)).To(BeTrue()) + + // The row purge does not depend on file removal, so only the file survives. + _, err = awRepo.GetImage(hb) + Expect(err).To(MatchError(model.ErrNotFound)) + rc, err := store.Open(hb, "image/jpeg") + Expect(err).ToNot(HaveOccurred()) + rc.Close() + }) + + It("never sweeps files on a transient DB error", func() { + ds.MockedArtwork = &flakyGetArtworkRepo{MockArtworkRepo: tests.CreateMockArtworkRepo()} + + data := []byte("live-bytes") + h, _ := hashImage(bytes.NewReader(data)) + Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed()) + + Expect(prune(context.Background(), ds, store)).ToNot(Succeed()) + + rc, err := store.Open(h, "image/jpeg") + Expect(err).ToNot(HaveOccurred()) + rc.Close() + }) +}) diff --git a/core/artwork/reader_album_test.go b/core/artwork/reader_album_test.go deleted file mode 100644 index c95ddedaa..000000000 --- a/core/artwork/reader_album_test.go +++ /dev/null @@ -1,481 +0,0 @@ -package artwork - -import ( - "context" - "errors" - "path/filepath" - "time" - - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Album Artwork Reader", func() { - Describe("loadAlbumFoldersPaths", func() { - var ( - ctx context.Context - ds *fakeDataStore - repo *fakeFolderRepo - album model.Album - now time.Time - expectedAt time.Time - ) - - BeforeEach(func() { - ctx = context.Background() - now = time.Now().Truncate(time.Second) - expectedAt = now.Add(5 * time.Minute) - - // Set up the test folders with image files - repo = &fakeFolderRepo{} - ds = &fakeDataStore{ - folderRepo: repo, - } - album = model.Album{ - ID: "album1", - Name: "Album", - FolderIDs: []string{"folder1", "folder2", "folder3"}, - } - }) - - It("returns sorted image files", func() { - repo.result = []model.Folder{ - { - Path: "Artist/Album/Disc1", - ImagesUpdatedAt: expectedAt, - ImageFiles: []string{"cover.jpg", "back.jpg", "cover.1.jpg"}, - }, - { - Path: "Artist/Album/Disc2", - ImagesUpdatedAt: now, - ImageFiles: []string{"cover.jpg"}, - }, - { - Path: "Artist/Album/Disc10", - ImagesUpdatedAt: now, - ImageFiles: []string{"cover.jpg"}, - }, - } - - _, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).ToNot(HaveOccurred()) - Expect(*imagesUpdatedAt).To(Equal(expectedAt)) - - // Check that image files are sorted by base name (without extension) - Expect(imgFiles).To(HaveLen(5)) - - // Files should be sorted by base filename without extension, then by full path - // "back" < "cover", so back.jpg comes first - // Then all cover.jpg files, sorted by path - Expect(imgFiles[0]).To(Equal("Artist/Album/Disc1/back.jpg")) - Expect(imgFiles[1]).To(Equal("Artist/Album/Disc1/cover.jpg")) - Expect(imgFiles[2]).To(Equal("Artist/Album/Disc2/cover.jpg")) - Expect(imgFiles[3]).To(Equal("Artist/Album/Disc10/cover.jpg")) - Expect(imgFiles[4]).To(Equal("Artist/Album/Disc1/cover.1.jpg")) - }) - - It("prioritizes files without numeric suffixes", func() { - // Test case for issue #4683: cover.jpg should come before cover.1.jpg - repo.result = []model.Folder{ - { - Path: "Artist/Album", - ImagesUpdatedAt: now, - ImageFiles: []string{"cover.1.jpg", "cover.jpg", "cover.2.jpg"}, - }, - } - - _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).ToNot(HaveOccurred()) - Expect(imgFiles).To(HaveLen(3)) - - // cover.jpg should come first because "cover" < "cover.1" < "cover.2" - Expect(imgFiles[0]).To(Equal("Artist/Album/cover.jpg")) - Expect(imgFiles[1]).To(Equal("Artist/Album/cover.1.jpg")) - Expect(imgFiles[2]).To(Equal("Artist/Album/cover.2.jpg")) - }) - - It("handles case-insensitive sorting", func() { - // Test that Cover.jpg and cover.jpg are treated as equivalent - repo.result = []model.Folder{ - { - Path: "Artist/Album", - ImagesUpdatedAt: now, - ImageFiles: []string{"Folder.jpg", "cover.jpg", "BACK.jpg"}, - }, - } - - _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).ToNot(HaveOccurred()) - Expect(imgFiles).To(HaveLen(3)) - - // Files should be sorted case-insensitively: BACK, cover, Folder - Expect(imgFiles[0]).To(Equal("Artist/Album/BACK.jpg")) - Expect(imgFiles[1]).To(Equal("Artist/Album/cover.jpg")) - Expect(imgFiles[2]).To(Equal("Artist/Album/Folder.jpg")) - }) - - It("includes images from parent folder for multi-disc albums", func() { - // Simulates: Artist/Album/cover.jpg with tracks in Artist/Album/CD1/ and Artist/Album/CD2/ - repo.result = []model.Folder{ - { - ID: "folder1", - Path: "Artist/Album", - Name: "CD1", - ParentID: "parentFolder", - ImagesUpdatedAt: now, - ImageFiles: []string{}, - }, - { - ID: "folder2", - Path: "Artist/Album", - Name: "CD2", - ParentID: "parentFolder", - ImagesUpdatedAt: now, - ImageFiles: []string{}, - }, - } - repo.parentResult = &model.Folder{ - ID: "parentFolder", - Path: "Artist", - Name: "Album", - ParentID: "artistFolder", - ImagesUpdatedAt: expectedAt, - ImageFiles: []string{"cover.jpg", "back.jpg"}, - } - - _, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).ToNot(HaveOccurred()) - Expect(*imagesUpdatedAt).To(Equal(expectedAt)) - Expect(imgFiles).To(HaveLen(2)) - Expect(imgFiles[0]).To(Equal("Artist/Album/back.jpg")) - Expect(imgFiles[1]).To(Equal("Artist/Album/cover.jpg")) - }) - - It("does not query parent when parent ID is already in album folders", func() { - // When the parent folder is already one of the album's folders, skip it - repo.result = []model.Folder{ - { - ID: "folder1", - Path: "Artist", - Name: "Album", - ParentID: "folder2", - ImagesUpdatedAt: now, - ImageFiles: []string{"cover.jpg"}, - }, - { - ID: "folder2", - Path: "", - Name: "Artist", - ImagesUpdatedAt: now, - ImageFiles: []string{}, - }, - } - - _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).ToNot(HaveOccurred()) - Expect(imgFiles).To(HaveLen(1)) - Expect(imgFiles[0]).To(Equal("Artist/Album/cover.jpg")) - // Get should not have been called (parent already in folder set) - Expect(repo.getCallCount).To(Equal(0)) - }) - - It("does not query parent when folders have different parents", func() { - // When album folders span different parents, don't search any parent - repo.result = []model.Folder{ - { - ID: "folder1", - Path: "Artist1/Album", - Name: "part1", - ParentID: "parentA", - ImagesUpdatedAt: now, - ImageFiles: []string{"cover.jpg"}, - }, - { - ID: "folder2", - Path: "Artist2/Album", - Name: "part2", - ParentID: "parentB", - ImagesUpdatedAt: now, - ImageFiles: []string{}, - }, - } - - _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).ToNot(HaveOccurred()) - Expect(imgFiles).To(HaveLen(1)) - Expect(imgFiles[0]).To(Equal("Artist1/Album/part1/cover.jpg")) - // Get should not have been called (different parents) - Expect(repo.getCallCount).To(Equal(0)) - }) - - It("does not include library root parent for multi-folder albums", func() { - // Two album parts directly under the library root — parent is the root itself - repo.result = []model.Folder{ - { - ID: "folder1", - Path: ".", - Name: "AlbumPart1", - ParentID: "rootFolder", - ImagesUpdatedAt: now, - ImageFiles: []string{"cover.jpg"}, - }, - { - ID: "folder2", - Path: ".", - Name: "AlbumPart2", - ParentID: "rootFolder", - ImagesUpdatedAt: now, - ImageFiles: []string{}, - }, - } - repo.parentResult = &model.Folder{ - ID: "rootFolder", - Path: "", - Name: ".", - ParentID: "", - ImageFiles: []string{"unrelated.jpg"}, - } - - _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).ToNot(HaveOccurred()) - Expect(imgFiles).To(HaveLen(1)) - Expect(imgFiles[0]).To(Equal("AlbumPart1/cover.jpg")) - Expect(repo.getCallCount).To(Equal(1)) - }) - - It("includes top-level album folder for multi-disc albums", func() { - // Album folder directly under library root, with disc subfolders - repo.result = []model.Folder{ - { - ID: "folder1", - Path: "Album", - Name: "Disc1", - ParentID: "albumFolder", - ImagesUpdatedAt: now, - ImageFiles: []string{"folder.jpg"}, - }, - { - ID: "folder2", - Path: "Album", - Name: "Disc2", - ParentID: "albumFolder", - ImagesUpdatedAt: now, - ImageFiles: []string{"folder.jpg"}, - }, - } - repo.parentResult = &model.Folder{ - ID: "albumFolder", - Path: ".", - Name: "Album", - ParentID: "rootFolder", - ImagesUpdatedAt: expectedAt, - ImageFiles: []string{"cover.jpg"}, - } - - _, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).ToNot(HaveOccurred()) - Expect(*imagesUpdatedAt).To(Equal(expectedAt)) - Expect(imgFiles).To(HaveLen(3)) - Expect(imgFiles[0]).To(Equal("Album/cover.jpg")) - Expect(imgFiles[1]).To(Equal("Album/Disc1/folder.jpg")) - Expect(imgFiles[2]).To(Equal("Album/Disc2/folder.jpg")) - Expect(repo.getCallCount).To(Equal(1)) - }) - - It("does not query parent for single-folder albums that already have images", func() { - repo.result = []model.Folder{ - { - ID: "folder1", - Path: "Artist", - Name: "Album", - ParentID: "artistFolder", - ImagesUpdatedAt: now, - ImageFiles: []string{"cover.jpg"}, - }, - } - - _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).ToNot(HaveOccurred()) - Expect(imgFiles).To(HaveLen(1)) - Expect(imgFiles[0]).To(Equal("Artist/Album/cover.jpg")) - Expect(repo.getCallCount).To(Equal(0)) - }) - - It("includes parent images for single-disc-subfolder albums", func() { - repo.result = []model.Folder{ - { - ID: "folder1", - Path: "Artist/Album", - Name: "disc1", - ParentID: "albumFolder", - ImagesUpdatedAt: now, - ImageFiles: []string{}, - }, - } - repo.parentResult = &model.Folder{ - ID: "albumFolder", - Path: "Artist", - Name: "Album", - ParentID: "artistFolder", - ImagesUpdatedAt: expectedAt, - ImageFiles: []string{"cover.jpg"}, - } - - _, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).ToNot(HaveOccurred()) - Expect(*imagesUpdatedAt).To(Equal(expectedAt)) - Expect(imgFiles).To(HaveLen(1)) - Expect(imgFiles[0]).To(Equal("Artist/Album/cover.jpg")) - Expect(repo.getCallCount).To(Equal(1)) - }) - - It("promotes the album root parent into the returned paths", func() { - repo.result = []model.Folder{ - { - ID: "folder1", - Path: "Artist", - Name: "Album", - ParentID: "artistFolder", - ImagesUpdatedAt: now, - ImageFiles: []string{}, - }, - } - repo.parentResult = &model.Folder{ - ID: "artistFolder", - Path: ".", - Name: "Artist", - ParentID: "libraryRoot", - ImagesUpdatedAt: expectedAt, - ImageFiles: []string{"folder.jpg"}, - } - - paths, _, _, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).ToNot(HaveOccurred()) - Expect(paths).To(Equal([]string{filepath.Join("Artist", "Album"), "Artist"})) - }) - - It("does not include parent images when other albums' audio lives under the parent", func() { - // Simulates: Artist/folder.jpg with Artist/Album (no images) and - // another album's tracks elsewhere under the artist folder - repo.result = []model.Folder{ - { - ID: "folder1", - Path: "Artist", - Name: "Album", - ParentID: "artistFolder", - ImagesUpdatedAt: now, - ImageFiles: []string{}, - }, - } - repo.parentResult = &model.Folder{ - ID: "artistFolder", - Path: ".", - Name: "Artist", - ParentID: "libraryRoot", - ImagesUpdatedAt: expectedAt, - ImageFiles: []string{"folder.jpg"}, - } - repo.hasOtherAudio = true - - _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).ToNot(HaveOccurred()) - Expect(imgFiles).To(BeEmpty()) - }) - - It("propagates errors from the album-root check", func() { - repo.result = []model.Folder{ - { - ID: "folder1", - Path: "Artist/Album", - Name: "disc1", - ParentID: "albumFolder", - ImagesUpdatedAt: now, - ImageFiles: []string{}, - }, - } - repo.parentResult = &model.Folder{ - ID: "albumFolder", - Path: "Artist", - Name: "Album", - ParentID: "artistFolder", - ImagesUpdatedAt: expectedAt, - ImageFiles: []string{"cover.jpg"}, - } - repo.otherAudioErr = errors.New("db connection failed") - - _, _, _, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).To(MatchError("db connection failed")) - }) - - It("propagates non-ErrNotFound errors from parent folder lookup", func() { - repo.result = []model.Folder{ - { - ID: "folder1", - Path: "Artist/Album", - Name: "CD1", - ParentID: "parentFolder", - ImagesUpdatedAt: now, - ImageFiles: []string{"cover.jpg"}, - }, - { - ID: "folder2", - Path: "Artist/Album", - Name: "CD2", - ParentID: "parentFolder", - ImagesUpdatedAt: now, - ImageFiles: []string{}, - }, - } - repo.getErr = errors.New("db connection failed") - - _, _, _, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).To(MatchError("db connection failed")) - Expect(repo.getCallCount).To(Equal(1)) - }) - - It("continues gracefully when parent folder is not found", func() { - // Parent folder may have been deleted; should log a warning and continue - repo.result = []model.Folder{ - { - ID: "folder1", - Path: "Artist/Album", - Name: "CD1", - ParentID: "missingParent", - ImagesUpdatedAt: now, - ImageFiles: []string{"cover.jpg"}, - }, - { - ID: "folder2", - Path: "Artist/Album", - Name: "CD2", - ParentID: "missingParent", - ImagesUpdatedAt: now, - ImageFiles: []string{}, - }, - } - // parentResult is nil, so Get will return ErrNotFound - - _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).ToNot(HaveOccurred()) - Expect(imgFiles).To(HaveLen(1)) - Expect(imgFiles[0]).To(Equal("Artist/Album/CD1/cover.jpg")) - Expect(repo.getCallCount).To(Equal(1)) - }) - }) -}) diff --git a/core/artwork/reader_artist.go b/core/artwork/reader_artist.go deleted file mode 100644 index fe5bcb196..000000000 --- a/core/artwork/reader_artist.go +++ /dev/null @@ -1,359 +0,0 @@ -package artwork - -import ( - "context" - "crypto/md5" - "fmt" - "io" - "io/fs" - "os" - "path" - "path/filepath" - "slices" - "strings" - "time" - - "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core" - "github.com/navidrome/navidrome/core/external" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/slice" - "github.com/navidrome/navidrome/utils/str" -) - -const ( - // maxArtistFolderTraversalDepth defines how many directory levels to search - // when looking for artist images (artist folder + parent directories) - maxArtistFolderTraversalDepth = 3 -) - -type artistReader struct { - cacheKey - a *artwork - provider external.Provider - artist model.Artist - artistFolder string - imgFiles []string - imgFolderImgPath string // cached path from ArtistImageFolder lookup - lib libraryView -} - -func newArtistArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*artistReader, error) { - ar, err := artwork.ds.Artist(ctx).Get(artID.ID) - if err != nil { - return nil, err - } - // Only consider albums where the artist is the sole album artist. - als, err := artwork.ds.Album(ctx).GetAll(model.QueryOptions{ - Filters: squirrel.And{ - squirrel.Eq{"album_artist_id": artID.ID}, - squirrel.Eq{"json_array_length(participants, '$.albumartist')": 1}, - }, - }) - if err != nil { - return nil, err - } - albumPaths, imgFiles, imagesUpdatedAt, err := loadArtistAlbumRoots(ctx, artwork.ds, als) - if err != nil { - return nil, err - } - artistFolder, artistFolderLastUpdate, err := loadArtistFolder(ctx, artwork.ds, als, albumPaths) - if err != nil { - return nil, err - } - var lib libraryView - if len(als) > 0 { - lib, err = loadLibraryView(ctx, artwork.ds, als[0].LibraryID) - if err != nil { - return nil, err - } - } - a := &artistReader{ - a: artwork, - provider: provider, - artist: *ar, - artistFolder: artistFolder, - imgFiles: imgFiles, - lib: lib, - } - // TODO Find a way to factor in the ExternalUpdateInfoAt in the cache key. Problem is that it can - // change _after_ retrieving from external sources, making the key invalid - //a.cacheKey.lastUpdate = ar.ExternalInfoUpdatedAt - - a.cacheKey.lastUpdate = *imagesUpdatedAt - if ar.UpdatedAt != nil && ar.UpdatedAt.After(a.cacheKey.lastUpdate) { - a.cacheKey.lastUpdate = *ar.UpdatedAt - } - if artistFolderLastUpdate.After(a.cacheKey.lastUpdate) { - a.cacheKey.lastUpdate = artistFolderLastUpdate - } - if conf.Server.ArtistImageFolder != "" && strings.Contains(strings.ToLower(conf.Server.ArtistArtPriority), "image-folder") { - a.imgFolderImgPath = findImageInArtistFolder(conf.Server.ArtistImageFolder, ar.MbzArtistID, ar.Name) - if a.imgFolderImgPath != "" { - if info, err := os.Stat(a.imgFolderImgPath); err == nil && info.ModTime().After(a.cacheKey.lastUpdate) { - a.cacheKey.lastUpdate = info.ModTime() - } - } - } - a.cacheKey.artID = artID - return a, nil -} - -func (a *artistReader) Key() string { - hash := md5.Sum([]byte(conf.Server.Agents)) - return fmt.Sprintf( - "%s.%t.%x", - a.cacheKey.Key(), - conf.Server.EnableExternalServices, - hash, - ) -} - -func (a *artistReader) LastUpdated() time.Time { - return a.lastUpdate -} - -func (a *artistReader) Reader(ctx context.Context) (io.ReadCloser, string, error) { - ff := []sourceFunc{a.fromArtistUploadedImage()} - ff = append(ff, a.fromArtistArtPriority(ctx, conf.Server.ArtistArtPriority)...) - return selectImageReader(ctx, a.artID, ff...) -} - -func (a *artistReader) fromArtistUploadedImage() sourceFunc { - return fromLocalFile(a.artist.UploadedImagePath()) -} - -func (a *artistReader) fromArtistArtPriority(ctx context.Context, priority string) []sourceFunc { - var ff []sourceFunc - for pattern := range strings.SplitSeq(strings.ToLower(priority), ",") { - pattern = strings.TrimSpace(pattern) - switch { - case pattern == "external": - ff = append(ff, fromArtistExternalSource(ctx, a.artist, a.provider)) - case pattern == "image-folder": - ff = append(ff, a.fromArtistImageFolder(ctx)) - case strings.HasPrefix(pattern, "album/"): - if a.lib.FS != nil { - ff = append(ff, fromExternalFile(ctx, a.lib.FS, a.imgFiles, strings.TrimPrefix(pattern, "album/"))) - } - default: - ff = append(ff, fromArtistFolder(ctx, a.lib.FS, a.lib.absRoot, a.artistFolder, pattern)) - } - } - return ff -} - -// fromArtistFolder walks up from artistFolder toward libPath looking for a -// file matching pattern. Traversal is bounded by both maxArtistFolderTraversalDepth -// and the library root: once we reach libPath (or if artistFolder is outside -// libPath), the walk stops. All reads go through libFS, which keeps artwork -// resolution scoped to the configured library. -func fromArtistFolder(ctx context.Context, libFS fs.FS, libPath, artistFolder, pattern string) sourceFunc { - return func() (io.ReadCloser, string, error) { - if libFS == nil { - return nil, "", fmt.Errorf("artist folder lookup unavailable") - } - rel, err := filepath.Rel(libPath, artistFolder) - if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { - return nil, "", fmt.Errorf(`artist folder '%s' is outside library '%s'`, artistFolder, libPath) - } - // fs.Glob / path.Join below expect forward-slash paths; filepath.Rel may - // return backslash separators on Windows. - rel = filepath.ToSlash(rel) - current := artistFolder - for range maxArtistFolderTraversalDepth { - reader, hit, err := findImageInFolder(ctx, libFS, rel, current, pattern) - if err == nil { - return reader, hit, nil - } - if rel == "." { - break // reached library root; don't traverse above it - } - rel = path.Dir(rel) - current = filepath.Dir(current) - } - return nil, "", fmt.Errorf(`no matches for '%s' in '%s' or its parent directories (within library)`, pattern, artistFolder) - } -} - -// findImageInFolder globs libFS at relFolder for pattern and returns the first -// matching image. absFolder is used only for the returned display path and log -// messages so callers see absolute-looking paths consistent with the rest of -// the artwork pipeline. -func findImageInFolder(ctx context.Context, libFS fs.FS, relFolder, absFolder, pattern string) (io.ReadCloser, string, error) { - log.Trace(ctx, "looking for artist image", "pattern", pattern, "folder", absFolder) - globPattern := pattern - if relFolder != "." { - globPattern = path.Join(escapeGlobLiteral(relFolder), pattern) - } - matches, err := fs.Glob(libFS, globPattern) - if err != nil { - log.Warn(ctx, "Error matching artist image pattern", "pattern", pattern, "folder", absFolder, err) - return nil, "", err - } - - // Filter to valid image files - var imagePaths []string - for _, m := range matches { - if !model.IsImageFile(m) { - continue - } - imagePaths = append(imagePaths, m) - } - - // Sort image files by prioritizing base filenames without numeric - // suffixes (e.g., artist.jpg before artist.1.jpg) - slices.SortFunc(imagePaths, compareImageFiles) - - for _, p := range imagePaths { - f, err := libFS.Open(p) - if err != nil { - log.Warn(ctx, "Could not open cover art file", "file", p, err) - continue - } - _, name := path.Split(p) - return f, filepath.Join(absFolder, name), nil - } - - return nil, "", fmt.Errorf(`no matches for '%s' in '%s'`, pattern, absFolder) -} - -func escapeGlobLiteral(s string) string { - var b strings.Builder - b.Grow(len(s)) - for _, r := range s { - switch r { - case '\\', '*', '?', '[', ']': - b.WriteByte('\\') - } - b.WriteRune(r) - } - return b.String() -} - -// loadArtistAlbumRoots returns one path per album — the deepest folder holding -// all of that album's tracks — so an album split into disc subfolders can't -// pull the artist folder's common prefix below the artist level. -func loadArtistAlbumRoots(ctx context.Context, ds model.DataStore, albums model.Albums) ([]string, []string, *time.Time, error) { - var folderIDs []string - for _, album := range albums { - folderIDs = append(folderIDs, album.FolderIDs...) - } - folders, err := loadFolders(ctx, ds, folderIDs) - if err != nil { - return nil, nil, nil, err - } - - pathByID := slice.ToMap(folders, func(f model.Folder) (string, string) { - return f.ID, f.AbsolutePath() - }) - var roots []string - for _, album := range albums { - var albumPaths []string - for _, fid := range album.FolderIDs { - if p, ok := pathByID[fid]; ok { - albumPaths = append(albumPaths, p) - } - } - if len(albumPaths) > 0 { - roots = append(roots, commonDir(albumPaths)) - } - } - - imgFiles, updatedAt := folderImages(folders) - return roots, imgFiles, &updatedAt, nil -} - -// commonDir returns the deepest directory containing all paths. Trailing -// separators keep the comparison on segment boundaries, so a shared name -// fragment (".../Album" and ".../Album2") is never read as a shared directory. -func commonDir(paths []string) string { - sep := string(filepath.Separator) - common := str.LongestCommonPrefix(slice.Map(paths, func(p string) string { return p + sep })) - if !strings.HasSuffix(common, sep) { - common, _ = filepath.Split(common) - } - return filepath.Clean(common) -} - -func loadArtistFolder(ctx context.Context, ds model.DataStore, albums model.Albums, paths []string) (string, time.Time, error) { - if len(albums) == 0 { - return "", time.Time{}, nil - } - libID := albums[0].LibraryID // Just need one of the albums, as they should all be in the same Library - for now! TODO: Support multiple libraries - - // paths holds one root per album: two or more distinct roots already meet at - // the artist folder, while a single root is an album folder needing a climb. - roots := slices.Compact(slices.Sorted(slices.Values(paths))) - folderPath := commonDir(roots) - if len(roots) < 2 { - folderPath = filepath.Dir(folderPath) - } - - // Manipulate the path to get the folder ID - // TODO: This is a bit hacky, but it's the easiest way to get the folder ID, ATM - libPath := core.AbsolutePath(ctx, ds, libID, "") - folderID := model.FolderID(model.Library{ID: libID, Path: libPath}, folderPath) - - log.Trace(ctx, "Calculating artist folder details", "folderPath", folderPath, "folderID", folderID, - "libPath", libPath, "libID", libID, "albumPaths", paths) - - // Get the last update time for the folder - folders, err := ds.Folder(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"folder.id": folderID, "missing": false}}) - if err != nil || len(folders) == 0 { - log.Warn(ctx, "Could not find folder for artist", "folderPath", folderPath, "id", folderID, - "libPath", libPath, "libID", libID, err) - return "", time.Time{}, err - } - return folderPath, folders[0].ImagesUpdatedAt, nil -} - -func (a *artistReader) fromArtistImageFolder(ctx context.Context) sourceFunc { - return func() (io.ReadCloser, string, error) { - folder := conf.Server.ArtistImageFolder - if folder == "" { - return nil, "", nil - } - // Use cached path from newArtistArtworkReader if available, - // avoiding a second directory scan. - path := a.imgFolderImgPath - if path == "" { - path = findImageInArtistFolder(folder, a.artist.MbzArtistID, a.artist.Name) - } - if path == "" { - return nil, "", fmt.Errorf("no image found for artist %q in %s", a.artist.Name, folder) - } - f, err := os.Open(path) - if err != nil { - return nil, "", err - } - return f, path, nil - } -} - -// findImageInArtistFolder scans a folder for an image file matching the artist's MBID or name -// (case-insensitive). Returns the full path, or empty string if not found. -func findImageInArtistFolder(folder, mbzArtistID, artistName string) string { - entries, err := os.ReadDir(folder) - if err != nil { - return "" - } - for _, candidate := range []string{mbzArtistID, artistName} { - if candidate == "" { - continue - } - for _, entry := range entries { - if entry.IsDir() { - continue - } - name := entry.Name() - base := strings.TrimSuffix(name, filepath.Ext(name)) - if strings.EqualFold(base, candidate) && model.IsImageFile(name) { - return filepath.Join(folder, name) - } - } - } - return "" -} diff --git a/core/artwork/reader_artist_test.go b/core/artwork/reader_artist_test.go deleted file mode 100644 index c75f8447e..000000000 --- a/core/artwork/reader_artist_test.go +++ /dev/null @@ -1,761 +0,0 @@ -package artwork - -import ( - "context" - "errors" - "io" - "io/fs" - "os" - "path/filepath" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core" - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("artistArtworkReader", func() { - var _ = Describe("loadArtistFolder", func() { - var ( - ctx context.Context - fds *fakeDataStore - repo *fakeFolderRepo - albums model.Albums - paths []string - now time.Time - expectedUpdTime time.Time - ) - - BeforeEach(func() { - ctx = context.Background() - DeferCleanup(stubCoreAbsolutePath()) - - now = time.Now().Truncate(time.Second) - expectedUpdTime = now.Add(5 * time.Minute) - repo = &fakeFolderRepo{ - result: []model.Folder{ - { - ImagesUpdatedAt: expectedUpdTime, - }, - }, - err: nil, - } - fds = &fakeDataStore{ - folderRepo: repo, - } - albums = model.Albums{ - {LibraryID: 1, ID: "album1", Name: "Album 1"}, - } - }) - - When("no albums provided", func() { - It("returns empty and zero time", func() { - folder, upd, err := loadArtistFolder(ctx, fds, model.Albums{}, []string{"/dummy/path"}) - Expect(err).ToNot(HaveOccurred()) - Expect(folder).To(BeEmpty()) - Expect(upd).To(BeZero()) - }) - }) - - When("artist has only one album", func() { - It("returns the parent folder", func() { - paths = []string{ - filepath.FromSlash("/music/artist/album1"), - } - folder, upd, err := loadArtistFolder(ctx, fds, albums, paths) - Expect(err).ToNot(HaveOccurred()) - Expect(folder).To(Equal(filepath.FromSlash("/music/artist"))) - Expect(upd).To(Equal(expectedUpdTime)) - }) - }) - - When("the artist have multiple albums", func() { - It("returns the common prefix for the albums paths", func() { - paths = []string{ - filepath.FromSlash("/music/library/artist/one"), - filepath.FromSlash("/music/library/artist/two"), - } - folder, upd, err := loadArtistFolder(ctx, fds, albums, paths) - Expect(err).ToNot(HaveOccurred()) - Expect(folder).To(Equal(filepath.FromSlash("/music/library/artist"))) - Expect(upd).To(Equal(expectedUpdTime)) - }) - }) - - When("two albums share the same folder", func() { - It("climbs above the shared album folder", func() { - paths = []string{ - filepath.FromSlash("/music/artist/split"), - filepath.FromSlash("/music/artist/split"), - } - folder, upd, err := loadArtistFolder(ctx, fds, albums, paths) - Expect(err).ToNot(HaveOccurred()) - Expect(folder).To(Equal(filepath.FromSlash("/music/artist"))) - Expect(upd).To(Equal(expectedUpdTime)) - }) - }) - - When("the album paths contain same prefix", func() { - It("returns the common prefix", func() { - paths = []string{ - filepath.FromSlash("/music/artist/album1"), - filepath.FromSlash("/music/artist/album2"), - } - folder, upd, err := loadArtistFolder(ctx, fds, albums, paths) - Expect(err).ToNot(HaveOccurred()) - Expect(folder).To(Equal(filepath.FromSlash("/music/artist"))) - Expect(upd).To(Equal(expectedUpdTime)) - }) - }) - - When("ds.Folder().GetAll returns an error", func() { - It("returns an error", func() { - paths = []string{ - filepath.FromSlash("/music/artist/album1"), - filepath.FromSlash("/music/artist/album2"), - } - repo.err = errors.New("fake error") - folder, upd, err := loadArtistFolder(ctx, fds, albums, paths) - Expect(err).To(MatchError(ContainSubstring("fake error"))) - // Folder and time are empty on error. - Expect(folder).To(BeEmpty()) - Expect(upd).To(BeZero()) - }) - }) - }) - - var _ = Describe("fromArtistFolder", func() { - var ( - ctx context.Context - tempDir string - libFS fs.FS - testFunc sourceFunc - ) - - BeforeEach(func() { - ctx = context.Background() - tempDir = GinkgoT().TempDir() - libFS = os.DirFS(tempDir) - }) - - When("artist folder contains matching image", func() { - BeforeEach(func() { - // Create test structure: /temp/artist/artist.jpg - artistDir := filepath.Join(tempDir, "artist") - Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) - - artistImagePath := filepath.Join(artistDir, "artist.jpg") - Expect(os.WriteFile(artistImagePath, []byte("fake image data"), 0600)).To(Succeed()) - - testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") - }) - - It("finds and returns the image", func() { - reader, path, err := testFunc() - Expect(err).ToNot(HaveOccurred()) - Expect(reader).ToNot(BeNil()) - Expect(path).To(ContainSubstring("artist.jpg")) - - // Verify we can read the content - data, err := io.ReadAll(reader) - Expect(err).ToNot(HaveOccurred()) - Expect(string(data)).To(Equal("fake image data")) - reader.Close() - }) - }) - - When("artist folder name contains glob metacharacters", func() { - BeforeEach(func() { - artistDir := filepath.Join(tempDir, "Artist [Live]") - Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) - - artistImagePath := filepath.Join(artistDir, "artist.jpg") - Expect(os.WriteFile(artistImagePath, []byte("bracketed artist image"), 0600)).To(Succeed()) - - testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") - }) - - It("treats the folder path literally when globbing through the library fs", func() { - reader, path, err := testFunc() - Expect(err).ToNot(HaveOccurred()) - Expect(reader).ToNot(BeNil()) - Expect(path).To(ContainSubstring("Artist [Live]" + string(filepath.Separator) + "artist.jpg")) - - data, err := io.ReadAll(reader) - Expect(err).ToNot(HaveOccurred()) - Expect(string(data)).To(Equal("bracketed artist image")) - reader.Close() - }) - }) - - When("artist folder is empty but parent contains image", func() { - BeforeEach(func() { - // Create test structure: /temp/parent/artist.jpg and /temp/parent/artist/album/ - parentDir := filepath.Join(tempDir, "parent") - artistDir := filepath.Join(parentDir, "artist") - albumDir := filepath.Join(artistDir, "album") - Expect(os.MkdirAll(albumDir, 0755)).To(Succeed()) - - // Put artist image in parent directory - artistImagePath := filepath.Join(parentDir, "artist.jpg") - Expect(os.WriteFile(artistImagePath, []byte("parent image"), 0600)).To(Succeed()) - - testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") - }) - - It("finds image in parent directory", func() { - reader, path, err := testFunc() - Expect(err).ToNot(HaveOccurred()) - Expect(reader).ToNot(BeNil()) - Expect(path).To(ContainSubstring("parent" + string(filepath.Separator) + "artist.jpg")) - - data, err := io.ReadAll(reader) - Expect(err).ToNot(HaveOccurred()) - Expect(string(data)).To(Equal("parent image")) - reader.Close() - }) - }) - - When("image is two levels up", func() { - BeforeEach(func() { - // Create test structure: /temp/grandparent/artist.jpg and /temp/grandparent/parent/artist/ - grandparentDir := filepath.Join(tempDir, "grandparent") - parentDir := filepath.Join(grandparentDir, "parent") - artistDir := filepath.Join(parentDir, "artist") - Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) - - // Put artist image in grandparent directory - artistImagePath := filepath.Join(grandparentDir, "artist.jpg") - Expect(os.WriteFile(artistImagePath, []byte("grandparent image"), 0600)).To(Succeed()) - - testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") - }) - - It("finds image in grandparent directory", func() { - reader, path, err := testFunc() - Expect(err).ToNot(HaveOccurred()) - Expect(reader).ToNot(BeNil()) - Expect(path).To(ContainSubstring("grandparent" + string(filepath.Separator) + "artist.jpg")) - - data, err := io.ReadAll(reader) - Expect(err).ToNot(HaveOccurred()) - Expect(string(data)).To(Equal("grandparent image")) - reader.Close() - }) - }) - - When("images exist at multiple levels", func() { - BeforeEach(func() { - // Create test structure with images at multiple levels - grandparentDir := filepath.Join(tempDir, "grandparent") - parentDir := filepath.Join(grandparentDir, "parent") - artistDir := filepath.Join(parentDir, "artist") - Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) - - // Put artist images at all levels - Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("artist level"), 0600)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(parentDir, "artist.jpg"), []byte("parent level"), 0600)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(grandparentDir, "artist.jpg"), []byte("grandparent level"), 0600)).To(Succeed()) - - testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") - }) - - It("prioritizes the closest (artist folder) image", func() { - reader, path, err := testFunc() - Expect(err).ToNot(HaveOccurred()) - Expect(reader).ToNot(BeNil()) - Expect(path).To(ContainSubstring("artist" + string(filepath.Separator) + "artist.jpg")) - - data, err := io.ReadAll(reader) - Expect(err).ToNot(HaveOccurred()) - Expect(string(data)).To(Equal("artist level")) - reader.Close() - }) - }) - - When("pattern matches multiple files", func() { - BeforeEach(func() { - artistDir := filepath.Join(tempDir, "artist") - Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) - - // Create multiple matching files - Expect(os.WriteFile(filepath.Join(artistDir, "artist.abc"), []byte("text file"), 0600)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(artistDir, "artist.png"), []byte("png image"), 0600)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("jpg image"), 0600)).To(Succeed()) - - testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") - }) - - It("returns the first valid image file in sorted order", func() { - reader, path, err := testFunc() - Expect(err).ToNot(HaveOccurred()) - Expect(reader).ToNot(BeNil()) - - // Should return an image file, - // Files are sorted: jpg comes before png alphabetically. - // .abc comes first, but it's not an image. - Expect(path).To(ContainSubstring("artist.jpg")) - reader.Close() - }) - }) - - When("prioritizing files without numeric suffixes", func() { - BeforeEach(func() { - // Test case for issue #4683: artist.jpg should come before artist.1.jpg - artistDir := filepath.Join(tempDir, "artist") - Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) - - // Create multiple matches with and without numeric suffixes - Expect(os.WriteFile(filepath.Join(artistDir, "artist.1.jpg"), []byte("artist 1"), 0600)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("artist main"), 0600)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(artistDir, "artist.2.jpg"), []byte("artist 2"), 0600)).To(Succeed()) - - testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") - }) - - It("returns artist.jpg before artist.1.jpg and artist.2.jpg", func() { - reader, path, err := testFunc() - Expect(err).ToNot(HaveOccurred()) - Expect(reader).ToNot(BeNil()) - Expect(path).To(ContainSubstring("artist.jpg")) - - // Verify it's the main file, not a numbered variant - data, err := io.ReadAll(reader) - Expect(err).ToNot(HaveOccurred()) - Expect(string(data)).To(Equal("artist main")) - reader.Close() - }) - }) - - When("handling case-insensitive sorting", func() { - BeforeEach(func() { - // Test case to ensure case-insensitive natural sorting - artistDir := filepath.Join(tempDir, "artist") - Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) - - // Create files with mixed case names - Expect(os.WriteFile(filepath.Join(artistDir, "Folder.jpg"), []byte("folder"), 0600)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("artist"), 0600)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(artistDir, "BACK.jpg"), []byte("back"), 0600)).To(Succeed()) - - testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "*.*") - }) - - It("sorts case-insensitively", func() { - reader, path, err := testFunc() - Expect(err).ToNot(HaveOccurred()) - Expect(reader).ToNot(BeNil()) - - // Should return artist.jpg first (case-insensitive: "artist" < "back" < "folder") - Expect(path).To(ContainSubstring("artist.jpg")) - - data, err := io.ReadAll(reader) - Expect(err).ToNot(HaveOccurred()) - Expect(string(data)).To(Equal("artist")) - reader.Close() - }) - }) - - When("no matching files exist anywhere", func() { - BeforeEach(func() { - artistDir := filepath.Join(tempDir, "artist") - Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) - - // Create non-matching files - Expect(os.WriteFile(filepath.Join(artistDir, "cover.jpg"), []byte("cover image"), 0600)).To(Succeed()) - - testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") - }) - - It("returns an error", func() { - reader, path, err := testFunc() - Expect(err).To(HaveOccurred()) - Expect(reader).To(BeNil()) - Expect(path).To(BeEmpty()) - Expect(err.Error()).To(ContainSubstring("no matches for 'artist.*'")) - Expect(err.Error()).To(ContainSubstring("parent directories")) - }) - }) - - When("directory traversal reaches filesystem root", func() { - BeforeEach(func() { - // Start from a shallow directory to test root boundary - artistDir := filepath.Join(tempDir, "artist") - Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) - - testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") - }) - - It("handles root boundary gracefully", func() { - reader, path, err := testFunc() - Expect(err).To(HaveOccurred()) - Expect(reader).To(BeNil()) - Expect(path).To(BeEmpty()) - // Should not panic or cause infinite loop - }) - }) - - When("file exists but cannot be opened", func() { - BeforeEach(func() { - artistDir := filepath.Join(tempDir, "artist") - Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) - - // Create a file that cannot be opened (permission denied) - restrictedFile := filepath.Join(artistDir, "artist.jpg") - Expect(os.WriteFile(restrictedFile, []byte("restricted"), 0600)).To(Succeed()) - - testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") - }) - - It("logs warning and continues searching", func() { - // This test depends on the ability to restrict file permissions - // For now, we'll just ensure it doesn't panic and returns appropriate error - reader, _, err := testFunc() - // The file should be readable in test environment, so this will succeed - // In a real scenario with permission issues, it would continue searching - if err == nil { - Expect(reader).ToNot(BeNil()) - reader.Close() - } - }) - }) - - When("single album artist scenario (original issue)", func() { - BeforeEach(func() { - // Simulate the exact folder structure from the issue: - // /music/artist/album1/ (single album) - // /music/artist/artist.jpg (artist image that should be found) - artistDir := filepath.Join(tempDir, "music", "artist") - albumDir := filepath.Join(artistDir, "album1") - Expect(os.MkdirAll(albumDir, 0755)).To(Succeed()) - - // Create artist.jpg in the artist folder (this was not being found before) - artistImagePath := filepath.Join(artistDir, "artist.jpg") - Expect(os.WriteFile(artistImagePath, []byte("single album artist image"), 0600)).To(Succeed()) - - // The fromArtistFolder is called with the artist folder path - testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") - }) - - It("finds artist.jpg in artist folder for single album artist", func() { - reader, path, err := testFunc() - Expect(err).ToNot(HaveOccurred()) - Expect(reader).ToNot(BeNil()) - Expect(path).To(ContainSubstring("artist.jpg")) - Expect(path).To(ContainSubstring("artist")) - - // Verify the content - data, err := io.ReadAll(reader) - Expect(err).ToNot(HaveOccurred()) - Expect(string(data)).To(Equal("single album artist image")) - reader.Close() - }) - }) - }) - - Describe("fromArtistUploadedImage", func() { - var ( - tempDir string - reader *artistReader - ) - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - tempDir = GinkgoT().TempDir() - conf.Server.DataFolder = conf.NewDir(tempDir) - - // Create the artwork/artist directory - Expect(os.MkdirAll(filepath.Join(tempDir, "artwork", "artist"), 0755)).To(Succeed()) - - reader = &artistReader{} - }) - - When("artist has an uploaded image", func() { - It("returns the uploaded image", func() { - imgPath := filepath.Join(tempDir, "artwork", "artist", "ar-1_test.jpg") - Expect(os.WriteFile(imgPath, []byte("uploaded artist image"), 0600)).To(Succeed()) - - reader.artist = model.Artist{ID: "ar-1", UploadedImage: "ar-1_test.jpg"} - sf := reader.fromArtistUploadedImage() - r, path, err := sf() - Expect(err).ToNot(HaveOccurred()) - Expect(r).ToNot(BeNil()) - Expect(path).To(Equal(imgPath)) - - data, err := io.ReadAll(r) - Expect(err).ToNot(HaveOccurred()) - Expect(string(data)).To(Equal("uploaded artist image")) - r.Close() - }) - }) - - When("artist has no uploaded image", func() { - It("returns nil reader (falls through)", func() { - reader.artist = model.Artist{ID: "ar-1"} - sf := reader.fromArtistUploadedImage() - r, path, err := sf() - Expect(err).ToNot(HaveOccurred()) - Expect(r).To(BeNil()) - Expect(path).To(BeEmpty()) - }) - }) - }) - - Describe("fromArtistImageFolder", func() { - var ( - ctx context.Context - tempDir string - ar *artistReader - ) - - BeforeEach(func() { - ctx = context.Background() - DeferCleanup(configtest.SetupConfig()) - tempDir = GinkgoT().TempDir() - ar = &artistReader{} - }) - - When("ArtistImageFolder is not configured", func() { - It("returns nil (skips)", func() { - conf.Server.ArtistImageFolder = "" - ar.artist = model.Artist{Name: "Test Artist"} - sf := ar.fromArtistImageFolder(ctx) - r, path, err := sf() - Expect(err).ToNot(HaveOccurred()) - Expect(r).To(BeNil()) - Expect(path).To(BeEmpty()) - }) - }) - - When("image exists matching MBID", func() { - It("finds the image by MBID", func() { - conf.Server.ArtistImageFolder = tempDir - mbid := "f27ec8db-af05-4f36-916e-3d57f91ecf5e" - imgPath := filepath.Join(tempDir, mbid+".jpg") - Expect(os.WriteFile(imgPath, []byte("mbid image"), 0600)).To(Succeed()) - - ar.artist = model.Artist{Name: "Test Artist", MbzArtistID: mbid} - sf := ar.fromArtistImageFolder(ctx) - r, path, err := sf() - Expect(err).ToNot(HaveOccurred()) - Expect(r).ToNot(BeNil()) - Expect(path).To(Equal(imgPath)) - - data, err := io.ReadAll(r) - Expect(err).ToNot(HaveOccurred()) - Expect(string(data)).To(Equal("mbid image")) - r.Close() - }) - }) - - When("MBID match is case-insensitive", func() { - It("finds the image regardless of case", func() { - conf.Server.ArtistImageFolder = tempDir - mbid := "F27EC8DB-AF05-4F36-916E-3D57F91ECF5E" - imgPath := filepath.Join(tempDir, "f27ec8db-af05-4f36-916e-3d57f91ecf5e.png") - Expect(os.WriteFile(imgPath, []byte("mbid case image"), 0600)).To(Succeed()) - - ar.artist = model.Artist{Name: "Test Artist", MbzArtistID: mbid} - sf := ar.fromArtistImageFolder(ctx) - r, path, err := sf() - Expect(err).ToNot(HaveOccurred()) - Expect(r).ToNot(BeNil()) - Expect(path).To(Equal(imgPath)) - r.Close() - }) - }) - - When("no MBID file exists but artist name file does", func() { - It("falls back to artist name match", func() { - conf.Server.ArtistImageFolder = tempDir - imgPath := filepath.Join(tempDir, "Test Artist.jpg") - Expect(os.WriteFile(imgPath, []byte("name image"), 0600)).To(Succeed()) - - ar.artist = model.Artist{Name: "Test Artist", MbzArtistID: "nonexistent-mbid"} - sf := ar.fromArtistImageFolder(ctx) - r, path, err := sf() - Expect(err).ToNot(HaveOccurred()) - Expect(r).ToNot(BeNil()) - Expect(path).To(Equal(imgPath)) - - data, err := io.ReadAll(r) - Expect(err).ToNot(HaveOccurred()) - Expect(string(data)).To(Equal("name image")) - r.Close() - }) - }) - - When("artist name match is case-insensitive", func() { - It("matches regardless of case", func() { - conf.Server.ArtistImageFolder = tempDir - imgPath := filepath.Join(tempDir, "test artist.jpg") - Expect(os.WriteFile(imgPath, []byte("case insensitive"), 0600)).To(Succeed()) - - ar.artist = model.Artist{Name: "Test Artist"} - sf := ar.fromArtistImageFolder(ctx) - r, path, err := sf() - Expect(err).ToNot(HaveOccurred()) - Expect(r).ToNot(BeNil()) - Expect(path).To(Equal(imgPath)) - r.Close() - }) - }) - - When("both MBID and name files exist", func() { - It("prefers MBID over name match", func() { - conf.Server.ArtistImageFolder = tempDir - mbid := "f27ec8db-af05-4f36-916e-3d57f91ecf5e" - mbidPath := filepath.Join(tempDir, mbid+".jpg") - namePath := filepath.Join(tempDir, "Test Artist.jpg") - Expect(os.WriteFile(mbidPath, []byte("mbid image"), 0600)).To(Succeed()) - Expect(os.WriteFile(namePath, []byte("name image"), 0600)).To(Succeed()) - - ar.artist = model.Artist{Name: "Test Artist", MbzArtistID: mbid} - sf := ar.fromArtistImageFolder(ctx) - r, path, err := sf() - Expect(err).ToNot(HaveOccurred()) - Expect(r).ToNot(BeNil()) - Expect(path).To(Equal(mbidPath)) - - data, err := io.ReadAll(r) - Expect(err).ToNot(HaveOccurred()) - Expect(string(data)).To(Equal("mbid image")) - r.Close() - }) - }) - - When("no matching image found", func() { - It("returns an error", func() { - conf.Server.ArtistImageFolder = tempDir - // Create an unrelated file - Expect(os.WriteFile(filepath.Join(tempDir, "other.jpg"), []byte("other"), 0600)).To(Succeed()) - - ar.artist = model.Artist{Name: "Test Artist"} - sf := ar.fromArtistImageFolder(ctx) - r, _, err := sf() - Expect(err).To(HaveOccurred()) - Expect(r).To(BeNil()) - Expect(err.Error()).To(ContainSubstring("no image found")) - }) - }) - - When("cached imgFolderImgPath is set", func() { - It("uses cached path instead of scanning", func() { - conf.Server.ArtistImageFolder = tempDir - imgPath := filepath.Join(tempDir, "cached.jpg") - Expect(os.WriteFile(imgPath, []byte("cached image"), 0600)).To(Succeed()) - - ar.artist = model.Artist{Name: "Test Artist"} - ar.imgFolderImgPath = imgPath - sf := ar.fromArtistImageFolder(ctx) - r, path, err := sf() - Expect(err).ToNot(HaveOccurred()) - Expect(r).ToNot(BeNil()) - Expect(path).To(Equal(imgPath)) - - data, err := io.ReadAll(r) - Expect(err).ToNot(HaveOccurred()) - Expect(string(data)).To(Equal("cached image")) - r.Close() - }) - }) - }) - - Describe("findImageInArtistFolder", func() { - var tempDir string - - BeforeEach(func() { - tempDir = GinkgoT().TempDir() - }) - - When("matching file exists by MBID", func() { - It("returns the file path", func() { - mbid := "f27ec8db-af05-4f36-916e-3d57f91ecf5e" - imgPath := filepath.Join(tempDir, mbid+".jpg") - Expect(os.WriteFile(imgPath, []byte("image"), 0600)).To(Succeed()) - - path := findImageInArtistFolder(tempDir, mbid, "Test") - Expect(path).To(Equal(imgPath)) - }) - }) - - When("matching file exists by name", func() { - It("returns the file path", func() { - imgPath := filepath.Join(tempDir, "Test Artist.png") - Expect(os.WriteFile(imgPath, []byte("image"), 0600)).To(Succeed()) - - path := findImageInArtistFolder(tempDir, "", "Test Artist") - Expect(path).To(Equal(imgPath)) - }) - }) - - When("no matching file exists", func() { - It("returns empty string", func() { - path := findImageInArtistFolder(tempDir, "", "Unknown Artist") - Expect(path).To(BeEmpty()) - }) - }) - - When("folder does not exist", func() { - It("returns empty string", func() { - path := findImageInArtistFolder("/nonexistent/path", "", "Test") - Expect(path).To(BeEmpty()) - }) - }) - }) -}) - -type fakeFolderRepo struct { - model.FolderRepository - result []model.Folder - parentResult *model.Folder - getErr error - getCallCount int - err error - // hasOtherAudio is returned by HasAudioOutsideFolders (the album-root - // check). False means the parent qualifies as an album root. - hasOtherAudio bool - otherAudioErr error -} - -func (f *fakeFolderRepo) GetAll(...model.QueryOptions) ([]model.Folder, error) { - return f.result, f.err -} - -func (f *fakeFolderRepo) HasAudioOutsideFolders(model.Folder, []string) (bool, error) { - return f.hasOtherAudio, f.otherAudioErr -} - -func (f *fakeFolderRepo) Get(id string) (*model.Folder, error) { - f.getCallCount++ - if f.getErr != nil { - return nil, f.getErr - } - if f.parentResult != nil { - return f.parentResult, nil - } - return nil, model.ErrNotFound -} - -type fakeDataStore struct { - model.DataStore - folderRepo *fakeFolderRepo -} - -func (fds *fakeDataStore) Folder(_ context.Context) model.FolderRepository { - return fds.folderRepo -} - -func stubCoreAbsolutePath() func() { - // Override core.AbsolutePath to return a fixed string during tests. - original := core.AbsolutePath - core.AbsolutePath = func(_ context.Context, ds model.DataStore, libID int, p string) string { - return filepath.FromSlash("/music") - } - return func() { - core.AbsolutePath = original - } -} diff --git a/core/artwork/reader_mediafile.go b/core/artwork/reader_mediafile.go deleted file mode 100644 index eac3c5e70..000000000 --- a/core/artwork/reader_mediafile.go +++ /dev/null @@ -1,82 +0,0 @@ -package artwork - -import ( - "context" - "fmt" - "io" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/model" -) - -type mediafileArtworkReader struct { - cacheKey - a *artwork - mediafile model.MediaFile - album model.Album - lib libraryView -} - -func newMediafileArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID) (*mediafileArtworkReader, error) { - mf, err := artwork.ds.MediaFile(ctx).Get(artID.ID) - if err != nil { - return nil, err - } - al, err := artwork.ds.Album(ctx).Get(mf.AlbumID) - if err != nil { - return nil, err - } - _, _, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, artwork.ds, *al) - if err != nil { - return nil, err - } - lib, err := loadLibraryView(ctx, artwork.ds, mf.LibraryID) - if err != nil { - return nil, err - } - a := &mediafileArtworkReader{ - a: artwork, - mediafile: *mf, - album: *al, - lib: lib, - } - a.cacheKey.artID = artID - a.cacheKey.lastUpdate = mf.UpdatedAt - if al.UpdatedAt.After(a.cacheKey.lastUpdate) { - a.cacheKey.lastUpdate = al.UpdatedAt - } - if imagesUpdatedAt != nil && imagesUpdatedAt.After(a.cacheKey.lastUpdate) { - a.cacheKey.lastUpdate = *imagesUpdatedAt - } - return a, nil -} - -func (a *mediafileArtworkReader) Key() string { - return fmt.Sprintf( - "%s.%t", - a.cacheKey.Key(), - conf.Server.EnableMediaFileCoverArt, - ) -} -func (a *mediafileArtworkReader) LastUpdated() time.Time { - return a.lastUpdate -} - -func (a *mediafileArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) { - var ff []sourceFunc - if a.mediafile.CoverArtID().Kind == model.KindMediaFileArtwork { - ff = []sourceFunc{ - fromTag(ctx, a.lib.FS, a.mediafile.Path), - fromFFmpegTag(ctx, a.a.ffmpeg, a.lib.Abs(a.mediafile.Path)), - } - } - // For multi-disc albums, fall back to disc artwork first; for single-disc albums, - // skip disc resolution (it would just fall through to album art anyway). - if len(a.album.Discs) > 1 { - ff = append(ff, fromAlbum(ctx, a.a, a.mediafile.DiscCoverArtID())) - } else { - ff = append(ff, fromAlbum(ctx, a.a, a.mediafile.AlbumCoverArtID())) - } - return selectImageReader(ctx, a.artID, ff...) -} diff --git a/core/artwork/reader_playlist.go b/core/artwork/reader_playlist.go deleted file mode 100644 index 09707843d..000000000 --- a/core/artwork/reader_playlist.go +++ /dev/null @@ -1,269 +0,0 @@ -package artwork - -import ( - "bytes" - "context" - "errors" - "image" - "image/draw" - "image/png" - "io" - "net/url" - "os" - "path/filepath" - "strings" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/slice" - xdraw "golang.org/x/image/draw" -) - -type playlistArtworkReader struct { - cacheKey - a *artwork - pl model.Playlist -} - -const tileSize = 600 - -func newPlaylistArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID) (*playlistArtworkReader, error) { - pl, err := artwork.ds.Playlist(ctx).Get(artID.ID) - if err != nil { - return nil, err - } - a := &playlistArtworkReader{ - a: artwork, - pl: *pl, - } - a.cacheKey.artID = artID - a.cacheKey.lastUpdate = pl.UpdatedAt - - // Check sidecar and ExternalImageURL local file ModTimes for cache invalidation. - // If either is newer than the playlist's UpdatedAt, use that instead so the - // cache is busted when a user replaces a sidecar image or local file reference. - for _, path := range []string{ - findPlaylistSidecarPath(ctx, pl.Path), - pl.ExternalImageURL, - } { - if path == "" || strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") { - continue - } - if info, err := os.Stat(path); err == nil { - if info.ModTime().After(a.cacheKey.lastUpdate) { - a.cacheKey.lastUpdate = info.ModTime() - } - } - } - - return a, nil -} - -func (a *playlistArtworkReader) LastUpdated() time.Time { - return a.lastUpdate -} - -func (a *playlistArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) { - return selectImageReader(ctx, a.artID, - a.fromPlaylistUploadedImage(), - a.fromPlaylistSidecar(ctx), - a.fromPlaylistExternalImage(ctx), - a.fromGeneratedTiledCover(ctx), - fromAlbumPlaceholder(), - ) -} - -func (a *playlistArtworkReader) fromPlaylistUploadedImage() sourceFunc { - return fromLocalFile(a.pl.UploadedImagePath()) -} - -func (a *playlistArtworkReader) fromPlaylistSidecar(ctx context.Context) sourceFunc { - return fromLocalFile(findPlaylistSidecarPath(ctx, a.pl.Path)) -} - -func (a *playlistArtworkReader) fromPlaylistExternalImage(ctx context.Context) sourceFunc { - return func() (io.ReadCloser, string, error) { - imgURL := a.pl.ExternalImageURL - if imgURL == "" { - return nil, "", nil - } - parsed, err := url.Parse(imgURL) - if err != nil { - return nil, "", err - } - if parsed.Scheme == "http" || parsed.Scheme == "https" { - if !conf.Server.EnableM3UExternalAlbumArt { - return nil, "", nil - } - return fromURL(ctx, parsed) - } - return fromLocalFile(imgURL)() - } -} - -// fromLocalFile returns a sourceFunc that opens the given local path. -// Returns (nil, "", nil) if path is empty — signalling "not found, try next source". -func fromLocalFile(path string) sourceFunc { - return func() (io.ReadCloser, string, error) { - if path == "" { - return nil, "", nil - } - f, err := os.Open(path) - if err != nil { - return nil, "", err - } - return f, path, nil - } -} - -// findPlaylistSidecarPath scans the directory of the playlist file for a sidecar -// image file with the same base name (case-insensitive). Returns empty string if -// no matching image is found or if plsPath is empty. -func findPlaylistSidecarPath(ctx context.Context, plsPath string) string { - if plsPath == "" { - return "" - } - dir := filepath.Dir(plsPath) - base := strings.TrimSuffix(filepath.Base(plsPath), filepath.Ext(plsPath)) - - entries, err := os.ReadDir(dir) - if err != nil { - log.Warn(ctx, "Could not read directory for playlist sidecar", "dir", dir, err) - return "" - } - for _, entry := range entries { - name := entry.Name() - nameBase := strings.TrimSuffix(name, filepath.Ext(name)) - if !entry.IsDir() && strings.EqualFold(nameBase, base) && model.IsImageFile(name) { - return filepath.Join(dir, name) - } - } - return "" -} - -func (a *playlistArtworkReader) fromGeneratedTiledCover(ctx context.Context) sourceFunc { - return func() (io.ReadCloser, string, error) { - tiles, err := a.loadTiles(ctx) - if err != nil { - return nil, "", err - } - r, err := a.createTiledImage(ctx, tiles) - return r, "", err - } -} - -func toAlbumArtworkIDs(albumIDs []string) []model.ArtworkID { - return slice.Map(albumIDs, func(id string) model.ArtworkID { - al := model.Album{ID: id} - return al.CoverArtID() - }) -} - -func (a *playlistArtworkReader) loadTiles(ctx context.Context) ([]image.Image, error) { - tracksRepo := a.a.ds.Playlist(ctx).Tracks(a.pl.ID, false) - albumIds, err := tracksRepo.GetAlbumIDs(model.QueryOptions{Max: 4, Sort: "random()"}) - if err != nil { - log.Error(ctx, "Error getting album IDs for playlist", "id", a.pl.ID, "name", a.pl.Name, err) - return nil, err - } - ids := toAlbumArtworkIDs(albumIds) - - var tiles []image.Image - for _, id := range ids { - r, _, err := fromAlbum(ctx, a.a, id)() - if err == nil { - tile, err := a.createTile(ctx, r) - if err == nil { - tiles = append(tiles, tile) - } - _ = r.Close() - } - if len(tiles) == 4 { - break - } - } - switch len(tiles) { - case 0: - return nil, errors.New("could not find any eligible cover") - case 2: - tiles = append(tiles, tiles[1], tiles[0]) - case 3: - tiles = append(tiles, tiles[0]) - } - return tiles, nil -} - -func (a *playlistArtworkReader) createTile(_ context.Context, r io.ReadCloser) (image.Image, error) { - img, _, err := image.Decode(r) - if err != nil { - return nil, err - } - return fillCenter(img, tileSize/2, tileSize/2), nil -} - -func (a *playlistArtworkReader) createTiledImage(_ context.Context, tiles []image.Image) (io.ReadCloser, error) { - buf := new(bytes.Buffer) - var rgba draw.Image - var err error - if len(tiles) == 4 { - rgba = image.NewRGBA(image.Rectangle{Max: image.Point{X: tileSize - 1, Y: tileSize - 1}}) - draw.Draw(rgba, rect(0), tiles[0], image.Point{}, draw.Src) - draw.Draw(rgba, rect(1), tiles[1], image.Point{}, draw.Src) - draw.Draw(rgba, rect(2), tiles[2], image.Point{}, draw.Src) - draw.Draw(rgba, rect(3), tiles[3], image.Point{}, draw.Src) - err = png.Encode(buf, rgba) - } else { - err = png.Encode(buf, tiles[0]) - } - if err != nil { - return nil, err - } - return io.NopCloser(buf), nil -} - -func rect(pos int) image.Rectangle { - r := image.Rectangle{} - switch pos { - case 1: - r.Min.X = tileSize / 2 - case 2: - r.Min.Y = tileSize / 2 - case 3: - r.Min.X = tileSize / 2 - r.Min.Y = tileSize / 2 - } - r.Max.X = r.Min.X + tileSize/2 - r.Max.Y = r.Min.Y + tileSize/2 - return r -} - -// fillCenter crops the source image from the center and scales it to fill dstW x dstH exactly, -// equivalent to imaging.Fill with Center anchor. -func fillCenter(src image.Image, dstW, dstH int) image.Image { - srcBounds := src.Bounds() - srcW := srcBounds.Dx() - srcH := srcBounds.Dy() - - // Calculate crop rectangle (center crop to match destination aspect ratio) - srcAspect := float64(srcW) / float64(srcH) - dstAspect := float64(dstW) / float64(dstH) - - var cropRect image.Rectangle - if srcAspect > dstAspect { - // Source is wider — crop horizontally - cropW := int(float64(srcH) * dstAspect) - cropX := (srcW - cropW) / 2 - cropRect = image.Rect(srcBounds.Min.X+cropX, srcBounds.Min.Y, srcBounds.Min.X+cropX+cropW, srcBounds.Max.Y) - } else { - // Source is taller — crop vertically - cropH := int(float64(srcW) / dstAspect) - cropY := (srcH - cropH) / 2 - cropRect = image.Rect(srcBounds.Min.X, srcBounds.Min.Y+cropY, srcBounds.Max.X, srcBounds.Min.Y+cropY+cropH) - } - - dst := image.NewNRGBA(image.Rect(0, 0, dstW, dstH)) - xdraw.CatmullRom.Scale(dst, dst.Bounds(), src, cropRect, draw.Src, nil) - return dst -} diff --git a/core/artwork/reader_radio.go b/core/artwork/reader_radio.go deleted file mode 100644 index 22db6e302..000000000 --- a/core/artwork/reader_radio.go +++ /dev/null @@ -1,40 +0,0 @@ -package artwork - -import ( - "context" - "io" - "time" - - "github.com/navidrome/navidrome/model" -) - -type radioArtworkReader struct { - cacheKey - a *artwork - radio model.Radio -} - -func newRadioArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID) (*radioArtworkReader, error) { - r, err := artwork.ds.Radio(ctx).Get(artID.ID) - if err != nil { - return nil, err - } - a := &radioArtworkReader{a: artwork, radio: *r} - a.cacheKey.artID = artID - a.cacheKey.lastUpdate = r.UpdatedAt - return a, nil -} - -func (a *radioArtworkReader) LastUpdated() time.Time { - return a.lastUpdate -} - -func (a *radioArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) { - return selectImageReader(ctx, a.artID, - a.fromRadioUploadedImage(), - ) -} - -func (a *radioArtworkReader) fromRadioUploadedImage() sourceFunc { - return fromLocalFile(a.radio.UploadedImagePath()) -} diff --git a/core/artwork/reader_radio_test.go b/core/artwork/reader_radio_test.go deleted file mode 100644 index 37ce1d827..000000000 --- a/core/artwork/reader_radio_test.go +++ /dev/null @@ -1,84 +0,0 @@ -package artwork - -import ( - "context" - "os" - "path/filepath" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("radioArtworkReader", func() { - var ( - tempDir string - reader *radioArtworkReader - ) - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - tempDir = GinkgoT().TempDir() - conf.Server.DataFolder = conf.NewDir(tempDir) - - Expect(os.MkdirAll(filepath.Join(tempDir, "artwork", "radio"), 0755)).To(Succeed()) - - reader = &radioArtworkReader{} - }) - - Describe("fromRadioUploadedImage", func() { - When("radio has an uploaded image", func() { - It("returns the uploaded image", func() { - imgPath := filepath.Join(tempDir, "artwork", "radio", "rd-1_test.jpg") - Expect(os.WriteFile(imgPath, []byte("uploaded radio image"), 0600)).To(Succeed()) - - reader.radio = model.Radio{ID: "rd-1", UploadedImage: "rd-1_test.jpg"} - sf := reader.fromRadioUploadedImage() - r, path, err := sf() - Expect(err).ToNot(HaveOccurred()) - Expect(r).ToNot(BeNil()) - Expect(path).To(Equal(imgPath)) - r.Close() - }) - }) - - When("radio has no uploaded image", func() { - It("returns nil reader (falls through)", func() { - reader.radio = model.Radio{ID: "rd-1"} - sf := reader.fromRadioUploadedImage() - r, path, err := sf() - Expect(err).ToNot(HaveOccurred()) - Expect(r).To(BeNil()) - Expect(path).To(BeEmpty()) - }) - }) - }) - - Describe("Reader", func() { - When("radio has an uploaded image", func() { - It("returns the image reader", func() { - imgPath := filepath.Join(tempDir, "artwork", "radio", "rd-1_test.jpg") - Expect(os.WriteFile(imgPath, []byte("uploaded radio image"), 0600)).To(Succeed()) - - reader.radio = model.Radio{ID: "rd-1", UploadedImage: "rd-1_test.jpg"} - reader.cacheKey.artID = model.ArtworkID{Kind: model.KindRadioArtwork, ID: "rd-1"} - r, _, err := reader.Reader(context.Background()) - Expect(err).ToNot(HaveOccurred()) - Expect(r).ToNot(BeNil()) - r.Close() - }) - }) - - When("radio has no uploaded image", func() { - It("returns ErrUnavailable", func() { - reader.radio = model.Radio{ID: "rd-1"} - reader.cacheKey.artID = model.ArtworkID{Kind: model.KindRadioArtwork, ID: "rd-1"} - r, _, err := reader.Reader(context.Background()) - Expect(err).To(MatchError(ErrUnavailable)) - Expect(r).To(BeNil()) - }) - }) - }) -}) diff --git a/core/artwork/reader_resized.go b/core/artwork/reader_resized.go deleted file mode 100644 index cd16cbada..000000000 --- a/core/artwork/reader_resized.go +++ /dev/null @@ -1,209 +0,0 @@ -package artwork - -import ( - "bytes" - "context" - "fmt" - "image" - "image/draw" - "image/jpeg" - "image/png" - "io" - "sync" - "time" - - "github.com/gen2brain/webp" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - xdraw "golang.org/x/image/draw" -) - -func init() { - conf.AddHook(func() { - // gen2brain/webp selects native (purego/libwebp) vs WASM in its own - // package init() and exposes the result only via webp.Dynamic(); there is - // no runtime way to switch back. On 32-bit ARM/x86 the purego callback path - // crashes (issue #5597), so those builds must be compiled with the - // "nodynamic" tag (see Dockerfile), which makes webp.Dynamic() report an - // error here and forces the safe WASM path. - if err := webp.Dynamic(); err != nil { - log.Debug("Using WASM WebP encoder/decoder", "reason", err) - } else { - log.Debug("Using native libwebp for WebP encoding/decoding") - } - }) -} - -var bufPool = sync.Pool{ - New: func() any { - return new(bytes.Buffer) - }, -} - -type resizedArtworkReader struct { - artID model.ArtworkID - cacheKey string - lastUpdate time.Time - size int - square bool - a *artwork -} - -func resizedFromOriginal(ctx context.Context, a *artwork, artID model.ArtworkID, size int, square bool) (*resizedArtworkReader, error) { - r := &resizedArtworkReader{a: a} - r.artID = artID - r.size = size - r.square = square - - // Get lastUpdated and cacheKey from original artwork - original, err := a.getArtworkReader(ctx, artID, 0, false) - if err != nil { - return nil, err - } - r.cacheKey = original.Key() - r.lastUpdate = original.LastUpdated() - return r, nil -} - -func (a *resizedArtworkReader) Key() string { - baseKey := fmt.Sprintf("%s.%d", a.cacheKey, a.size) - if a.square { - return baseKey + ".square" - } - return fmt.Sprintf("%s.%d", baseKey, conf.Server.CoverArtQuality) -} - -func (a *resizedArtworkReader) LastUpdated() time.Time { - return a.lastUpdate -} - -func (a *resizedArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) { - // Get artwork in original size, possibly from cache - orig, _, err := a.a.Get(ctx, a.artID, 0, false) - if err != nil { - return nil, "", err - } - defer orig.Close() - - resized, origSize, err := a.resizeImage(ctx, orig) - if resized == nil { - log.Trace(ctx, "Image smaller than requested size", "artID", a.artID, "original", origSize, "resized", a.size, "square", a.square) - } else { - log.Trace(ctx, "Resizing artwork", "artID", a.artID, "original", origSize, "resized", a.size, "square", a.square) - } - if err != nil { - log.Warn(ctx, "Could not resize image. Will return image as is", "artID", a.artID, "size", a.size, "square", a.square, err) - } - if err != nil || resized == nil { - // if we couldn't resize the image, return the original - orig, _, err = a.a.Get(ctx, a.artID, 0, false) - return orig, "", err - } - // Preserve ReadCloser semantics if the resized reader already supports Close - // (e.g., ffmpeg pipe), otherwise wrap with NopCloser - if rc, ok := resized.(io.ReadCloser); ok { - return rc, fmt.Sprintf("%s@%d", a.artID, a.size), nil - } - return io.NopCloser(resized), fmt.Sprintf("%s@%d", a.artID, a.size), nil -} - -func (a *resizedArtworkReader) resizeImage(ctx context.Context, reader io.Reader) (io.Reader, int, error) { - data, err := io.ReadAll(reader) - if err != nil { - return nil, 0, fmt.Errorf("reading image data: %w", err) - } - - // Preserve animation for animated images - if isAnimatedGIF(data) { - if a.a.ffmpeg.IsAvailable() { - // Animated GIF: convert to animated WebP via ffmpeg (with optional resize) - r, err := a.a.ffmpeg.ConvertAnimatedImage(ctx, bytes.NewReader(data), a.size, conf.Server.CoverArtQuality) - if err == nil { - return r, 0, nil - } - log.Warn(ctx, "Could not convert animated GIF, falling back to static", err) - } - } else if isAnimatedWebP(data) || isAnimatedPNG(data) { - // Animated WebP/APNG: return original as-is (ffmpeg can't re-encode these) - return bytes.NewReader(data), 0, nil - } - - return resizeStaticImage(data, a.size, a.square) -} - -// toFastScaleType converts images whose concrete type has no optimized scaler -// in x/image/draw (e.g. *image.NYCbCrA from WebP, *image.Paletted from indexed -// PNGs) into *image.RGBA, which has a fast path. Without this, CatmullRom.Scale -// falls back to a generic per-pixel At()/RGBA() loop that is several times -// slower. Fast-path types are returned unchanged. -func toFastScaleType(img image.Image) image.Image { - switch img.(type) { - case *image.RGBA, *image.NRGBA, *image.Gray, *image.YCbCr: - return img - default: - rgba := image.NewRGBA(img.Bounds()) - draw.Draw(rgba, rgba.Bounds(), img, img.Bounds().Min, draw.Src) - return rgba - } -} - -func resizeStaticImage(data []byte, size int, square bool) (io.Reader, int, error) { - original, format, err := image.Decode(bytes.NewReader(data)) - if err != nil { - return nil, 0, err - } - - bounds := original.Bounds() - originalSize := max(bounds.Max.X, bounds.Max.Y) - - // Clamp size to original dimensions - upscaling wastes resources and adds no information - if size > originalSize { - size = originalSize - } - - if originalSize <= size && !square { - return nil, originalSize, nil - } - - // Calculate aspect-fit dimensions - srcW, srcH := bounds.Dx(), bounds.Dy() - scale := float64(size) / float64(max(srcW, srcH)) - dstW := int(float64(srcW) * scale) - dstH := int(float64(srcH) * scale) - - var dst *image.NRGBA - var dstRect image.Rectangle - if square { - // Square canvas with image centered (transparent padding via zero-initialized NRGBA) - dst = image.NewNRGBA(image.Rect(0, 0, size, size)) - offsetX := (size - dstW) / 2 - offsetY := (size - dstH) / 2 - dstRect = image.Rect(offsetX, offsetY, offsetX+dstW, offsetY+dstH) - } else { - // Tight-fit canvas - dst = image.NewNRGBA(image.Rect(0, 0, dstW, dstH)) - dstRect = dst.Bounds() - } - original = toFastScaleType(original) - xdraw.CatmullRom.Scale(dst, dstRect, original, bounds, draw.Src, nil) - - buf := bufPool.Get().(*bytes.Buffer) - buf.Reset() - if conf.Server.EnableWebPEncoding { - err = webp.Encode(buf, dst, webp.Options{Quality: conf.Server.CoverArtQuality}) - } else if format == "png" || square { - err = png.Encode(buf, dst) - } else { - err = jpeg.Encode(buf, dst, &jpeg.Options{Quality: conf.Server.CoverArtQuality}) - } - if err != nil { - bufPool.Put(buf) - return nil, originalSize, err - } - // Copy bytes before returning buffer to pool (pool may reuse the buffer) - encoded := make([]byte, buf.Len()) - copy(encoded, buf.Bytes()) - bufPool.Put(buf) - return bytes.NewReader(encoded), originalSize, nil -} diff --git a/core/artwork/reader_resized_test.go b/core/artwork/reader_resized_test.go deleted file mode 100644 index 7c14f5e44..000000000 --- a/core/artwork/reader_resized_test.go +++ /dev/null @@ -1,176 +0,0 @@ -package artwork - -import ( - "bytes" - "context" - "errors" - "io" - - "github.com/navidrome/navidrome/core/ffmpeg" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("resizeImage", func() { - var mockFF *tests.MockFFmpeg - var r *resizedArtworkReader - - BeforeEach(func() { - mockFF = tests.NewMockFFmpeg("converted-animated-data") - r = &resizedArtworkReader{ - size: 300, - square: false, - a: &artwork{ffmpeg: mockFF}, - } - }) - - Describe("animated GIF handling", func() { - It("converts animated GIF via ffmpeg when available", func() { - data := createAnimatedGIF(3) - result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data)) - Expect(err).ToNot(HaveOccurred()) - Expect(result).ToNot(BeNil()) - - // Should have been processed by ffmpeg (mock returns "converted-animated-data") - output, err := io.ReadAll(result) - Expect(err).ToNot(HaveOccurred()) - Expect(output).To(Equal(data)) // MockFFmpeg echoes input back - }) - - It("falls back to static resize when ffmpeg fails for animated GIF", func() { - mockFF.Error = errors.New("ffmpeg failed") - // Use size smaller than image so static resize actually produces output - r.size = 1 - data := createAnimatedGIF(3) - result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data)) - // Should fall through to static resize successfully (no ffmpeg error propagated) - Expect(err).ToNot(HaveOccurred()) - Expect(result).ToNot(BeNil()) - - // Verify it's a static image (WebP encoded), not the ffmpeg error - output, err := io.ReadAll(result) - Expect(err).ToNot(HaveOccurred()) - Expect(len(output)).To(BeNumerically(">", 0)) - }) - - It("preserves animation for square thumbnails with animated GIF", func() { - r.square = true - data := createAnimatedGIF(3) - result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data)) - Expect(err).ToNot(HaveOccurred()) - Expect(result).ToNot(BeNil()) - - // Should have been processed by ffmpeg (mock returns input data) - output, err := io.ReadAll(result) - Expect(err).ToNot(HaveOccurred()) - Expect(output).To(Equal(data)) - }) - }) - - Describe("animated WebP handling", func() { - It("returns animated WebP data as-is when not square", func() { - data := createAnimatedWebPBytes() - result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data)) - Expect(err).ToNot(HaveOccurred()) - Expect(result).ToNot(BeNil()) - - // Should return original data unchanged - output, err := io.ReadAll(result) - Expect(err).ToNot(HaveOccurred()) - Expect(output).To(Equal(data)) - }) - - It("preserves animated WebP for square thumbnails", func() { - r.square = true - data := createAnimatedWebPBytes() - result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data)) - Expect(err).ToNot(HaveOccurred()) - Expect(result).ToNot(BeNil()) - - // Should return original data unchanged - output, err := io.ReadAll(result) - Expect(err).ToNot(HaveOccurred()) - Expect(output).To(Equal(data)) - }) - }) - - Describe("animated PNG handling", func() { - It("returns animated PNG data as-is when not square", func() { - data := createAPNGBytes() - result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data)) - Expect(err).ToNot(HaveOccurred()) - Expect(result).ToNot(BeNil()) - - // Should return original data unchanged - output, err := io.ReadAll(result) - Expect(err).ToNot(HaveOccurred()) - Expect(output).To(Equal(data)) - }) - - It("preserves animated PNG for square thumbnails", func() { - r.square = true - data := createAPNGBytes() - result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data)) - Expect(err).ToNot(HaveOccurred()) - Expect(result).ToNot(BeNil()) - - // Should return original data unchanged - output, err := io.ReadAll(result) - Expect(err).ToNot(HaveOccurred()) - Expect(output).To(Equal(data)) - }) - }) - - Describe("static image handling", func() { - It("resizes a static PNG normally", func() { - data := createStaticPNGBytes() - result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data)) - // Static PNG is 2x2, size 300 is larger, so should return nil (no upscale) - Expect(err).ToNot(HaveOccurred()) - Expect(result).To(BeNil()) - }) - }) - - Describe("ReadCloser preservation", func() { - It("preserves Close semantics from ffmpeg ReadCloser", func() { - // Create a trackable ReadCloser - tracker := &closeTracker{Reader: bytes.NewReader([]byte("test data"))} - mockFF2 := &mockFFmpegWithCloser{tracker: tracker} - r.a = &artwork{ffmpeg: mockFF2} - - data := createAnimatedGIF(3) - result, _, err := r.resizeImage(context.Background(), bytes.NewReader(data)) - Expect(err).ToNot(HaveOccurred()) - - // The result should be an io.ReadCloser (the tracker) - rc, ok := result.(io.ReadCloser) - Expect(ok).To(BeTrue()) - Expect(rc.Close()).ToNot(HaveOccurred()) - Expect(tracker.closed).To(BeTrue()) - }) - }) -}) - -// closeTracker is an io.ReadCloser that tracks whether Close was called. -type closeTracker struct { - io.Reader - closed bool -} - -func (c *closeTracker) Close() error { - c.closed = true - return nil -} - -// mockFFmpegWithCloser is a minimal FFmpeg mock that returns a specific ReadCloser -// for ConvertAnimatedImage, allowing us to verify Close propagation. -type mockFFmpegWithCloser struct { - ffmpeg.FFmpeg - tracker *closeTracker -} - -func (m *mockFFmpegWithCloser) IsAvailable() bool { return true } -func (m *mockFFmpegWithCloser) ConvertAnimatedImage(_ context.Context, _ io.Reader, _ int, _ int) (io.ReadCloser, error) { - return m.tracker, nil -} diff --git a/core/artwork/resize.go b/core/artwork/resize.go new file mode 100644 index 000000000..9536c3bb1 --- /dev/null +++ b/core/artwork/resize.go @@ -0,0 +1,144 @@ +package artwork + +import ( + "bytes" + "context" + "fmt" + "image" + "image/draw" + "image/jpeg" + "image/png" + "io" + "sync" + "time" + + "github.com/gen2brain/webp" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core/ffmpeg" + "github.com/navidrome/navidrome/log" + xdraw "golang.org/x/image/draw" +) + +func init() { + conf.AddHook(func() { + // gen2brain/webp picks native vs WASM in its own init(), with no way to switch at + // runtime: 32-bit builds need the "nodynamic" tag (see Dockerfile) to force WASM. + if err := webp.Dynamic(); err != nil { + log.Debug("Artwork: Using WASM WebP encoder/decoder", "reason", err) + } else { + log.Debug("Artwork: Using native libwebp for WebP encoding/decoding") + } + }) +} + +var bufPool = sync.Pool{ + New: func() any { + return new(bytes.Buffer) + }, +} + +// resizeImageData resizes raw image bytes to fit size, preserving animation where +// possible. A nil reader means the image was already within bounds (no resize needed). +func resizeImageData(ctx context.Context, ffm ffmpeg.FFmpeg, data []byte, size int, square bool) (io.Reader, int, error) { + start := time.Now() + defer func() { + log.Trace(ctx, "Artwork: Resized image", "bytes", len(data), "size", size, "square", square, + "elapsed", time.Since(start)) + }() + if isAnimatedGIF(data) { + if ffm.IsAvailable() { + // Animated GIF: convert to animated WebP via ffmpeg (with optional resize) + r, err := ffm.ConvertAnimatedImage(ctx, bytes.NewReader(data), size, conf.Server.CoverArtQuality) + if err == nil { + return r, 0, nil + } + log.Warn(ctx, "Artwork: Could not convert animated GIF, falling back to static", err) + } + } else if isAnimatedWebP(data) || isAnimatedPNG(data) { + // Animated WebP/APNG: return original as-is (ffmpeg can't re-encode these) + return bytes.NewReader(data), 0, nil + } + + return resizeStaticImage(data, size, square) +} + +// toFastScaleType converts types x/image/draw has no optimized scaler for (e.g. *image.NYCbCrA, +// *image.Paletted) to *image.RGBA, avoiding CatmullRom.Scale's generic per-pixel fallback. +func toFastScaleType(img image.Image) image.Image { + switch img.(type) { + case *image.RGBA, *image.NRGBA, *image.Gray, *image.YCbCr: + return img + default: + rgba := image.NewRGBA(img.Bounds()) + draw.Draw(rgba, rgba.Bounds(), img, img.Bounds().Min, draw.Src) + return rgba + } +} + +func resizeStaticImage(data []byte, size int, square bool) (io.Reader, int, error) { + original, format, err := image.Decode(bytes.NewReader(data)) + if err != nil { + return nil, 0, err + } + + bounds := original.Bounds() + originalSize := max(bounds.Max.X, bounds.Max.Y) + + // Clamp size to original dimensions - upscaling wastes resources and adds no information + if size > originalSize { + size = originalSize + } + + if originalSize <= size && !square { + return nil, originalSize, nil + } + + // Calculate aspect-fit dimensions + srcW, srcH := bounds.Dx(), bounds.Dy() + scale := float64(size) / float64(max(srcW, srcH)) + dstW := int(float64(srcW) * scale) + dstH := int(float64(srcH) * scale) + + var dst *image.NRGBA + var dstRect image.Rectangle + if square { + // Square canvas with image centered (transparent padding via zero-initialized NRGBA) + dst = image.NewNRGBA(image.Rect(0, 0, size, size)) + offsetX := (size - dstW) / 2 + offsetY := (size - dstH) / 2 + dstRect = image.Rect(offsetX, offsetY, offsetX+dstW, offsetY+dstH) + } else { + // Tight-fit canvas + dst = image.NewNRGBA(image.Rect(0, 0, dstW, dstH)) + dstRect = dst.Bounds() + } + original = toFastScaleType(original) + xdraw.CatmullRom.Scale(dst, dstRect, original, bounds, draw.Src, nil) + + buf := bufPool.Get().(*bytes.Buffer) + buf.Reset() + if conf.Server.EnableWebPEncoding { + err = webp.Encode(buf, dst, webp.Options{Quality: conf.Server.CoverArtQuality}) + } else if format == "png" || square { + err = png.Encode(buf, dst) + } else { + err = jpeg.Encode(buf, dst, &jpeg.Options{Quality: conf.Server.CoverArtQuality}) + } + if err != nil { + bufPool.Put(buf) + return nil, originalSize, err + } + // Copy bytes before returning buffer to pool (pool may reuse the buffer) + encoded := bytes.Clone(buf.Bytes()) + bufPool.Put(buf) + return bytes.NewReader(encoded), originalSize, nil +} + +// formatQualityTag folds the encoder config (WebP toggle + quality) into a cache-key +// fragment, so flipping either setting invalidates previously-encoded sized artwork. +func formatQualityTag() string { + if conf.Server.EnableWebPEncoding { + return fmt.Sprintf("webp%d", conf.Server.CoverArtQuality) + } + return fmt.Sprintf("q%d", conf.Server.CoverArtQuality) +} diff --git a/core/artwork/resolve.go b/core/artwork/resolve.go new file mode 100644 index 000000000..11711469d --- /dev/null +++ b/core/artwork/resolve.go @@ -0,0 +1,452 @@ +package artwork + +import ( + "context" + "errors" + "fmt" + "image" + "io" + "io/fs" + "net/url" + "os" + "strings" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/ffmpeg" + "github.com/navidrome/navidrome/model" +) + +// resolution is one attempted acquisition outcome for an entity. +type resolution struct { + reader io.ReadCloser // nil when no source yielded an image + source string // model.ItemArtwork.Source value: "folder", "embedded", "external", "upload", "generated" + sourcePath string // backing library/upload file (folder/upload: the image; embedded: the audio file); "" otherwise + refMtime int64 // sourcePath mtime (unix-nanoseconds) at resolution; 0 when no sourcePath + // external source errored/timed out. With no reader it forces failed (never absent); + // on a hit a higher-priority external step failed—serve this, but retry later. + extError bool + // a local source that should have been readable wasn't. With no reader it forces failed, + // so a transient I/O fault never records absent. + localError bool +} + +// chainState carries what a priority walk has seen so far. A hit takes extErr with it so a +// transient external failure still retries; localErr is dropped, as the scanner re-lists changes. +type chainState struct{ extErr, localErr bool } + +// try stamps the accumulated external failure onto a hit, and records the miss otherwise. +func (c *chainState) try(res resolution, ok bool) (resolution, bool) { + if ok { + res.extError = c.extErr + return res, true + } + c.localErr = c.localErr || res.localError + return resolution{}, false +} + +// exhausted is the outcome when no source in the chain yielded an image. +func (c *chainState) exhausted() resolution { + return resolution{extError: c.extErr, localError: c.localErr} +} + +// externalSource holds the agents to ask and the rate limiter/circuit breaker to ask them through. +type externalSource struct { + agents *agents.Agents + gate gateFunc +} + +// resolver walks a kind's priority chain and returns the first hit; a nil ext means local-only. +type resolver struct { + ds model.DataStore + ffmpeg ffmpeg.FFmpeg + ext *externalSource +} + +func newResolver(ds model.DataStore, ag *agents.Agents, ffm ffmpeg.FFmpeg, gate gateFunc) *resolver { + if gate == nil { + gate = passthroughGate + } + return &resolver{ds: ds, ffmpeg: ffm, ext: &externalSource{agents: ag, gate: gate}} +} + +// newLocalResolver builds a resolver that can neither reach the network nor sample album art +// for the worker-built grid. +func newLocalResolver(ds model.DataStore, ffm ffmpeg.FFmpeg) *resolver { + return &resolver{ds: ds, ffmpeg: ffm} +} + +func (r *resolver) resolve(ctx context.Context, item model.ArtworkQueueItem) (resolution, error) { + kind, _ := model.ParseKind(item.ItemKind) + switch kind { + case model.KindAlbumArtwork: + return r.resolveAlbum(ctx, item.ItemID) + case model.KindArtistArtwork: + return r.resolveArtist(ctx, item.ItemID) + case model.KindPlaylistArtwork: + return r.resolvePlaylist(ctx, item.ItemID) + case model.KindRadioArtwork: + return r.resolveRadio(ctx, item.ItemID) + case model.KindMediaFileArtwork: + return r.resolveMediaFile(ctx, item.ItemID) + default: + return resolution{}, fmt.Errorf("artwork: kind %q is not resolvable by the worker", item.ItemKind) + } +} + +// fetchExternalAlbum and fetchExternalArtist are the only places resolution touches the network, +// so a local-only resolver is stopped here rather than at each point in the chain walk. +func (r *resolver) fetchExternalAlbum(ctx context.Context, al model.Album) (io.ReadCloser, string, bool) { + if r.ext == nil { + return nil, "", false + } + return fetchAlbumImage(ctx, r.ext.agents, r.ext.gate, al) +} + +func (r *resolver) fetchExternalArtist(ctx context.Context, ar model.Artist) (io.ReadCloser, string, bool) { + if r.ext == nil { + return nil, "", false + } + return fetchArtistImage(ctx, r.ext.agents, r.ext.gate, ar) +} + +// resolveAlbum walks conf.Server.CoverArtPriority over the folder, embedded and external sources. +func (r *resolver) resolveAlbum(ctx context.Context, albumID string) (resolution, error) { + al, err := r.ds.Album(ctx).Get(albumID) + if err != nil { + return resolution{}, err + } + _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, r.ds, *al) + if err != nil { + return resolution{}, err + } + lib, err := loadLibraryView(ctx, r.ds, al.LibraryID) + if err != nil { + return resolution{}, err + } + + var chain chainState + for pattern := range strings.SplitSeq(strings.ToLower(conf.Server.CoverArtPriority), ",") { + pattern = strings.TrimSpace(pattern) + switch { + case pattern == "embedded": + if res, ok := chain.try(resolveEmbedded(ctx, lib, r.ffmpeg, al.EmbedArtPath)); ok { + return res, nil + } + case pattern == "external": + if rd, name, isErr := r.fetchExternalAlbum(ctx, *al); rd != nil { + return resolution{reader: rd, source: "external:" + name}, nil + } else if isErr { + chain.extErr = true + } + case len(imgFiles) > 0: + if res, ok := chain.try(resolveFolderFile(ctx, lib, imgFiles, pattern)); ok { + return res, nil + } + } + } + return chain.exhausted(), nil +} + +// resolveArtist tries the uploaded image first, then walks conf.Server.ArtistArtPriority. +func (r *resolver) resolveArtist(ctx context.Context, artistID string) (resolution, error) { + ar, err := r.ds.Artist(ctx).Get(artistID) + if err != nil { + return resolution{}, err + } + upload, ok := resolveLocalFile(ar.UploadedImagePath(), "upload") + if ok { + return upload, nil + } + if upload.localError { + // The upload outranks every other source; falling through would persist a lower-priority + // image as if the upload were gone. + return upload, nil + } + + // Only consider albums where the artist is the sole album artist. + als, err := r.ds.Album(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.And{ + squirrel.Eq{"album_artist_id": artistID}, + squirrel.Eq{"json_array_length(participants, '$.albumartist')": 1}, + }, + }) + if err != nil { + return resolution{}, err + } + albumPaths, imgFiles, _, err := loadArtistAlbumRoots(ctx, r.ds, als) + if err != nil { + return resolution{}, err + } + artistFolder, _, err := loadArtistFolder(ctx, r.ds, als, albumPaths) + if err != nil { + return resolution{}, err + } + var lib libraryView + if len(als) > 0 { + lib, err = loadLibraryView(ctx, r.ds, als[0].LibraryID) + if err != nil { + return resolution{}, err + } + } + + var chain chainState + for pattern := range strings.SplitSeq(strings.ToLower(conf.Server.ArtistArtPriority), ",") { + pattern = strings.TrimSpace(pattern) + switch { + case pattern == "external": + if rd, name, isErr := r.fetchExternalArtist(ctx, *ar); rd != nil { + return resolution{reader: rd, source: "external:" + name}, nil + } else if isErr { + chain.extErr = true + } + case pattern == "image-folder": + if res, ok := chain.try(resolveArtistImageFolder(ar)); ok { + return res, nil + } + case strings.HasPrefix(pattern, "album/"): + if lib.FS == nil { + continue + } + if res, ok := chain.try(resolveFolderFile(ctx, lib, imgFiles, strings.TrimPrefix(pattern, "album/"))); ok { + return res, nil + } + default: + if lib.FS == nil || artistFolder == "" { + continue + } + if res, ok := chain.try(resolveArtistFolderPattern(ctx, lib, artistFolder, pattern)); ok { + return res, nil + } + } + } + return chain.exhausted(), nil +} + +// resolvePlaylist tries the uploaded image, the sidecar and ExternalImageURL, then a generated grid. +func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (resolution, error) { + pl, err := r.ds.Playlist(ctx).Get(playlistID) + if err != nil { + return resolution{}, err + } + + var extErr bool + for _, src := range []struct{ path, source string }{ + {pl.UploadedImagePath(), "upload"}, + {findPlaylistSidecarPath(ctx, pl.Path), "folder"}, + } { + res, ok := resolveLocalFile(src.path, src.source) + if ok { + return res, nil + } + if res.localError { + // These outrank the generated grid; falling through would replace them with it. + return res, nil + } + } + // A local ExternalImageURL is file-backed and served in place; only http(s) needs the gated fetch. + localImg, remoteImg := classifyPlaylistImage(pl.ExternalImageURL) + if localImg != "" { + res, ok := resolveLocalFile(localImg, "folder") + if ok { + return res, nil + } + if res.localError { + return res, nil + } + } + if r.ext == nil { + // The remote fetch and the generated grid are worker-only; a request must do neither. + return resolution{}, nil + } + if remoteImg != nil && conf.Server.EnableM3UExternalAlbumArt { + sf := func() (io.ReadCloser, string, error) { return fromURL(ctx, remoteImg) } + if res, ok, isErr := resolveExternalStep(r.ext.gate, "m3u", sf); ok { + return res, nil + } else if isErr { + extErr = true + } + } + + albumIDs, err := r.ds.Playlist(ctx).Tracks(pl.ID, false).GetAlbumIDs(model.QueryOptions{Max: 4, Sort: "random()"}) + if err != nil { + return resolution{}, err + } + + var tiles []image.Image + var tileErr error // first internal (non-external) tile failure, e.g. album deleted mid-flight + for _, albumID := range albumIDs { + res, err := r.resolveAlbum(ctx, albumID) + if err != nil { + if tileErr == nil { + tileErr = err + } + continue + } + if res.extError { + extErr = true + } + if res.reader == nil { + continue + } + tile, decErr := decodeTile(res.reader) + res.reader.Close() + if decErr == nil { + tiles = append(tiles, tile) + } + if len(tiles) == 4 { + break + } + } + if len(tiles) == 0 { + // A tile-level failure must never resolve as a clean absent. + if tileErr != nil { + return resolution{}, fmt.Errorf("resolvePlaylist: sampled album art failed: %w", tileErr) + } + return resolution{extError: extErr}, nil + } + // Grow to 4 tiles by repeating what we have. + switch len(tiles) { + case 2: + tiles = append(tiles, tiles[1], tiles[0]) + case 3: + tiles = append(tiles, tiles[0]) + } + grid, err := assembleTiles(tiles) + if err != nil { + return resolution{extError: extErr}, nil //nolint:nilerr // encode failure is a soft "no image", not a resolution error + } + return resolution{reader: grid, source: "generated", extError: extErr}, nil +} + +// resolveRadio serves only an uploaded image; there is no fallback. +func (r *resolver) resolveRadio(ctx context.Context, radioID string) (resolution, error) { + radio, err := r.ds.Radio(ctx).Get(radioID) + if err != nil { + return resolution{}, err + } + res, _ := resolveLocalFile(radio.UploadedImagePath(), "upload") + return res, nil +} + +// resolveMediaFile resolves a track's own embedded art only, so disabled or missing cover art +// is a definitive absent. +func (r *resolver) resolveMediaFile(ctx context.Context, id string) (resolution, error) { + mf, err := r.ds.MediaFile(ctx).Get(id) + if err != nil { + return resolution{}, err + } + if !conf.Server.EnableMediaFileCoverArt || !mf.HasCoverArt { + return resolution{}, nil + } + lib, err := loadLibraryView(ctx, r.ds, mf.LibraryID) + if err != nil { + return resolution{}, err + } + res, _ := resolveEmbedded(ctx, lib, r.ffmpeg, mf.Path) + return res, nil +} + +// resolveExternalStep runs a single external sourceFunc through the named gate. extErr excludes +// a not-found, which is a definitive "no" rather than a failure. +func resolveExternalStep(gate gateFunc, name string, sf sourceFunc) (res resolution, ok bool, extErr bool) { + r, path, err := gate(name, sf) + if r != nil { + return resolution{reader: r, source: "external", sourcePath: path}, true, false + } + return resolution{}, false, err != nil && !errors.Is(err, model.ErrNotFound) +} + +// classifyPlaylistImage splits a playlist ExternalImageURL into a local filesystem path or a +// remote http(s) URL; at most one is set. +func classifyPlaylistImage(imageURL string) (localPath string, remote *url.URL) { + if imageURL == "" { + return "", nil + } + u, err := url.Parse(imageURL) + if err != nil { + return imageURL, nil // unparseable → treat as a local path + } + switch u.Scheme { + case "http", "https": + return "", u + case "file": + return u.Path, nil + default: + return imageURL, nil + } +} + +func resolveEmbedded(ctx context.Context, lib libraryView, ffm ffmpeg.FFmpeg, embedRel string) (resolution, bool) { + if embedRel == "" { + return resolution{}, false + } + abs := lib.Abs(embedRel) + var unreadable bool + for _, sf := range []sourceFunc{fromTag(ctx, lib.FS, embedRel), fromFFmpegTag(ctx, ffm, abs)} { + r, _, err := sf() + if r != nil { + return resolution{reader: r, source: "embedded", sourcePath: abs, refMtime: mtimeViaFS(lib.FS, embedRel)}, true + } + unreadable = unreadable || errors.Is(err, errSourceUnreadable) + } + return resolution{localError: unreadable}, false +} + +func resolveFolderFile(ctx context.Context, lib libraryView, imgFiles []string, pattern string) (resolution, bool) { + r, path, err := fromExternalFile(ctx, lib.FS, imgFiles, pattern)() + if r == nil { + return resolution{localError: errors.Is(err, errSourceUnreadable)}, false + } + return resolution{reader: r, source: "folder", sourcePath: lib.Abs(path), refMtime: mtimeViaFS(lib.FS, path)}, true +} + +func resolveArtistImageFolder(ar *model.Artist) (resolution, bool) { + folder := conf.Server.ArtistImageFolder + if folder == "" { + return resolution{}, false + } + return resolveLocalFile(findImageInArtistFolder(folder, ar.MbzArtistID, ar.Name), "folder") +} + +func resolveArtistFolderPattern(ctx context.Context, lib libraryView, artistFolder, pattern string) (resolution, bool) { + r, path, err := fromArtistFolder(ctx, lib.FS, lib.absRoot, artistFolder, pattern)() + if r == nil { + return resolution{localError: errors.Is(err, errSourceUnreadable)}, false + } + return resolution{reader: r, source: "folder", sourcePath: path, refMtime: mtimeOf(path)}, true +} + +// resolveLocalFile opens an absolute path directly. A missing path is "no source"; any other +// open failure says nothing about whether the image exists. +func resolveLocalFile(path, source string) (resolution, bool) { + if path == "" { + return resolution{}, false + } + f, err := os.Open(path) + if err != nil { + return resolution{localError: !errors.Is(err, fs.ErrNotExist)}, false + } + return resolution{reader: f, source: source, sourcePath: path, refMtime: mtimeOf(path)}, true +} + +func mtimeOf(path string) int64 { + info, err := os.Stat(path) + if err != nil { + return 0 + } + return info.ModTime().UnixNano() +} + +// mtimeViaFS stats through the library FS, since library roots in tests may not be real OS paths. +func mtimeViaFS(fsys fs.FS, name string) int64 { + if fsys == nil || name == "" { + return 0 + } + info, err := fs.Stat(fsys, name) + if err != nil { + return 0 + } + return info.ModTime().UnixNano() +} diff --git a/core/artwork/resolve_test.go b/core/artwork/resolve_test.go new file mode 100644 index 000000000..fc1a6a3ff --- /dev/null +++ b/core/artwork/resolve_test.go @@ -0,0 +1,632 @@ +package artwork + +import ( + "bytes" + "context" + "errors" + "image" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync/atomic" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("resolveItem", func() { + var ( + ctx context.Context + ds *tests.MockDataStore + folderRepo *fakeFolderRepo + libRepo *tests.MockLibraryRepo + ffm *tests.MockFFmpeg + ag *agents.Agents + repoRoot string + ) + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + ctx = context.Background() + var err error + repoRoot, err = os.Getwd() + Expect(err).ToNot(HaveOccurred()) + + folderRepo = &fakeFolderRepo{} + libRepo = &tests.MockLibraryRepo{} + libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) + ffm = tests.NewMockFFmpeg("") + ag = agents.GetAgents(&tests.MockDataStore{}, nil) + ds = &tests.MockDataStore{ + MockedFolder: folderRepo, + MockedLibrary: libRepo, + } + }) + + Describe("kind dispatch", func() { + It("returns an error for kinds the worker never enqueues", func() { + _, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "zz", ItemID: "x"}) + Expect(err).To(HaveOccurred()) + }) + }) + + Describe("media file", func() { + BeforeEach(func() { + conf.Server.EnableMediaFileCoverArt = true + ds.MockedMediaFile = tests.CreateMockMediaFileRepo() + }) + + It("resolves embedded art from the track file", func() { + ds.MockedMediaFile.(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "mf1", LibraryID: 0, Path: "tests/fixtures/artist/an-album/test.mp3", HasCoverArt: true}, + }) + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "mf", ItemID: "mf1"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).ToNot(BeNil()) + defer res.reader.Close() + Expect(res.source).To(Equal("embedded")) + Expect(filepath.ToSlash(res.sourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/test.mp3")) + Expect(res.refMtime).To(BeNumerically(">", 0)) + Expect(res.extError).To(BeFalse()) + }) + + It("resolves absent when the track has no cover art", func() { + ds.MockedMediaFile.(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "mf2", LibraryID: 0, Path: "tests/fixtures/artist/an-album/test.mp3", HasCoverArt: false}, + }) + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "mf", ItemID: "mf2"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).To(BeNil()) + Expect(res.extError).To(BeFalse()) + }) + + It("resolves absent when media file cover art is disabled", func() { + conf.Server.EnableMediaFileCoverArt = false + ds.MockedMediaFile.(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "mf3", LibraryID: 0, Path: "tests/fixtures/artist/an-album/test.mp3", HasCoverArt: true}, + }) + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "mf", ItemID: "mf3"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).To(BeNil()) + }) + + It("returns the error when the track is not in the DB", func() { + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "mf", ItemID: "missing"}) + Expect(err).To(MatchError(model.ErrNotFound)) + Expect(res.reader).To(BeNil()) + }) + }) + + Describe("album", func() { + BeforeEach(func() { + conf.Server.CoverArtPriority = "cover.jpg, embedded" + ds.MockedAlbum = tests.CreateMockAlbumRepo() + }) + + It("resolves folder art from the library FS", func() { + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"cover.jpg"}, + }} + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "al1", Name: "Album", FolderIDs: []string{"f1"}}, + }) + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).ToNot(BeNil()) + defer res.reader.Close() + Expect(res.source).To(Equal("folder")) + Expect(filepath.ToSlash(res.sourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/cover.jpg")) + Expect(res.refMtime).To(BeNumerically(">", 0)) + Expect(res.extError).To(BeFalse()) + }) + + It("falls back to embedded art when no folder image matches", func() { + folderRepo.result = nil + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "al2", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}}, + }) + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).ToNot(BeNil()) + defer res.reader.Close() + Expect(res.source).To(Equal("embedded")) + Expect(filepath.ToSlash(res.sourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/test.mp3")) + Expect(res.refMtime).To(BeNumerically(">", 0)) + }) + + It("sets extError when the external source errors without being not-found", func() { + conf.Server.CoverArtPriority = "external" + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "al3", Name: "Album"}, + }) + imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).To(BeNil()) + Expect(res.extError).To(BeTrue()) + }) + + It("does not set extError when the external source reports not-found", func() { + conf.Server.CoverArtPriority = "external" + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "al4", Name: "Album"}, + }) + // no image agents enabled -> the external step is a definitive not-found + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).To(BeNil()) + Expect(res.extError).To(BeFalse()) + }) + + It("carries extError onto a fallback folder hit after a transient external failure", func() { + conf.Server.CoverArtPriority = "external, cover.jpg" + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"cover.jpg"}, + }} + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "al6", Name: "Album", FolderIDs: []string{"f1"}}, + }) + imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al6"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).ToNot(BeNil()) + defer res.reader.Close() + Expect(res.source).To(Equal("folder")) + Expect(res.extError).To(BeTrue()) + }) + + It("does not carry extError onto a fallback folder hit after a definitive external not-found", func() { + conf.Server.CoverArtPriority = "external, cover.jpg" + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"cover.jpg"}, + }} + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "al7", Name: "Album", FolderIDs: []string{"f1"}}, + }) + // no image agents enabled -> the external step is a definitive not-found + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al7"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).ToNot(BeNil()) + defer res.reader.Close() + Expect(res.source).To(Equal("folder")) + Expect(res.extError).To(BeFalse()) + }) + + It("routes the external step through the injected gate, keyed by agent name", func() { + conf.Server.CoverArtPriority = "external" + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "al5", Name: "Album"}, + }) + imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("boom")}) + var gatedNames []string + gate := func(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { + gatedNames = append(gatedNames, name) + return f() + } + + res, err := newResolver(ds, ag, ffm, gate).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al5"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.extError).To(BeTrue()) + Expect(gatedNames).To(Equal([]string{"failAgent"})) + }) + }) + + Describe("artist", func() { + It("resolves the uploaded image before any priority chain lookup", func() { + tmpDir := GinkgoT().TempDir() + conf.Server.DataFolder = conf.NewDir(tmpDir) + Expect(os.MkdirAll(filepath.Join(tmpDir, "artwork", "artist"), 0755)).To(Succeed()) + imgPath := filepath.Join(tmpDir, "artwork", "artist", "ar1_test.jpg") + Expect(os.WriteFile(imgPath, []byte("uploaded artist image"), 0600)).To(Succeed()) + + artistRepo := tests.CreateMockArtistRepo() + artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist", UploadedImage: "ar1_test.jpg"}}) + ds.MockedArtist = artistRepo + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar1"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).ToNot(BeNil()) + defer res.reader.Close() + Expect(res.source).To(Equal("upload")) + Expect(res.sourcePath).To(Equal(imgPath)) + }) + + It("falls through to the ArtistArtPriority chain when there is no upload", func() { + conf.Server.ArtistArtPriority = "album/artist.*" + folderRepo.result = []model.Folder{{ + LibraryPath: testFileLibPath(repoRoot), + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"artist.png"}, + }} + artistRepo := tests.CreateMockArtistRepo() + artistRepo.SetData(model.Artists{{ID: "ar2", Name: "Artist"}}) + ds.MockedArtist = artistRepo + ds.MockedAlbum = tests.CreateMockAlbumRepo() + ds.MockedAlbum.(*tests.MockAlbumRepo).All = model.Albums{ + {ID: "al9", Name: "Album", LibraryID: 0, FolderIDs: []string{"f1"}}, + } + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar2"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).ToNot(BeNil()) + defer res.reader.Close() + Expect(res.source).To(Equal("folder")) + Expect(filepath.ToSlash(res.sourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/artist.png")) + }) + + It("sets extError when the external source errors without being not-found", func() { + conf.Server.ArtistArtPriority = "external" + artistRepo := tests.CreateMockArtistRepo() + artistRepo.SetData(model.Artists{{ID: "ar3", Name: "Artist"}}) + ds.MockedArtist = artistRepo + imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar3"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).To(BeNil()) + Expect(res.extError).To(BeTrue()) + }) + + It("does not set extError when the external source reports not-found", func() { + conf.Server.ArtistArtPriority = "external" + artistRepo := tests.CreateMockArtistRepo() + artistRepo.SetData(model.Artists{{ID: "ar4", Name: "Artist"}}) + ds.MockedArtist = artistRepo + // no image agents enabled -> the external step is a definitive not-found + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar4"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).To(BeNil()) + Expect(res.extError).To(BeFalse()) + }) + + It("routes the external step through the injected gate, keyed by agent name", func() { + conf.Server.ArtistArtPriority = "external" + artistRepo := tests.CreateMockArtistRepo() + artistRepo.SetData(model.Artists{{ID: "ar5", Name: "Artist"}}) + ds.MockedArtist = artistRepo + imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("boom")}) + var gatedNames []string + gate := func(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { + gatedNames = append(gatedNames, name) + return f() + } + + res, err := newResolver(ds, ag, ffm, gate).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar5"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.extError).To(BeTrue()) + Expect(gatedNames).To(Equal([]string{"failAgent"})) + }) + }) + + Describe("radio", func() { + It("yields an empty resolution when there is no uploaded image", func() { + tmpDir := GinkgoT().TempDir() + conf.Server.DataFolder = conf.NewDir(tmpDir) + + radioRepo := tests.CreateMockedRadioRepo() + radioRepo.Data = map[string]*model.Radio{"ra1": {ID: "ra1", Name: "Radio"}} + ds.MockedRadio = radioRepo + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra1"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res).To(Equal(resolution{})) + }) + + It("resolves the uploaded image when set", func() { + tmpDir := GinkgoT().TempDir() + conf.Server.DataFolder = conf.NewDir(tmpDir) + Expect(os.MkdirAll(filepath.Join(tmpDir, "artwork", "radio"), 0755)).To(Succeed()) + imgPath := filepath.Join(tmpDir, "artwork", "radio", "ra2_test.jpg") + Expect(os.WriteFile(imgPath, []byte("uploaded radio image"), 0600)).To(Succeed()) + + radioRepo := tests.CreateMockedRadioRepo() + radioRepo.Data = map[string]*model.Radio{"ra2": {ID: "ra2", Name: "Radio", UploadedImage: "ra2_test.jpg"}} + ds.MockedRadio = radioRepo + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra2"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).ToNot(BeNil()) + defer res.reader.Close() + Expect(res.source).To(Equal("upload")) + Expect(res.sourcePath).To(Equal(imgPath)) + }) + }) + + Describe("playlist", func() { + BeforeEach(func() { + conf.Server.CoverArtPriority = "cover.jpg" + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"cover.jpg"}, + }} + ds.MockedAlbum = tests.CreateMockAlbumRepo() + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "t1", Name: "T1", FolderIDs: []string{"f1"}}, + {ID: "t2", Name: "T2", FolderIDs: []string{"f1"}}, + {ID: "t3", Name: "T3", FolderIDs: []string{"f1"}}, + {ID: "t4", Name: "T4", FolderIDs: []string{"f1"}}, + }) + }) + + DescribeTable("yields a generated grid from up to 4 album tiles", + func(albumIDs []string, expectedSize int) { + plRepo := tests.CreateMockPlaylistRepo() + plRepo.SetData(model.Playlists{{ID: "pl1", Name: "Playlist"}}) + plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: albumIDs} + ds.MockedPlaylist = plRepo + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl1"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).ToNot(BeNil()) + defer res.reader.Close() + Expect(res.source).To(Equal("generated")) + + img, format, err := image.Decode(res.reader) + Expect(err).ToNot(HaveOccurred()) + Expect(format).To(Equal("png")) + Expect(img.Bounds().Dx()).To(Equal(expectedSize)) + Expect(img.Bounds().Dy()).To(Equal(expectedSize)) + }, + // tileSize-1: the 4-tile canvas is built as [0, tileSize-1]. + Entry("1 album -> single tile", []string{"t1"}, tileSize/2), + Entry("2 albums -> duplicated to 4 tiles", []string{"t1", "t2"}, tileSize-1), + Entry("3 albums -> duplicated to 4 tiles", []string{"t1", "t2", "t3"}, tileSize-1), + Entry("4 albums -> full grid", []string{"t1", "t2", "t3", "t4"}, tileSize-1), + ) + + It("resolves the uploaded image before the generated grid", func() { + tmpDir := GinkgoT().TempDir() + conf.Server.DataFolder = conf.NewDir(tmpDir) + Expect(os.MkdirAll(filepath.Join(tmpDir, "artwork", "playlist"), 0755)).To(Succeed()) + imgPath := filepath.Join(tmpDir, "artwork", "playlist", "plu_test.jpg") + Expect(os.WriteFile(imgPath, []byte("uploaded playlist image"), 0600)).To(Succeed()) + + plRepo := tests.CreateMockPlaylistRepo() + plRepo.SetData(model.Playlists{{ID: "plu", Name: "Playlist", UploadedImage: "plu_test.jpg"}}) + plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1", "t2"}} + ds.MockedPlaylist = plRepo + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plu"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).ToNot(BeNil()) + defer res.reader.Close() + Expect(res.source).To(Equal("upload")) + Expect(res.sourcePath).To(Equal(imgPath)) + }) + + It("resolves a sidecar image next to the playlist file before the grid", func() { + plDir := GinkgoT().TempDir() + Expect(os.WriteFile(filepath.Join(plDir, "list.m3u"), []byte("#EXTM3U"), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(plDir, "list.jpg"), []byte("sidecar image"), 0600)).To(Succeed()) + + plRepo := tests.CreateMockPlaylistRepo() + plRepo.SetData(model.Playlists{{ID: "pls", Name: "Playlist", Path: filepath.Join(plDir, "list.m3u")}}) + plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1", "t2"}} + ds.MockedPlaylist = plRepo + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pls"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).ToNot(BeNil()) + defer res.reader.Close() + Expect(res.source).To(Equal("folder")) + Expect(filepath.ToSlash(res.sourcePath)).To(HaveSuffix("list.jpg")) + }) + + It("serves a local ExternalImageURL as a file-backed reference (staleness-checked)", func() { + dir := GinkgoT().TempDir() + imgPath := filepath.Join(dir, "cover.png") + Expect(os.WriteFile(imgPath, []byte("local external image"), 0600)).To(Succeed()) + + plRepo := tests.CreateMockPlaylistRepo() + plRepo.SetData(model.Playlists{{ID: "pll", Name: "Playlist", ExternalImageURL: imgPath}}) + plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}} + ds.MockedPlaylist = plRepo + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pll"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).ToNot(BeNil()) + defer res.reader.Close() + Expect(res.source).To(Equal("folder")) + Expect(res.sourcePath).To(Equal(imgPath)) + Expect(res.refMtime).To(BeNumerically(">", 0)) + }) + + It("routes ExternalImageURL through extGate and sets extError on transient failure", func() { + conf.Server.EnableM3UExternalAlbumArt = true + folderRepo.result = nil // no grid tiles, so the external failure is what surfaces + + plRepo := tests.CreateMockPlaylistRepo() + plRepo.SetData(model.Playlists{{ID: "ple", Name: "Playlist", ExternalImageURL: "http://example.com/cover.jpg"}}) + plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}} + ds.MockedPlaylist = plRepo + + var gatedNames []string + gate := func(name string, _ func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { + gatedNames = append(gatedNames, name) + return nil, "", errors.New("network down") + } + + res, err := newResolver(ds, ag, ffm, gate).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "ple"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).To(BeNil()) + Expect(res.extError).To(BeTrue()) + Expect(gatedNames).To(Equal([]string{"m3u"}), "the playlist URL fetch is gated under \"m3u\"") + }) + + It("treats a missing local ExternalImageURL as a definitive miss, not extError", func() { + folderRepo.result = nil // no grid tiles, so the local-file miss is what surfaces + + plRepo := tests.CreateMockPlaylistRepo() + plRepo.SetData(model.Playlists{{ID: "plm", Name: "Playlist", ExternalImageURL: "/nonexistent/path/cover.jpg"}}) + plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}} + ds.MockedPlaylist = plRepo + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plm"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).To(BeNil()) + Expect(res.extError).To(BeFalse()) + }) + + It("treats an ExternalImageURL 404 as a definitive miss and falls through to the grid", func() { + conf.Server.EnableM3UExternalAlbumArt = true + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + plRepo := tests.CreateMockPlaylistRepo() + plRepo.SetData(model.Playlists{{ID: "pl404", Name: "Playlist", ExternalImageURL: srv.URL}}) + plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}} + ds.MockedPlaylist = plRepo + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl404"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).ToNot(BeNil()) + defer res.reader.Close() + Expect(res.source).To(Equal("generated")) + Expect(res.extError).To(BeFalse()) + }) + + // A local resolver holds no agents: reaching the external branch would panic, not degrade. + It("skips the external step instead of dereferencing absent agents", func() { + conf.Server.CoverArtPriority = "external" + ds.MockedAlbum = tests.CreateMockAlbumRepo() + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alx", Name: "Album"}}) + + res, err := newLocalResolver(ds, ffm).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alx"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).To(BeNil()) + Expect(res.extError).To(BeFalse(), "a skipped step is not a failed one") + }) + + // The worker resolving the same playlist is asserted alongside, so this cannot pass vacuously. + It("resolves a playlist locally without fetching remotely or building the grid", func() { + conf.Server.EnableM3UExternalAlbumArt = true + var hits atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits.Add(1) + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + plRepo := tests.CreateMockPlaylistRepo() + plRepo.SetData(model.Playlists{{ID: "pllocal", Name: "Playlist", ExternalImageURL: srv.URL}}) + plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}} + ds.MockedPlaylist = plRepo + item := model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pllocal"} + + res, err := newLocalResolver(ds, ffm).resolve(ctx, item) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).To(BeNil(), "no local source, and the grid is worker-only") + Expect(hits.Load()).To(BeZero(), "a request must never reach the network") + + worker, err := newResolver(ds, ag, ffm, nil).resolve(ctx, item) + Expect(err).ToNot(HaveOccurred()) + Expect(worker.reader).ToNot(BeNil()) + defer worker.reader.Close() + Expect(worker.source).To(Equal("generated"), "the worker does build the grid") + Expect(hits.Load()).To(Equal(int32(1)), "and the worker does fetch") + }) + + It("treats an ExternalImageURL 500 as a transient failure and sets extError", func() { + conf.Server.EnableM3UExternalAlbumArt = true + folderRepo.result = nil // no grid tiles, so the external failure is what surfaces + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + plRepo := tests.CreateMockPlaylistRepo() + plRepo.SetData(model.Playlists{{ID: "pl500", Name: "Playlist", ExternalImageURL: srv.URL}}) + plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}} + ds.MockedPlaylist = plRepo + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl500"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).To(BeNil()) + Expect(res.extError).To(BeTrue()) + }) + + It("yields an empty resolution when no album has art", func() { + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "empty1", Name: "Empty"}, + }) + plRepo := tests.CreateMockPlaylistRepo() + plRepo.SetData(model.Playlists{{ID: "pl2", Name: "Playlist"}}) + plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"empty1"}} + ds.MockedPlaylist = plRepo + folderRepo.result = nil + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl2"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).To(BeNil()) + Expect(res.source).To(BeEmpty()) + }) + + It("skips a grid tile whose declared dimensions are a decompression bomb", func() { + libRoot := GinkgoT().TempDir() + Expect(os.MkdirAll(filepath.Join(libRoot, "bomb"), 0755)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(libRoot, "bomb", "cover.jpg"), pngHeaderWithDims(50000, 50000), 0600)).To(Succeed()) + libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(libRoot)}}) + folderRepo.result = []model.Folder{{Path: "bomb", ImageFiles: []string{"cover.jpg"}}} + + plRepo := tests.CreateMockPlaylistRepo() + plRepo.SetData(model.Playlists{{ID: "plbomb", Name: "Playlist"}}) + plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}} + ds.MockedPlaylist = plRepo + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plbomb"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.reader).To(BeNil()) + Expect(res.source).To(BeEmpty()) + }) + + It("does not resolve as absent when every sampled album fails to resolve", func() { + // The album ids are absent from MockAlbumRepo, so every tile fails non-externally. + plRepo := tests.CreateMockPlaylistRepo() + plRepo.SetData(model.Playlists{{ID: "pl3", Name: "Playlist"}}) + plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"missing1", "missing2"}} + ds.MockedPlaylist = plRepo + + res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl3"}) + Expect(err).To(HaveOccurred()) + Expect(res).To(Equal(resolution{})) + }) + }) +}) + +// decodeTile runs before the processor's own guards, so it must enforce the caps itself. +var _ = Describe("decodeTile", func() { + It("rejects a decompression bomb before the full decode", func() { + data := pngHeaderWithDims(50000, 50000) // 2.5 gigapixels, far above the cap + _, err := decodeTile(io.NopCloser(bytes.NewReader(data))) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("dimensions")) + }) + + It("rejects a tile larger than the size cap", func() { + data := bytes.Repeat([]byte{0}, maxImageBytes+1) + _, err := decodeTile(io.NopCloser(bytes.NewReader(data))) + Expect(err).To(HaveOccurred()) + }) +}) diff --git a/core/artwork/sources.go b/core/artwork/sources.go index 04a9257fb..885ca03cf 100644 --- a/core/artwork/sources.go +++ b/core/artwork/sources.go @@ -3,6 +3,7 @@ package artwork import ( "bytes" "context" + "errors" "fmt" "io" "io/fs" @@ -16,14 +17,16 @@ import ( "time" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/resources" "go.senan.xyz/taglib" ) +// errSourceUnreadable marks a candidate the resolver knows exists but could not read. Failing +// to open it is not evidence the entity has no artwork, so callers must not settle on absent. +var errSourceUnreadable = errors.New("artwork source unreadable") + func selectImageReader(ctx context.Context, artID model.ArtworkID, extractFuncs ...sourceFunc) (io.ReadCloser, string, error) { for _, f := range extractFuncs { if ctx.Err() != nil { @@ -32,11 +35,11 @@ func selectImageReader(ctx context.Context, artID model.ArtworkID, extractFuncs start := time.Now() r, path, err := f() if r != nil { - msg := fmt.Sprintf("Found %s artwork", artID.Kind) + msg := fmt.Sprintf("Artwork: Found %s artwork", artID.Kind) log.Debug(ctx, msg, "artID", artID, "path", path, "source", f, "elapsed", time.Since(start)) return r, path, nil } - log.Trace(ctx, "Failed trying to extract artwork", "artID", artID, "source", f, "elapsed", time.Since(start), err) + log.Trace(ctx, "Artwork: Failed trying to extract artwork", "artID", artID, "source", f, "elapsed", time.Since(start), err) } return nil, "", fmt.Errorf("could not get `%s` cover art for %s: %w", artID.Kind, artID, ErrUnavailable) } @@ -55,11 +58,12 @@ func (f sourceFunc) String() string { func fromExternalFile(ctx context.Context, libFS fs.FS, files []string, pattern string) sourceFunc { return func() (io.ReadCloser, string, error) { + var openErr error for _, file := range files { _, name := filepath.Split(file) match, err := filepath.Match(pattern, strings.ToLower(name)) if err != nil { - log.Warn(ctx, "Error matching cover art file to pattern", "pattern", pattern, "file", file) + log.Warn(ctx, "Artwork: Error matching cover art file to pattern", "pattern", pattern, "file", file) continue } if !match { @@ -67,11 +71,15 @@ func fromExternalFile(ctx context.Context, libFS fs.FS, files []string, pattern } f, err := libFS.Open(file) if err != nil { - log.Warn(ctx, "Could not open cover art file", "file", file, err) + log.Warn(ctx, "Artwork: Could not open cover art file", "file", file, err) + openErr = fmt.Errorf("%w: %s: %w", errSourceUnreadable, file, err) continue } return f, file, nil } + if openErr != nil { + return nil, "", openErr + } return nil, "", fmt.Errorf("pattern '%s' not matched by files %v", pattern, files) } } @@ -90,7 +98,7 @@ func fromTag(ctx context.Context, libFS fs.FS, relPath string) sourceFunc { } f, err := libFS.Open(relPath) if err != nil { - return nil, "", err + return nil, "", fmt.Errorf("%w: %s: %w", errSourceUnreadable, relPath, err) } rs, ok := f.(io.ReadSeeker) if !ok { @@ -103,7 +111,7 @@ func fromTag(ctx context.Context, libFS fs.FS, relPath string) sourceFunc { ) if err != nil { f.Close() - return nil, "", err + return nil, "", fmt.Errorf("%w: %s: %w", errSourceUnreadable, relPath, err) } // Close in LIFO order: tf first (it holds rs internally), then f. defer f.Close() @@ -127,12 +135,12 @@ func findBestImageIndex(ctx context.Context, images []taglib.ImageDesc, path str for _, regex := range picTypeRegexes { for i, img := range images { if regex.MatchString(img.Type) { - log.Trace(ctx, "Found embedded image", "type", img.Type, "path", path) + log.Trace(ctx, "Artwork: Found embedded image", "type", img.Type, "path", path) return i } } } - log.Trace(ctx, "Could not find a front image. Getting the first one", "type", images[0].Type, "path", path) + log.Trace(ctx, "Artwork: Could not find a front image. Getting the first one", "type", images[0].Type, "path", path) return 0 } @@ -171,44 +179,6 @@ type readCloser struct { io.Closer } -func fromAlbum(ctx context.Context, a *artwork, id model.ArtworkID) sourceFunc { - return func() (io.ReadCloser, string, error) { - r, _, err := a.Get(ctx, id, 0, false) - if err != nil { - return nil, "", err - } - return r, id.String(), nil - } -} - -func fromAlbumPlaceholder() sourceFunc { - return func() (io.ReadCloser, string, error) { - r, _ := resources.FS().Open(consts.PlaceholderAlbumArt) - return r, consts.PlaceholderAlbumArt, nil - } -} -func fromArtistExternalSource(ctx context.Context, ar model.Artist, provider external.Provider) sourceFunc { - return func() (io.ReadCloser, string, error) { - imageUrl, err := provider.ArtistImage(ctx, ar.ID) - if err != nil { - return nil, "", err - } - - return fromURL(ctx, imageUrl) - } -} - -func fromAlbumExternalSource(ctx context.Context, al model.Album, provider external.Provider) sourceFunc { - return func() (io.ReadCloser, string, error) { - imageUrl, err := provider.AlbumImage(ctx, al.ID) - if err != nil { - return nil, "", err - } - - return fromURL(ctx, imageUrl) - } -} - func fromURL(ctx context.Context, imageUrl *url.URL) (io.ReadCloser, string, error) { hc := http.Client{Timeout: 5 * time.Second} req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageUrl.String(), nil) @@ -217,6 +187,12 @@ func fromURL(ctx context.Context, imageUrl *url.URL) (io.ReadCloser, string, err if err != nil { return nil, "", err } + // An agent-advertised URL that 404s is a definitive miss, not a fault: settle absent + // instead of retrying forever and tripping the artwork breaker. + if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone { + resp.Body.Close() + return nil, "", model.ErrNotFound + } if resp.StatusCode != http.StatusOK { resp.Body.Close() return nil, "", fmt.Errorf("error retrieving artwork from %s: %s", imageUrl, resp.Status) diff --git a/core/artwork/thumbhash/testdata/alpha.png b/core/artwork/thumbhash/testdata/alpha.png new file mode 100644 index 0000000000000000000000000000000000000000..6ac85943a9041b9592a0550e7ef5be8f34683785 GIT binary patch literal 24912 zcmV(^K-IsAP)X$NJvO%qJ{PzXfGrX8VC)Rkk}wL7~{Q1Y$uNGBu*PQ z)7fd-IL&n0G--Cy^m+T8e&;^Ff8aVd001~}5D*p~9tJo7I1C5_M2AHmOgIn|brf(o zJUuM_VDf>O=!~%FnEZorF(rVc_yRzDd=(%uruty&k&5v2n3k~QgeE}Fk>>E6B=CX! zq|SrcNk~9Z1`3duhB=s*)e}~niUSm9kPepTG69vDeSp$@&Vjmo0iY&N5?-ID05s(* z0S$RtKod{}Xf81xY%MkcpcTUaXr(o*y~=U0quL8V0w)2`%CWHSvMB(fZZ535VgZ1v z4uxTxR>HA0TVce8;|H-#dk1OFdttq8rvZ$nvjB3_wJ=KC4FC;tD~#EC6TpYuIlzM4 z2@}9x07wvj07yDs1jt}70s7%DhpFI?4obV;JfKFr572bK4^W~%JgCC{E!;@>Jj{as z8eqbH127T3KWM`K1Ta&60@$cO14anHgbkAbVPOXjh93$)a4-UJC=L*PAPx`}mKYup zlNufsnR+NHE+Zm3I_+>=R6Zaft|%ftrtHws_{u}cG1U>r;;JLEj@E?dB-aD-5?aD@ zkHRAI(-45-V~9h!>FC2n8Aw2R8X8cTfeSCmAsi@8ryVRSU;}D083!wJ1!0Z3vhdm> z>4ByKDWIi5bpTXm0JN0o4z^TUA|Pe9aCn6c0510c;5F_rWX0qGc-{B`RNYhrQxY%N~FPITb-}Js-h>oq}&Z!9eBV<|0EBxXKjSQEEK|s~9>AFLNI3EO$hp%6(zz`iU@j^;mRQ z)b_9E%f(~&grg+ol(rGu=_i_wDi zr@}a$HxG+pw+>1=o;fT=z8EG(JUlEz{2@$&c^D-{y%MIxz8l%!{cgAd`{zh4`lCZy z;wJ}<eo@jllpiA;eg8h}qyhl32M!;MJRA`o zb1>#mM09LybWCJoTvSwi+>s+GF-H=zqK?Mo#v~lci%m%^j!jG{I+UDJ9Fdw9u$BpR605t*L}iz)cu1Jlv5mHDJdU>+`}Jc|%np4%5$SICNK z$P+}=0|kc~$|Vuah00hEP#p=WQpdEH7!QL>P0=0I12NEY$Dz(DM=YY+5sRqw#3E}Z zOMDRu|t>eTYO6#d87UX<1t>Z!j2XgKZ z19s)G2!11ykGvTzMBk2;c0C{0kGU5mL%a~vk9v5xzvp3u7XM157WXJxhkG-^M0_Vw zOMV=oC43xb!F?8Kp?no*C46ycnEd3BgZypeFzwr@;l5vE?d+c;U5x)bFVOQ3h0isZr1 zB(OU#C2+c)ie$sCMu{-D6NJb+38J27V#GbqCG?{o#Hlf_#H+g>#;b9!98r=UMX7Kf zMC$N=PSBD58b3h&Fy2V`IC6;mS?mz=??)`GCvi6R_mMWvPth*!_c0FUkMVBK|3$l) zfCEPk#U4(Ki48vzoe*~tr~6tAA9Xceh`b&r>ADxA#5@m5P6Y1TU0qXnlX6k!K%=Ev;4^uvhv9dlnYGXf1uyVeM8|nKt-og4g!PWO&qKETa zoKN^mf|myXq(;U7l46e>PL7Fo0_v9(qd-&(Cqf!3)LJL(Lnuv$|*yvCT) z)o4vdfb7Q*AZI+f(RmEpHj#*F^C#k)11V(iY$6pjcZ>>MNMOK1@yzy(6h`OnF$R1q zfd$)7Vj)f(wnd;&EE7`~S?${{n zhoe5>Z^@JF|E73306eB z&L~JvO#!CnW)>yqr5C3ZWLBl+X4Rw>XEdi57PMs)6oHb8^C79gd{}Z-0W_nwu;*A+ zAv&?X5SLb4flI0d(vljhsA=`3oHTGPH>0hRmkOy6CbiedQlSm<6h-&-Cb&8As&tpE`KaY*F z|CQLLCq<;hr$(nFCdH>ECnaVaJ(iN1ek?URBPBB} zJv%!qH77SMFRLUgGow5+GovIkKc_q!m|LHnpIet*kk_1CTnI_8$cJRs6hM+Ii%}`n z#i)$hQe;LWkeFUyK}>G}_NKN~v9eoh`%;?fILV+YVHT)Sl+{rq&VtpcQ#79+5 zWLUj91=BQ;-qmQ$z&1NGdfFV>JuM^Y1h6}q02@!Hw);~Eh?!)1*Gwk0V=kKoUr*-3 zc2b#`?MxPOFN4=}GL?fqnJqz{&k*4+r_1qIvlXPwx;(^s*3nRwubV@ zbUo?iG%fvMrh)Qu@<87k$yU}|sl&9tWDhal%e1l|rwy?mXOFTz%^GDrNgEMk4_T9<|D*>*zh_U&0O7fD$HFs@rbgr>AB#&*$vBdmmL8jzoD!dtmY$N4 zlA4m4nw6cIk&~O2l~bIZlTnnFpIwz!kYAaXpIeh%SkjPNT+oa$Fv$nvw>tW@NQhF*93gIa%#h>|9u#B&!o7%IvI@r@@-F znO$w#tR9d#2Mrm_#zAZusE*-u62z0&19jz5I>)og2wxr<5y)e}=5i?Tg^WJLLM|P# zp2hCk&SUlLWeK{rvba5`viP{u=~B$;94Y2ndOzVxma6Agu8Q<@hLZ41x{`1|Pfd6} zPeXhmP2cxowu$jZx|#7-o`v~#riuP2$I5swbCCZy*UI@c&&K{D+rj@N%`JYCHzN2l zdrbVVtZ~7&Ig^4P(*pAU559TMQh36hU7Lj)>H6b@WH7+YDJux>q zGc7A4J2^i)D<`KQGc!M@pg1!xuROarAD9aSmSq*_Do#6JsS%hE2MOeW)cwNS&Xh=R$upgE(5b%(APDW z%kEmqYYpj z<$i&N^)T1a_e$;{{pGwt?xS1__nk}&`<;S8&O7nP;=j9X^<)prRa7 zP*d4i)L0G!Hq~MB8!OTIjn$-lP$e$ErGZcgX`uo;YB{+u5EBS%5dxuY(gGMnS=g>qu&M^dY>*b((jk( z=r88$seddq(O=9ra^EPia$YMO=Dk^L6TF>o=l{8Al=DHpo%eCkDDU%pAOEvLm-tJd zPy8f*T=9KzK=nh>l;Y>Yfa)h;K>kZfKmiCZPD(ypa4ajLBt18-C^aLhI3q8nI4v`^ zAfq6uFe^8^Br7+!xHvDrAg`pTq@bj%v=mrUP+U@7SXEwMUR_dOSXb7gmVLp7yYU%|-Tl#>I7DiDgm|Fw`2@4Rd0wAnK0qg?F z3Q!%oG91`YjO{cR^}wy=JrGL)vE5ccMY)TK2v<>Wr>}&Jnl7bbr^;EV=^_>`Sj@#O z0oj;P8LuY<|ymw0MqIXK2{C9w(yvIO~;Bo1w==0)H$yWtF>1PGw z{ZC3K)!!FPNq;B{D8DZcsD3R7>i=KqoB{wSOG!OYnUr}Dn3;FDI4$Q$WmZl+Fe5Li zG%Gi$BC8;|ys#vv6j+p9QB(-51eTYUlvh-gl~+`kmz7mil~&c3Radu_)YXE34OQ)x zHFaoDyP>-r?Z^2fz*7cTlG?FWzO*9~+rLO`8=2Su3`BmMmvN9xCS%&RY z7Na1Vaso_Mj)fUZ2pxu!UZ}m2(mhl~>~dA~b&r;ldpsq5UE^h3RG^a8<1gi*m&&;K z)lx2T87L)f6pKm6D`lje3K8W*xq@=OLPk7WuAp58D(P2%2G+GQ6XSZ7k$bn;z`av$ zWZtc^GG8hk5IzK2MQ>CLb6%~qbKWi+VI76?f;^3O!^gY zTKV^qNyWb^CpG^np3(nUHLd%#d`AC!@w^srpe!RVq9!xvU|D8vR83}KOl4Y0OnE_Z zLRn^UdTmibc2!eptQ6yx3Z+Pq`bJgva+j%H3ZvV~KL?%m37j|ktZa?Af(4NIls#)W|tCkG_Ky7+fSY>KKRBdKSTyOq`IQ6vc9sqy0)UCwyLhOv8J)EwV}BM zT+`IjS=-zUuWDM$ zH8%0x3OnbyszK??bx!_E)i&`P)lTtS)eiXw)jrkxbsoh>bw1T!tHxBH){ggoUOBD# zqGnS4P0gg?>-wPK$J*e)FIBVVUuu`lzt%1r0pWGYC5LL#@}p{V3nLnH%A)FWfk*4} ziW2K`OVevg%Q6~DfVs656`6G>#68}qUeUa@|c$V z%43a%RVnoa<>^gDRawm?<@wF!b@>gI)ddYzRlxeXn)2rQmYUXvmX_wariPa07D#g& zxUCV~2yJL>Ml`j7G0l+HZV9b98 z+FY}O8EWgN?KGK6w7*|cc+p&@AgreSIDhlU07PoTBI-; zk%Yc-W=e7gu3PGiH1+HldcXZ<05C~E;s*?^uLn$rD4lWqq$p+!!JP-jT0ufN+b`nnA zM8PRQB)qYu7pHCS!x-CH_(2GRINZj=yC6KO3nCe`sXnj)?sI9dT(%jb4-qH>Nw?g3{ zC>ROpY=^_TI&h#4C=rV6Ac49%NF6;0Ca4=tgW}O#7!fOK!=gnU1dI$sM)kue`2JQh zK@DS(^sUq$1FVlY1ncW{v@>Z=5R2}Ga>-+Dd}^Rw)He;0(&pMkee>;N&O*DIvjUZ| zHX!}1omRDA52oYowd=%ZK}P;rh*5AEX5d}#un4Y!%))12L*m=5BmK|7hUND`Bbw*h zU7A0^T-rZ@y~@`?qsliyUc(3ADeVXC6UINc`86LyCk>xLX3d{KW(;4pEgQaV4-J0b z8nS&0U3L7>8nXWzvg-VA+s-fm0LcP|LGpkRFko#oB)>8aQC@STqqy!UtfDTZy{tN` zqq;d0URR$BtF9|RRJWEx8(K=C&EVP&P;*^pV_OTP71D|T!$9Cx2pHPl0qcZ9pzR1K z8rFfpAkbZec0?Dc1KUOJ?CGXKa9wmL2`zw=umTvCAb^n23IrY75AE&MbkOi7D2rl( z_mKuV`>4b1Y`PO6q}e+JG!IL+U2aJ4r$*CT*F%HQ1VtgG`tO% zj&~BGyXI*n4 zvKmy}S=(3wZvdBdf<+H((~=37A288fp@p zfelE`BTb?!2&?=u)GEE&HQav-?o`}II+b_29Et~BBl-tjVX<^L0mnd6dw6gPR*WLy$iAplDaTO(RQ=)~(7qc{ZD3-{0_{vKvXu#YxLPW6}+=a9qF zYdsFxMYuzC9X{ND1L;!Tf_qf=yT`QmFka(J7_a)p?g`_om?`7yn2CXRy8Y(2yQa;5 zM$Fp&+BIkW7%@Bi7_%_^C3?mB5BR#{EBLDIYt)AGNA#xa$L=ln@5o)p@2DLQAiO)T zCIVGdAC4@mi@=sQ$D^uS;?Px~qnJuiI;OfU9aj&|LN>G&V(QxqyPI1}kZs@+B&59* z(+;k~LtwS&&UO&09npsBM1nEU&dzQ$5{~Y|_Mouc-54AS-;KibVn{d|u9v{VV0&p; z5|M+X_Hw&PWFDSOl=ZNwGGrfBfnm|~cs5mwV^ar_eD*L-$gpE&Y!_0@cA}Mnahyaj zj+P21QT^hXZVhJ^spKtU)WVH!gLo5f5*$bAl_&6ns(svm@&w8uzlgT?pF`P{7cmay zRs5*oI(kHN59u=9!;NX~_Dt%Z!;c&PgqpPcsb_NF^`1%NYu%IPw=oOmw{gM2KjY`D zA0wBBA0t?v7LAX7EVGTyYUzl8jUBC&{#By*xO6T5__pQDxQg^;Q1s9iA(Gw z@vwBd0?nZ-u{@TB$fT-?LZ%TTWSOxdhMB-)+t4Dx2v)}O^+-6AL>146Q}8Eo3TY6p z7tZ$Rr3)mzC`2+#wr~c;CfXq1!P?ZP2^RTjyi<3MIHI~j8dhIKyL3;X-I{0c?ty19 zV}@t(Uej}gG1CLWl>Q-h%K8RoV(1lO!2Tv-#_<6rXnB{kIQS86$@VF6$@v*()$un% z$n$sHit}si>gYd+tE2xS9v}IEurvBIdS~=^^u8Mq)(dP3CzRD6qEs}*5i1(w$mO6! zY;Ef?Ty<+AxdxO?ZfH%%H^Z`VjnI4?xC@ADfftk7pyj=tT{XR6L=zs0XeFait%PnI zgwTy{CnIp3#2!pHiGWA;;t3cmnLwtHuw)#Kz`zp8Gy;jrCsRqB-adv9%caSQbhezx zXKE-Mu9n1OYjOO(0RoRZh!YBIL1>sc3?rl1`FTd_PGg2;xosb2zPhl`x=O z#|`vv;0M&36svXzXV&dwhg9dW4&^z#-FP1FQ9Xrqny%u!12=nJre}J6gD>DG&Ce11 z)|bc=125zKw%72pmNy7N$6NRr+xysg`+L2!u8)XI_78DOqo0x1ykFwhoL~2DxV|E< z`Tj{+AN!fKGxi;2%l$iP$NO*WaUTFcD{c-a03ioy<;{nw2xNUL1$1|OfH?<$D=X(6eJ!^L*;WcBq7U4=L-fX ze72F?&vwvc++lh@-$s*5ePpF%f~XYws0y*4q!!Q9jN&D#PPRZbYBot0)dt0;+w8Td zb}0_S3DSu61j(+uNVe%O&|TW=1ef6^&1<K}Ob_4howU9{nY2G3Ogmqv z&Dmb32d(dt=Ung67j5s7794+}EP4J)*cknUwCeeszBBqIb#45c-fi~}^iB8Aq&@#n zq|;+RQg{8oQ}(9-09JW(1i893npqExVpKtn^fkeb&}-Tgsi5}szD8&^9fZs!gJ5}7 za91${3@@d_y2_~#bSVviuB3Kg>S-{16AeWGk$Z5hbSwcv>Bb@$L|iuuPsUL3Bn*W_ zCep}#y)0S#XhroiEP#`(afq1sztL+acK7$Bidb-)p(xe zFrOhi2d1!=a+Bp9e3mg~y~CQaJ)rxYFZ2b59x$heU!w%AZ_$JHH~T`4 zH|Y!Z_vy>-Kld$-eM}FHf5F`HenHvwea6`KKA~)l{m^&P_cQ(E*e}%m$=~`;PXEF> z9|Qn6RUiPpwl$hj1Bv8R!;i3Q;jye*XfnILJ&6nMOlP)s=k$TQ^EqwELOKjp-Ur53 z&=AP#K16o|qqDn_jli|g5%_i%7T>|fkzi~L70ST%!f7M|icO&rX+$!PL7|XneSHiL zoz};oa|ESFa2(^_m&FS+l@4YZnk zG_UnG!!vl3J?XgBH!*mZHRX7o8?ZiL%nrZ8Sa3aL&N<$ot=Qk;tT-RhS4ZBaue#pr z+wlI4v+Dhnwl(pDwmJEA-|@gd*;|3{n5V}7MLQn+xo>~+=e{%k|D&Cr2LMPBc41052x0{ThOgov zaLxRl-WEQlx0Q*Zw(@bbb}q5ElR=`vIRplVL!^CXd4t zGud1rM<5ilgd90n%2#uRLL*Zl&@rUqA+B67#MVgc483HOtCD&cI`tUepq=KKbpe4z zJIy!ggUn&w5@W~^Vp)uvESF)A@6_+I9OhFT$KXk>cjzqNGkj4nX}iwxS#Ar)hwreb zY_}K__Pg9U`wN`F@I%3({S|i5{u*<^`4(?^>~&UX^j*P{>qGvs_dUUy_ha_j;_mgyAGbf zZ$utpH^Gy6?LA4NwyqQ|tSdtV?#UB(;)=w`o-z&;TgpWcs(A=(0~& z5ti1A(zb-3B&>(S1b@p zR3a%~Cr}8D0)=RRt&m&9a`_NnCmCTYl zyS!P)i{csIL+-5e74fR?74d@mUGawdJ^qIK17T?DF?%iW5&L-HF>k~F8Fy#q3+}0@ zulOgX{vp~6{7ZOZ{s+OCz`up(mwpvoSp)#YP4Gj!2E;*e3p#?|&>bsiL?-dUUCEO6 z?lft8PbLrAlO~4s6GoNWWsq{=C?m140`ShXFT_L3(g0UW$*KXSk~Tc&FyR6`!8@ zm-OuHZ?bdCzlbg`{#Sfu5pY0M*LgtEf{d0nz~iJ~WQ@GEJ6_R@Oq7GVGvsZ=ba^{5 zLjlF-D`DhfNjI)Q3MZE;P~-|Rl29o@(QA|ha;vz90+NwgFd3~6(NCel`$;T}gvslc zF&P8}Lx7hu_(Ua#&+O-m=nB4+BbNv{N|8(`7R#jysazwID0Q-aiBYVSnw4tJpiHH+ zDO75wVnAtEXjNk(gJw)Ns2P)4&9l;>ff@0zVOC}{Ey@3f=Nt%0efD*U)3PfW9XcWR z*v^W5&eQU#kt;I4?V32?xFQYOZ_B5Bx0JKqXO&B14@3*Tmt;%6KS`H8e-f=szAg#* z-w|*6-%_kk{YA1p@xFX(_AiqCz~_?vz!S;I+0PXFi~p3IU;06LarP(K<>eoxmqP!M zU0eE9d370ZP~CtAs9R76uTdGLD$Rggr!i=Bsv)`A;EqjSkX~_~3p9AEIV(P&&4-OWh|%YUw8 zAFI#If37((_lb6Y;dAAwm2Xw&*S^zTT=-dgY2^p~^_71sFRlNd;`$Z6oRDcmk~59nv?48*R;z0)zeq2Y ziHr)RSg+Em3_6wCq*Lp)My*+=AF!(R2CLd)u&RbEBN~&*qjn5q8)C;~_ z>ZQPQ>XpEYn&s&iw4sTYOdElh)oX#bG@F6fbX&6@==SG6FzzqCZ`@!0SaoXUBi)JB zPxNQjzA&9x{JZMP#y_-IR=-nS{{Q<`b7lA6rrT=(fUyMw(6!^E^iXoF1xAT8fboe& zC?!#c>`gMj$jQ1MMy3``&o|&1#U?bTz<{HdnThOLJ&9khCkyHZD4Yf@Lj*BXIS>v&0sa#3}%~0XSceI!#1DEZl5&Uhh_&{&L!=bYr!~bThqBc zA^oIh-8khuVVQQG(oT(?GfsIg>-^r!mVoDme#v{=81g-3UY)pSSQvlKvN`jDWo`Ne z%lgcpv>OW#Ejx3s=(Yln%=-)Pn)m15F`Nl~G_be)q3O)R-?SH3zS5sw{k#7B=97Ud zo8RltZ~th#xbvg&^3IR?D_j4yJiP+|n8En)fmUpU5kieLz^Rdgozw&)l#*nC(^8B` zR;m%(mp9nW&L6}u3ygS9v6;XuH{v+81}d|`Kom4u`?#$ZGN;+dkhGfEGQ|dDvjKjtttY9_z4k)Z`fSo9!c$MwdM>jcZM6g zKbWr{|H*i5?`Pw+t^ZhV?Ewx9LZ|>6loVlw5~9q|-q@i|>d`?YEy0SSBs-Aw>|s1R z+kvGQ+VR|?;U0F;FqT_6L=sh*>D($incHG!@tdr4Uh^PJ*kR%JgDpG-d{Eerv52I2 zn^;9MOVv1=L_@VpwN!^f#j+~&oWXvra9BMcuqh03tI;C28co_kqeX2VG8=8iA+vdC z*kK9Hd|Q^_ z*eOeJ`t0!h^rhjYvCG!b_+|Uzkr|PUca2S6{NNhk4A2-75juJg<9*v@VK*>Ps$L)V$r$0HZEKO4HR`Gxb!iLaekx4&`S*!jtMef#H; z+b4gvK6~c(p?hZlfRRp0*a(aj?dYJ!IJ#NUPBb^sfo7+AdblYgC}xU{$WM0=xtUHp z7wDu&fF822)J_#tyP2X|8&%riW-37rrWkBvOF$z$Ri}rqMc5>Av`wh)u?dveQL&of zkediDshZ|d>e#k^3*Vt16nfManZr0FcbSI!?PjaaHDFP>hlU26mJyT7X|sA9_7R)Q zJ?b8D`s|}4qjsNn-0ks9+dZzJ&F5bB_&iH)zi-*$_pdvGfxVHL@g4iz#GZ3$;;egd z@|=5h_Pl*<{^ICr;I?NiaMQLmd(XYPa(85B`Tof9xreU(wTGj}*Iybv6?%PifBo%| z(`#>!oZoq8^z_>Mj*FWgJFo11>N>ym#B*`?3&+jlUwf{f{MLQr)Q`67$A7gwb>dgg z{d2#K-a82h_rchQU0w7DAB-M5+Qo@=W7%;oB>SklTae_za8gEkM7g6xakiT%%pIl6 z3f&|TaI8;K;iE|#yj*Fshp7bl*!`_eo~*+uRCl@s`Yx|b2OsIzq1_TS(bKQNxn*X` zs9ei(>J4y61y?qf5c#?)m8x-r3nRzLntF zv6bm-?v3EZ(XHUMv9-k;uJwic-mRtk&h5}W&(_+*(NoJWc}{J;>OHmnrt?(j4d2@ZIkBXShUh#bcWVn@-ugbB1baSYGN_7X%Hlf9BcH(8YLp$f}K zDatAjOHn(?kk^j0RSh1#w%)^6f_-dF>o`x}Il)tROiBz$w^)lBmki+Daszf!X&{Vg zEmV(ki0jdrS)(Q!*F7*OnbZ%9JeFbE#Gq3@HZ-bo504lpY|f!^hr>KE;;@dn-L|n& zk9X4J@{Es-`6s44!SN~Y+~m~slGi^z?+pevJ+o75<1;hIC+7UeeT&m)##d+0co*g` zj;<|V7+;^cKDIG`ZFDDeV`6vtwr6+o{`k)F{qa*Pe;hx)_0Y4w{p#eo^_M43ZoNK! zcJtlIE1U28uI&HCdu9J`-~f>^Bp)&wuB>d-k`nXD|FT z`t0dnC!W6m08AkJ4g?U~@Bo$*H;oe3(&`xY&T6lABOdY8`%3GejEi zx6*yeA=a4M&JGx?+^GS(c*0=iO`As~lNP6XY;agHWwmSlLtfLAbHp@mcMtmAt`VPi z)H&@L^ZF;oe7;FxAi!uZL}S0+vze|6&A#v|XE z&DR1KHy`<~?7Tm9dH1ir%R8S;T-pC(>el`f|1+naj6HSYiT}>|Z>R5^`QCT;(ywE8 z&i?4Xd-?xOzIYXIaJq|gXrfDaXu5|RJJBONGDVao1hM>MejGnNNRy{d(WJSv3}tqJ zDg(~Yl*Ivt7C6z@UpC3@ub5$}Yo-Ls`k+ADG%eFYX5<5~S&11wB_G60sZ6LT#QI}NPo@w`#d)5<}^iNJr&x}t`O-=*@GeQ4CaDHmVKObBPF3*Pii-GOgrTLTomANz1 z8w;oXE321hw-+u3KTke? z>BpJpulzLqhwHxvUbp}NOm}m_{YY-~0-hV??-9h#;AQavk}!3ySDZXgm81lzl8kw( zykLQ*ED3V@3l^B_%4w#u#?R4J2Ka{N1>r#3EMM0i>^HZ~$V?q`(gB2DX+#B8W^6!j z$IR=7djlF9by_#lH$Px!`SmuzjCEAFFyIi)*xeGp#oa&SaH^(fTfg8OvrN0a zgEQ{wp#|TBXWADW3yx3v0^_q&vy)R3fysb>ZpJ?wTv}KP&Mt=L=jTJyi?he)LW|q} zjnMvVXytTZWBv5Z?)s(a^_A-(QB+&KH`?2S{O1nyq^$K0I@PXc!@d>g!b z?We%A7rvi+@$#>;FJAn=>F2Hj0JGgZz;d_X@N$nhc7`O1Tf{07mhq~@AWfRSNR_26 z^eHmu`&3!8Y<2z|TT?W{)ECdN^<{G$W8ETG-?S*zwap7mparp|Z9zH!TaZ~1Gb$@) zNpHq1Xl*?U8ar{(G}60l86huQM%YVM8+Xy{6$gi$vIV=Rf5GNbE;vSY3!@X7CFi7J z#xrgS`ligm@iEuDf5si0oE!^I1w8Y!^W(wknc2CynYp?7x#ij5?Be{~!b)&Hw6PRg z+*w!&9iLlXJ-xKKdS-rW<-+{&l?zKdTbGx%H*e1DY+Ro?xp8Z0f8+k#sr~1c&h0)w zb9wiV%a`}xn7MNN&G~D4kCvV~_h|9*>A%e1KKb7KQ>Q;!e(LhyW}dqARq)Q`FN4os z`secVSN=Kw;PUqi4=(<^_=g++nR|Ho*V&hD001j^$$=$|@X%_nG-d&-h+874j;xaV z6Bg*QlsQ&^>IzezxlA+UtS}8liyU>?0^3x+%-2;fN=&sYB6IVyY^ZrfVgfBIY_NIR zFlu7&y*xkS2 z@+#&WUhR@=$~ZSMF|;%~>6rJ-I9H}-ouR3qZ+T{REHpjsUk=Pogl3n6OUny0D@!ZO zi}TAXON$Gk`PIdZ`HhY3)t$97q3xCZ)!nU2q0OCh^V@3|=XSQQtn6*yoI7>=nYq(@ z&(2*ub#Lk1?sKbWPCQt?a^mIHD<>X>uAX=+bnV=m^G{uPf93Y2j~1W0@M-9&v!Bf0 zy82}P_Vp+8_bz@Nx_9-T%MUJnyL$if&r5&2{o~>bSASc6@j4)U9j83FgzrDN+N+G% z=v7Cp(G*A4$?{|CR88s%TbsGg)ub=6ba^WbL+&QuSh&G8ly3-(HR}RHWk@pE7?PMl zYtljRs%ofpMd^Sos~ymF^)TvxEt?$Z70W0kWN?#L1|9Sj(`etad7Qg6?B*}ICS)6q zG3k=cD_eE>HJiS1Rmc~#Y>Wl0q47CO$Ui%@F*WDeoSpHkPS5$4=a#281Ixjcg{6hi z?85xU($ezU;_^l)v>w_Bt*ozagmyO8*N(4jub*7mUOTgSa{I#i-qyv9Gbe7WojGx5 z_0;~o(5X{*H!dB2uzGR-g_ZOBe+pec|8V`r`PVnEoqZ#8(5^K z%gWQ&KUshJ;^(3JPkp)c?4>W4p1c0f<>zjHyZ+$jcdIYm{AuOIXMSD#;|%~{l`H{l zlT~3`Bz5ctQ5hNP(q>Lox@5R{OF7iO-ai7`QCXpz1{Y#OVP zZ*ABk+;L9wR-HcCs(W0q<@IaUypx7)Z_p4L3tDz2<_1^C796XyGwzT-=nTy+dp8yq zrnVMC(`)l<)0>Nn^Xm(1%c0QP=EmB_=Fa;1&gSm!-s<+o$QfNZUj1-s5IB zVb3u}+jNf6_C~y{ZO52s%jFjyADNbK`}~Sc@0@1GGo#=21_n0#i^k*A^XB6d^UhuW zvTN7BG`2CjK4nAy3e*GZwpPi zC;9(hhjif|cVRdTI0H6{jlMt`w!tV2##(f&*jVXsDQr+^fjZUHB2D9-G*sTxm5j6%*FbD;VxDG3t3Wg09jOb0Cm0Y6MRv_SLho}-{2}*zlT>ge~+tcJ%XtP zoxs%hoW#_29!J%6okq8IU%)hgE+TJsm%!V>rPvNo8M0%b91rTRz;q2Z;d_Sa@SwpK zbpJ>jZg8**35E9HpwI#Q7;Fd!MGvA7$WhGL7!r*@;?Z~v28Tisu|y0LLm{%!R3aBk z#t2bVoCr&%O7S$3498%ouzZ>h&Zp_&EQS>+;JDEOjuR!Kd$2-g2qED_F)~pMsS+eG za&8u{6i%T5@jOl=Uq+hcd4xf_fwRfiP)7AS*7E-Wi+_tcboEa3k5}%)A1S?$_|w&g zNr#IbB_1n#6nnJ%3H0fz$I!;fovI#gx|l8(&)Y0j{X+Gi+JI=Y-0RFNif={~=U$eM6|dc?eh0^%JQM zd=y>Nae~y;cLv?kdmhsUK8tSYzl?3|yGH8jFCl>kD)7C~YE;)q4XPVjM+A>FqlZUY zNlZ8?cJAsW)&Ipx`&uLq!Fo@2=lL`svzzl*2c6;*OQv zPx!fL7x`$#BjnSieObQy*1teHQ@00qzIG4xO3hy4)#g{R7i#z6 zidx?>xR@42BD3(;n6xObhHUSFnSX= z1aBk5$GXYGm;o#dF+f0J2Z5N@g*Y zbS6v2;P4d`G2ckxa&>e$&qS5-Y($aB##9JgM1U8h0)h|;kj1GQX^de~WoUX}l5Upf zh&K5=!=%iUE$TeQsb8ae)wh{;Z9xI`(DmO^kCfcOJX&=R>&NOHl%th9DL+*|%062C zDEmb96V%fU&(O})Jwv@v{T$F9VtE%H8b`AJTPIb>gN0js4Lop#+BH;2>m4a$^bTF8f)SOJepC&!f258&469|0!5b+1I8bPQpXO(c!cF?1}8Lc>w5c#VFw^DRbk=RcXH9sB9mdp_Wm z^?b;yX#a#!)$s|x2J{uZ4)g`R5qyZ*3^~kd10P{F_a3LW44mS%_aCQs^`B*f2hXv( zq1QM)@FL#8$W;ymTFf3oU*`>BDw+L=dhQUqg$+fu@W&8s3>dM4g&_8_;g}vahTKQT zQ6U^GVVF*$jj{L&RPeVS!bXLvTv9LK3&p!*EV zY`boY;nLk^2h9ZqoTFuTa*vkXDLPv5JMPc54{}b`JjDB@?m^zUs$Igf&3_b}Z+?t( zzV1okxw=0KuQWZ&z1aA?ps4Xh*0r`hyprzM1m&%7v#)o)%`FGN_);Ma8T7nS#Z zBB<&6h|>uAj9mvlC~6q|FTY{hRoFdR zEa(|4<@6!T1bwhFK4h$t14TEnNAR^mB&vmj!ncaXupJy2wugrzL)aMV5RXWLa4}4% zfIx%sXe<<)O2hD2Oe~khW$>wVDv!?O@>l{Pm&fLdI6^Up&y$KoA_ZF^Q?r$FEk`P} z3RDuiNF{ZPG=Ptz)A$8CMNp(wMg@9ZOkmfi*mixEYgf;5UFrpv*R;yD>sPr!(;6@6 zxW)P(An$lx0r%(19lX;O5AaSk+%G;|^N{dV-6O&?HGklpX?;p?uK989rPilJ7h0ba z7dGw|mfU<^bfsetulVN6;v1mXB}F}N3(9-m=2iB*Ehz)NFR1G}AgTd>#IJ*V#ccwA zDQbcIM|^YWptNE5J8ARq_u@|I&%E}LJccXhLXlPCVQixShHDlhu$??Cv0IF$^l&lsK?#X5#G_D$Bv>j;LSmyuR62^! zViH6gK3T}%Qp9`?mCqINq&zWS!WZ%cVu_R|kV?5CnT{({YQ$=lk*`o1`6{)G2dJG= zjlv_s4hXXrJtQ-&>L!{qITZ1`dd8!25;OJ)g+x`@T{%K)#VSLcf-^ z_kSmDAN*0;1v?~c8$BTcjT{q!M^DN6;1?ti*m=nS;<9vj?2>!{S0aI;i=;!uYRM43 zUJl3ADiD-L2@2b$MAO>EXkw2T$>@~h=@2=V2^ACRgK{DpsU)#R<#Yy4PT^n`G!{X` z|EV_%G0g86qlPGQJ!mjRDQMNPtu~+-O4LBcT0*uFG)(lF9Jm!ugXfh z_NdA_-&E9ey{;%9*e9#&eMee7@Sds>@}at=?^9(hrsN8Hx z0#3^$;4n=B9^4(!M>)QqW)36eiu8wJorNz)k2kx)%5$ZP&;VpbPe+rUP?C*NZ)> z>&BeW_MtDRhLIOFgTxD(VO+5aM!ceglgkw_Vhu1ttyaS64LTI94#1Ebn3BrIC>dO|nj^vL_!63qC#5OGQkFs{XDh@Kfle+L z%H?V~pphx$a+ON0(i;FZU{|sn@e{5(&er@a;`%-li@tvw2@vRPwK4j>^olx~7kEy`;6F@)V7xe)8 zEC3;028PHbDj4}XFiI>|qv+*&IHS%u#%$2x*)3``r$I$vc56v=u#UN98n+15RgD=eA9%eU?ds%aYZEZL@l>XHM;RFBt;f z6;&*_4McsnwBw$F0?qlR-{{Xa-K#&_a&srW)uw+NQzxE%mT}>uw@G zGPJ=z)^;O4HFaSAV*!o**VutQr0+xisDWUQ>%r)g8VLTlz7O|{VU%#rGK{-mhEuK> z5X3SIoKa#NqgELZ)M_1`QfI<38Z9Vpn}xt=H{n=d9f{d#VX}M7OwNFw!5cJk1aKoy ziZ%*m2%|uOH%UZPvp`8U$Q3k$T+Oh^Ra}h(;OnG1kqJ-&7L`G6R_S#nt=vWNt+klEMz>|$=(b069!tvX_r&!+PfqJ~W;ElD1%1@DVhDOS3=z+!Ar;y* zM8gGlT2Hm#Y5b+-cjn8jJB)=l?{{44c))(G>mk#%j)$$qollvs_dj910eaR_(f^#G zyzejO>wSBTRggW_O32Ih`r&=Hdg$xsmf?4-4bbBIeO>BpS34UmP~th$80oxuly=nuXB63xj1n`F zaovt&SDTUCIs=+fZzQqW93*as1262hkU8BBDhFhzN`{S8@vws2%al~R5};WWTAoF!6PV;mp-HP#+5x@Vsx_*NIWGr4CCQ-Tg1C&nFwr{5|M%e=XucG z*2`V@y3cptowAKlPuWIj7i^=H3zji9AocPO+h z3!r6~)kdCGq2t+fYN1=Jk=e8cl~t$LSglrr*=#XeY-WeU?r@teR*%)@4!gbXh|}eX z+Pt2mE#OI7{h=9iAdq#2!%OzW_>wslT6Dw$Th>%~!e3$z&=pNtI&ima( zpdFrK@PoGN13P`CphxUCAb+&q7G;H&GwBJFx$@fw3QXyK!H6K&XT6e$scgKHT@dA?%O7e)5m5 zLHbEMjP#3rgmKXUrJZq&aSFYooDw&TSLVab_sxY$h9b^P)D_kRTLLs;CSVFj!UiO+YCmY zPiGOi^m@RpGplW8o6c!5X*^D|-DkDfy*8`M;dKSvZdb_Z^M%}AU)&e)#@vx`+BF`@ z*~Z7Su4rV&J`r7UC&J5~WO&t;iLKjG69onC3thkUU+ulebqV}{?;7~`fnv~ofl}~; zt}@7DfufnYSDBRRE0r!renY=!4J?`vxgLc!rrrgTwSw&Ozoe4}x~q zJIcD?ALU$jA{m#QI7UeT$*XV?_~l*_uf|W|*9WM=1~*OA;-|^m{A^)okfZAIbHqIX zzI@0lki%SD&9IBF91ZXlIHz1o@T!cYph8FUt4u79+QJIx3?iS-B=Q^05~tazb{nl4 zx6uYTjaG}-<}tW!cB|L!cey-HugmH8xPt+|JLrjeeg27{Kag?<0~uE&Htmna7hSRO zRd0GC@0*Nldyu|IigN1ut*A)fP8kG|xuLA)4jM!XSd7~2EHYNNIwLitizrW=HcKF{gemE{3Q&d{}O<4FGo>c+V15_+I=iZw@;`5d-EN#M3iwlzGU$`Bdf1cEO2qE* zM%c5V#<9JDX3Q&*R?HjWX5{OEHtbve7UbLhHr)P5H~GEsF470VPV%P#F!9saAmxj2 zKl2O!AoDx_AoECgi2Z|qi1kwp!9Ed%vd@MwytCs7R^d2WbTLTaUI}1@*Mm4wQ3xlh z2@nOfL87cN!2I7npf$*rb%uG0&T*y^9OP>uVTp3c&(n;?u1e_jI#NjbV{62fg9dZYKK~KyR z@PCub(TIF;q8YU} z)<}3I(MotF-hzKU0>ZvC(T#a8(t+Eb=qG+029rLFKxm(Z2dSThp_FgJ!}M>GL)`BY zaPFZfoOwKf5d0KFb54g*+zSz$s4$M^Uzxy*E``bBvIJFlJx-LD$EcE;ai+8>$yT)_ zX|l!%mb_)0uWe8ARNWDd7Mu_p`@>RTEGbvR!V1%9LaoP-OUdYIJi3U>V~+UT)|l6CjR*X;iEuC&9uJ1c$K$bhWFnb} zMB>q8GL?*tXJZqSbK|M_e0(Y~pU5UwW7E^?V>I#%u5q(gcqh-Q7^}u(Qm|W68;|V zCcTwxBfK*KBD^)xOFIw+(LYM`(LPBHvp-8h*k2|FIN!#g+(VNv`gd_C@6hCk@aP1R zeKG~-pN_%>r=uwTnK)K_HH8xvPm(1iDVnS(L6lcUnexgw6{w9-wXI1O&@#nTwkKK2 z_N2rBP6z-{N~#%5O7(+ri5`|zTVM&94IR^3PzkLS8#B6y39XYgVezmhEIw}B>J`Ur z0a486R7E|0b=>9GCG37v%>#ewi$xX`Yyo@QUpFL%F@KbgVf70fc$1QFkMCpLhVXbA|9TqLOeQM zgLph!k9#Uzk9#50M0hFNirJfPp}siTO8nbYH~F<(JN5N6nEYnCm-)}!Ap3({FZaW2 zANSLlA;uRogWNCEP~q1p1nb-B5y6oxn)5>nEaOOf0?{qlnNhPMd*?1zHOU1`C>FmTzYC1ig%4BDza@m=g?EFkRvy{oruFcHm zwp00;wV9<{LBVVh?Cx~Q*gfeg#C_9MsQYFs&^vRLxSiAWgokJAiI2`Vlb)V##ypj2 z!#y+8OnElbN!>f!P5Ik&C-dd$Uh-?XZstE`K&*dE53v531=HTm4)FI+L%9E*A7g)# z9pZeN8y0+#MR2~JLrcHQV)#GIBPBoP(9)ARg5rPk&SpvSi*sbvr7RIB%rNC;vs9ow z$JAEN@zoWxEMr5Cr)!wyYTGkXU1v^g>z+|s`(|X;foX+hFsn2}r{xydoX!QyYV6po z(TSZmIw-R?KV!}kpv{3je z3+`Gjh2K3}4!>u<7I*J_Ir729dc@Af8uYG(TEecm7SdDmE%-k!x8R@3wcvNpv@)K{ zwUJ&}0+C*vZD;J6?dH5W*F)Pk2jacGFu>WL>u2s?f^rTlKm;Ey3~@eNM)JO1fQ!Ff z9OZvI1D772!6|>7C5V5X$IDL4V`Rsc3DPr5RN19PhVtSZNmaPS)|V~O^rZ`Q&GluT zwtksou3r-9o0r7KmK@*Ix+phx%!n=hb8-u0QRNy~lG}$CG|tg^y&E>C_TZN^LBgWm zOnOtrmw=h35mz!J27RRtEX+&5g1@ULN6pzA_^DVrfkF%^X_%Zrfn;!8Q;Q0 zI?Uf z7p&G`?pdnG-@j5t+_~0*`~6x2{-MR2^j*ts#K)G~n15Ptr#-uNll=5j5A`pLVCr92 zx|uKMLEODdVAiXPVAku4{erhw`q}TTj!E8M8RdVt3>SU8G%ERM8K(Gj9WMQ95urM` zj#3}VY{Po|oF%R#djmRiy`%*Eo8Y^a02^;2B=lgvM4hA;;u3rnqO^b<)X(csjSxrv*))HxFKAZC| zPEH3Fv-63i+)`>Ow~$(!U&^k}FJ%_WTMm)Zurm)DrK{HrYG*yM-p+Vzy_NIib{pd_t3CAR);n1*twK02 zZGi`l_ z!j8=b;*Rxt((l(A8TW6sP@w#_h}zRfZf-lppc*M;`tTWn|XZJy)CHs4mhDfLuuh}`wJ z<+l1Yz|pj=^0cpO0zI1=SKn=Qa9~aC9o{empc|G5bj==sZJI-fTh0h-%Mz#Fa>j@o z-YDnSK#X}im=xR!C%7AtgnWBEqrMf(YS$t;)p{al-b_syw^M2N)?~)JF*)bjn4FFL zI=kS%on8uW&MZbZ=T_5exz+5}((3f)+WOqq#^%E9&8@XxS61`e>l@p*H@5x{se}NN TQ)lJ100000NkvXXu0mjf_`5Pu literal 0 HcmV?d00001 diff --git a/core/artwork/thumbhash/testdata/gen_fixtures.mjs b/core/artwork/thumbhash/testdata/gen_fixtures.mjs new file mode 100644 index 000000000..b2e488b51 --- /dev/null +++ b/core/artwork/thumbhash/testdata/gen_fixtures.mjs @@ -0,0 +1,90 @@ +// Generates lossless PNG fixtures <=100px. Run: node gen_fixtures.mjs +import { writeFileSync } from 'fs' +import { deflateSync } from 'zlib' + +const crcTable = Array.from({ length: 256 }, (_, n) => { + let c = n + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1 + return c >>> 0 +}) +const crc32 = (buf) => { + let c = 0xffffffff + for (const b of buf) c = crcTable[(c ^ b) & 0xff] ^ (c >>> 8) + return (c ^ 0xffffffff) >>> 0 +} +const chunk = (type, data) => { + const len = Buffer.alloc(4) + len.writeUInt32BE(data.length) + const body = Buffer.concat([Buffer.from(type, 'ascii'), data]) + const crc = Buffer.alloc(4) + crc.writeUInt32BE(crc32(body)) + return Buffer.concat([len, body, crc]) +} +const png = (w, h, rgba) => { + const ihdr = Buffer.alloc(13) + ihdr.writeUInt32BE(w, 0) + ihdr.writeUInt32BE(h, 4) + ihdr[8] = 8 // bit depth + ihdr[9] = 6 // truecolor + alpha + const raw = Buffer.alloc(h * (w * 4 + 1)) + for (let y = 0; y < h; y++) { + raw[y * (w * 4 + 1)] = 0 // filter: none + for (let x = 0; x < w * 4; x++) raw[y * (w * 4 + 1) + 1 + x] = rgba[y * w * 4 + x] + } + return Buffer.concat([ + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), + chunk('IHDR', ihdr), + chunk('IDAT', deflateSync(raw)), + chunk('IEND', Buffer.alloc(0)), + ]) +} + +// A ramp has exactly-zero DCT coefficients, and a zero coefficient's nibble is decided by float +// rounding noise, so no two summation orders agree on it. Dither gives every coefficient signal. +let seed = 0 +const dither = () => { + seed ^= seed << 13 + seed ^= seed >>> 17 + seed ^= seed << 5 + seed >>>= 0 + return (seed % 9) - 4 +} +const shade = (v) => Math.max(0, Math.min(255, v + dither())) + +const make = (w, h, fn) => { + seed = 0x9e3779b9 + const rgba = new Uint8Array(w * h * 4) + for (let y = 0; y < h; y++) + for (let x = 0; x < w; x++) fn(rgba, (y * w + x) * 4, x, y, w, h) + return png(w, h, rgba) +} + +const gradient = (rgba, i, x, y, w, h) => { + rgba[i] = shade(Math.floor((255 * x) / w)) + rgba[i + 1] = shade(Math.floor((255 * y) / h)) + rgba[i + 2] = shade(Math.floor((255 * (x + y)) / (w + h))) + rgba[i + 3] = 255 +} +const solid = (rgba, i) => { + rgba[i] = 60 + rgba[i + 1] = 120 + rgba[i + 2] = 180 + rgba[i + 3] = 255 +} +// RGB must vary with position too: a constant color composited over its own average cancels to a +// flat L/P/Q (the bug this fixture exists to catch), so pair a color gradient with the alpha ramp. +const alphaRamp = (rgba, i, x, y, w, h) => { + rgba[i] = shade(Math.floor((255 * x) / w)) + rgba[i + 1] = shade(Math.floor((255 * y) / h)) + rgba[i + 2] = shade(Math.floor((255 * (x + y)) / (w + h))) + rgba[i + 3] = shade(Math.floor((255 * (x + 2 * y)) / (w + 2 * h))) +} + +const out = new URL('.', import.meta.url).pathname +writeFileSync(out + 'square.png', make(100, 100, gradient)) +writeFileSync(out + 'landscape.png', make(100, 60, gradient)) +writeFileSync(out + 'portrait.png', make(60, 100, gradient)) +writeFileSync(out + 'alpha.png', make(80, 80, alphaRamp)) +writeFileSync(out + 'solid.png', make(64, 64, solid)) +writeFileSync(out + 'tiny.png', make(1, 1, solid)) +console.log('fixtures written') diff --git a/core/artwork/thumbhash/testdata/gen_generated.mjs b/core/artwork/thumbhash/testdata/gen_generated.mjs new file mode 100644 index 000000000..e8eae585e --- /dev/null +++ b/core/artwork/thumbhash/testdata/gen_generated.mjs @@ -0,0 +1,28 @@ +// Produces generated.json: ThumbHashes of synthetic images, from the vendored reference. +// The pixels are a pure function of their index, so Go rebuilds them byte-for-byte from the same +// formula and only the hashes need committing. Run: node gen_generated.mjs +import { writeFileSync } from 'fs' +import { rgbaToThumbHash } from './thumbhash.js' + +const COUNT = 300 + +// mix is a stateless 32-bit finaliser; Go's mix in thumbhash_test.go must match it exactly. +const mix = (n) => { + n = Math.imul(n ^ (n >>> 16), 2246822507) >>> 0 + n = Math.imul(n ^ (n >>> 13), 3266489909) >>> 0 + return (n ^ (n >>> 16)) >>> 0 +} + +const out = [] +for (let i = 0; i < COUNT; i++) { + const w = 1 + (mix(i * 3 + 1) % 100) + const h = 1 + (mix(i * 3 + 2) % 100) + const rgba = new Uint8Array(w * h * 4) + for (let k = 0; k < rgba.length; k++) rgba[k] = mix(i * 1000003 + k) & 255 + // Random alpha is opaque essentially never, so half are forced opaque to exercise the 7x7 + // no-alpha layout as often as the 5x5-plus-alpha one. + if (i % 2 === 0) for (let k = 3; k < rgba.length; k += 4) rgba[k] = 255 + out.push({ w, h, hash: Buffer.from(rgbaToThumbHash(w, h, rgba)).toString('base64') }) +} +writeFileSync(new URL('.', import.meta.url).pathname + 'generated.json', JSON.stringify(out) + '\n') +console.log(`${out.length} vectors; first ${out[0].w}x${out[0].h} ${out[0].hash}`) diff --git a/core/artwork/thumbhash/testdata/gen_golden.mjs b/core/artwork/thumbhash/testdata/gen_golden.mjs new file mode 100644 index 000000000..2825e0709 --- /dev/null +++ b/core/artwork/thumbhash/testdata/gen_golden.mjs @@ -0,0 +1,37 @@ +// Produces golden.json from the vendored reference. Run: node gen_golden.mjs +import { readFileSync, writeFileSync, readdirSync } from 'fs' +import { inflateSync } from 'zlib' +import { rgbaToThumbHash } from './thumbhash.js' + +// Minimal reader for the exact PNGs gen_fixtures.mjs writes: 8-bit RGBA, filter 0, single IDAT. +const readPNG = (buf) => { + let w = 0, h = 0 + const idat = [] + for (let off = 8; off < buf.length; ) { + const len = buf.readUInt32BE(off) + const type = buf.toString('ascii', off + 4, off + 8) + const data = buf.subarray(off + 8, off + 8 + len) + if (type === 'IHDR') { + w = data.readUInt32BE(0) + h = data.readUInt32BE(4) + if (data[8] !== 8 || data[9] !== 6) throw new Error('expected 8-bit RGBA') + } else if (type === 'IDAT') idat.push(data) + off += 12 + len + } + const raw = inflateSync(Buffer.concat(idat)) + const rgba = new Uint8Array(w * h * 4) + for (let y = 0; y < h; y++) { + if (raw[y * (w * 4 + 1)] !== 0) throw new Error('expected filter 0') + for (let x = 0; x < w * 4; x++) rgba[y * w * 4 + x] = raw[y * (w * 4 + 1) + 1 + x] + } + return { w, h, rgba } +} + +const dir = new URL('.', import.meta.url).pathname +const golden = {} +for (const name of readdirSync(dir).filter((f) => f.endsWith('.png')).sort()) { + const { w, h, rgba } = readPNG(readFileSync(dir + name)) + golden[name] = Buffer.from(rgbaToThumbHash(w, h, rgba)).toString('base64') +} +writeFileSync(dir + 'golden.json', JSON.stringify(golden, null, 2) + '\n') +console.log(golden) diff --git a/core/artwork/thumbhash/testdata/generated.json b/core/artwork/thumbhash/testdata/generated.json new file mode 100644 index 000000000..1b49bbb03 --- /dev/null +++ b/core/artwork/thumbhash/testdata/generated.json @@ -0,0 +1 @@ +[{"w":28,"h":79,"hash":"4AcCCgBFgGVnc6OLh2u/b7Y="},{"w":94,"h":86,"hash":"4PeBBYAIBZlXeaVKmwl6Zx+i9EeEUqdlGA=="},{"w":61,"h":88,"hash":"3/cBBQCxf6l3ioV1WadpmG+B7Pm4"},{"w":29,"h":41,"hash":"H/iBDAIHX5Chbnyqawdo8otij7N3a0qJBA=="},{"w":63,"h":74,"hash":"3wcCBgB0x884dZeDiopMhclYDUpUAm4="},{"w":29,"h":25,"hash":"HwiCBIIHF79zi6+Gg2fAhk/4fEtnapaYDQ=="},{"w":57,"h":98,"hash":"H/gBBABIejmXynwHHoVAnvdXSw=="},{"w":71,"h":69,"hash":"IPiBBYAHmD2KUW/lQQyLaLCDh0KklFn32g=="},{"w":71,"h":50,"hash":"3/cBBYBohqhLaJpwZoZUd011gBCJ"},{"w":89,"h":83,"hash":"4PeBBYAHkBp+Q/bXx0CSiZ+sT4Z4fIyYZg=="},{"w":92,"h":57,"hash":"IPgBBICgJdaPlJqBnUfQcGbwdA=="},{"w":39,"h":95,"hash":"3/eBAgAIymeYlAuWDQ9RUidjZat7jxk="},{"w":100,"h":82,"hash":"4AcCBoDiuKh2N+TrR6fh9nFm+5lp8Kc="},{"w":92,"h":93,"hash":"HwiCBQAIuI2JaEYDVRA6gU/q5PxIULPapw=="},{"w":67,"h":90,"hash":"IPgBBQC1bGaGk47Hl3+nddp6vwuY"},{"w":78,"h":71,"hash":"HwiCBYAHmZZIYn9JhXCLjZaQSWV2ZUGrfw=="},{"w":85,"h":42,"hash":"4AcCA4KeKZCAlcZzkAz7i6k="},{"w":8,"h":50,"hash":"3/eBCQAH+IVwSkj7IiZPL/h0dYpCSEg="},{"w":13,"h":78,"hash":"HwgCCQJGjqtkmUowdno/3fY="},{"w":66,"h":95,"hash":"HwiCAwAHCjuAqh2Fn/H9K5loYGiYmJU="},{"w":2,"h":84,"hash":"nvcFEQJ7c7iHmKwJj5iHCGA="},{"w":37,"h":32,"hash":"IAiCBIIIldBHeXKZ6f3EA2DV0K2GZkvDBA=="},{"w":41,"h":25,"hash":"3wcCDII7Jst5c0WFhga8cEnTBg=="},{"w":57,"h":26,"hash":"IPiBAoAIuvRneZfgjPSmAhZrl9W8SPU="},{"w":31,"h":40,"hash":"IPgBDQK6o7REKkCxJ1cXRlNqsK+B"},{"w":7,"h":69,"hash":"3veBCQIHdoKrFgYOxqU5cAVYqapmgjQ="},{"w":82,"h":59,"hash":"3/cBBYD4M6tIe6iJenZX0jv3i6QF"},{"w":85,"h":48,"hash":"IAiCA4AHyKR2lgvPG0TFBnXQWGkydzw="},{"w":53,"h":67,"hash":"HwgCBgKbVol5V393ersDOea29bSlZPk="},{"w":79,"h":26,"hash":"IAiCAoAI6QZtSGnvJmnwcJiHZquG+Hc="},{"w":14,"h":28,"hash":"3/cBDAJIhLeYi1+pu1owyIVvMg=="},{"w":85,"h":78,"hash":"4AeCBYAHXPhldaZ2ibVPVQmKh/iJlZQrig=="},{"w":26,"h":67,"hash":"H/gBCwI4T6OnR3GPhq81kGo="},{"w":51,"h":13,"hash":"IPiBCYII24eGC4VNfYAJR/S6V8WLeyg="},{"w":57,"h":81,"hash":"4AcCBQCoojSIY0qYe3f3UQGuawz+"},{"w":8,"h":72,"hash":"4PeBCQIHSUVYVn8ANrQoB3UWWYiXWbA="},{"w":89,"h":40,"hash":"IAgCA4LDe0AG5hb2aM8bcEM="},{"w":55,"h":94,"hash":"3/eBAwAHpIVmT7VvjwahW2NPhkiaDok="},{"w":59,"h":81,"hash":"H/gBDQBERoZIo4nKhqgKaJYvSkVA"},{"w":41,"h":45,"hash":"3/eBBQAHdeyYm3UGRR99adcGQ0RwRZpGmA=="},{"w":11,"h":28,"hash":"HxgCCwLHZnhGlGVggoAJ6GU="},{"w":8,"h":36,"hash":"4PeBCQIIbWHpldBF78rO/EKZaaiAqHo="},{"w":40,"h":100,"hash":"IAgCAwAIT2uoiESGs1BDrxw="},{"w":63,"h":33,"hash":"3weCA4IHmrEpmk9KVK92+LOxI+sLqfs="},{"w":72,"h":63,"hash":"HwgCBoCYlQZkaHeXaeabRJit12VwH7Y="},{"w":29,"h":90,"hash":"3/eBAgAIzqlo+KRtoAl1qh2KO7ZwWWo="},{"w":81,"h":26,"hash":"IPgBAoJVdauz6giWSnhgCYk="},{"w":92,"h":76,"hash":"IPiBBIAHrET8bFvpCZQdZQubVvO0vEV5DQ=="},{"w":85,"h":99,"hash":"3wcCBgB4L9nVxUhpWUyqb76n4LjPOfw="},{"w":79,"h":72,"hash":"4AeCBYAHWneox5/01wN7aHnvSAZscNB3qA=="},{"w":76,"h":18,"hash":"4PcBCoL6mZdmelSFz5UU9EQ="},{"w":78,"h":90,"hash":"4PeBBAAIDnhKoKRQpgBqMJy+iar8L4pZDg=="},{"w":72,"h":13,"hash":"3+cBCYKsdkJZyx6/lICO+8I="},{"w":78,"h":19,"hash":"H/iBAYAImX9hwHSeDXImENV3UIN1hMc="},{"w":29,"h":34,"hash":"IAgCDgB+WIp4KreKyhKJX6mqKKCvEI0="},{"w":63,"h":43,"hash":"H/iBA4AI++eiSIlZkLl/ZwKZhrlnSqg="},{"w":19,"h":79,"hash":"3wcCCgD1k3t0hnYqBHm6Di4="},{"w":82,"h":44,"hash":"3/eBA4AIqi2AjW6pr5YLmwF+OlNou7s="},{"w":70,"h":24,"hash":"3wcCCoAyRWCKl2Z3kHa6A1o="},{"w":28,"h":93,"hash":"4PeBCgAHwrsHVqZgqIePdy6kkJh4RRY="},{"w":44,"h":28,"hash":"H/gBBIJ2xMevRKKpZ4bnUTD6hQ=="},{"w":77,"h":9,"hash":"HwiCCYIIxixdZgmgw/m6OIcDrDM6WZ0="},{"w":59,"h":39,"hash":"H/gBBYKlW6qoh11be/NmovTQCbYc"},{"w":9,"h":93,"hash":"4PeBAQIHFVn4p5mIv/DH95KWbUXgUOk="},{"w":9,"h":96,"hash":"3wcCAQKKqluVczewLNPP0G8="},{"w":54,"h":83,"hash":"IPiBAwAIZVRaj5dvnH4NiFfGpfFPxjg="},{"w":75,"h":3,"hash":"3/cFEYR4iPWKmIiV99OsmPc="},{"w":96,"h":42,"hash":"4PeBAoAHcJqRe3qfQ6f3THWIkFi2ivY="},{"w":74,"h":8,"hash":"4PcBCYJ8JYC0NJh5+a5HnaA="},{"w":2,"h":95,"hash":"oPeBCQR3f82Im7eL3/dIdXgHiHiHiHc="},{"w":40,"h":25,"hash":"3/cBDIJGPXu1PFWvGyI2SH8JWA=="},{"w":48,"h":56,"hash":"H/iBDAAHU4OHDHmKpoT1qngAiHZWl2d4CQ=="},{"w":71,"h":18,"hash":"3/cBCoBFzceDtlBaL20Z8Ok="},{"w":60,"h":44,"hash":"3/eBBIAHqreoZmb9ePMT+YS4hIZZmncHCg=="},{"w":42,"h":25,"hash":"3/cBDIKPdrNMd9eNmFlSn7SZCw=="},{"w":64,"h":67,"hash":"IPiBBQAHbSpXaJBWxEgLybIIa6jFdgxmsw=="},{"w":74,"h":85,"hash":"4AcCBgCVe0R0W1l7eTSq0/dH+zScBSU="},{"w":42,"h":71,"hash":"4AeCAwAIZyCedbhPdZlPiaKXHQRSNeA="},{"w":61,"h":34,"hash":"H/gBBII/nL94LK58lLTVCUz9Jw=="},{"w":26,"h":66,"hash":"4AeCCgAISob0BHp4m1BvNhVnCiiFAtM="},{"w":17,"h":62,"hash":"IAgCCgI8jI9nEofooGprka8="},{"w":58,"h":90,"hash":"IAiCAwAI+Rt0bsP6o/o02aef81rcY4g="},{"w":41,"h":96,"hash":"H/gBAwDLuolgvElQHQppYPY="},{"w":57,"h":6,"hash":"IPiBCYIIh0q8dn/PhQBnd8ApWKNbdKo="},{"w":38,"h":47,"hash":"4PcBDgI6lpeRR2aABrhXvWW0UNaEIHg="},{"w":76,"h":6,"hash":"3xeCCYAISIuiIk91a6CpEKQVvUdsBYc="},{"w":85,"h":66,"hash":"H/gBBYDvamWWd5h1Vo2YlvdTB6/w"},{"w":7,"h":52,"hash":"4AeCCQII+ZuGYmj6XPaXdqa3B0a2dNA="},{"w":1,"h":4,"hash":"Hhk+UjgIh2iYh4iYGIuPh/g="},{"w":19,"h":26,"hash":"3weCDAIHY2JJumCmuqb1ToC4Yy94dxtUBw=="},{"w":63,"h":98,"hash":"3/cBBQBSUpp6Kn2Gnn+ikgXX5EBM"},{"w":36,"h":27,"hash":"IPiBBIAHh6xAemiM6Of5b5+q+XOJtpUwBg=="},{"w":69,"h":50,"hash":"IAgCBYK2ilWImIhgd8ikFPeoVvSn"},{"w":17,"h":100,"hash":"HwiCAQAI8el0tLUJG2OAFon6I0mAZ8U="},{"w":59,"h":24,"hash":"4AcCC4Inv4c4e2OIYDUGV7g="},{"w":57,"h":48,"hash":"3/eBBIAHBXh6Y4qllpB1ryKBqvqpSomhDA=="},{"w":90,"h":41,"hash":"IAgCC4CidBWGNweJm6+coBY="},{"w":68,"h":3,"hash":"oBiCCYII8hsmqlAgxcXwJHh2mAVZhWc="},{"w":77,"h":25,"hash":"4PcBCoL0iEqFdXO2zo+n9Eo="},{"w":50,"h":41,"hash":"HwiCBIAHSK9XhZbW6qiAX4j+o1Yn5JPmBA=="},{"w":95,"h":87,"hash":"3/cBBoBbqfUutpKElbmKm4B0C4I0ePA="},{"w":19,"h":7,"hash":"H/iBCoIYmUl/V1VESZ+mA6eLeHenBnY="},{"w":62,"h":28,"hash":"3wcCC4JqV4G1jAeTn3ttsvs="},{"w":80,"h":84,"hash":"HwiCBQAItaKauK+3WPxqSRf6TQlTiYgs1w=="},{"w":58,"h":11,"hash":"IPgBCYJWdVboUvKJ+nc6wI4="},{"w":62,"h":1,"hash":"oRjCKYZ3iIiIiHCXiJCLCIeIiIiAd/g="},{"w":83,"h":84,"hash":"IAgCBwCXsHOreqRrykpmV4t8lq9nsqEP"},{"w":38,"h":50,"hash":"HwiCBAAIMJSGqHiF+EsGGyZ/MLJmynBjAA=="},{"w":90,"h":25,"hash":"4AcCAoAmQEenlsuKgE4WyvY="},{"w":44,"h":34,"hash":"4AeCDIAIlUgqYFOZB1WIemC1gMKUhIq2Bg=="},{"w":44,"h":58,"hash":"4AcCDQJ1xMubx4YIEjhy2ct7H/IW"},{"w":53,"h":97,"hash":"3/eBAwAIfvkuWL5QdinGj6Tb3EAkpKk="},{"w":46,"h":78,"hash":"3wcCBAD69gtnSeOmSQvGoFC4QA=="},{"w":1,"h":91,"hash":"Xui9GQR3CIiHeIgIiXhfqAj3h4iHiHg="},{"w":62,"h":53,"hash":"HwgCBoDPnGynWGlHeHhaWbZ4ymtwP7k="},{"w":31,"h":16,"hash":"HwiCC4IHYMrnVnx9T5138ke3RXBlSHs="},{"w":79,"h":89,"hash":"3wcCBgCwS65nmVZ1Z3pFOze0G4xfP4U="},{"w":88,"h":46,"hash":"HwiCA4AIeft1qUicgPivQEqYkHp7hEY="},{"w":42,"h":42,"hash":"IAgCBwJd0ybqWZCUd6pdbkhHCZnq9ZoE"},{"w":33,"h":75,"hash":"4AeCAgAIin9mWYoCq51AulUCcmlfBB0="},{"w":74,"h":1,"hash":"3ic+AYp4h4eIiAiHD4hcjQg="},{"w":55,"h":35,"hash":"4AeCA4AIeIClN5NyENOgNZyaiIljBmw="},{"w":2,"h":11,"hash":"HlgKIQZwiHh0aZdocCUKd0w="},{"w":29,"h":33,"hash":"IPiBDAIH9lRItpqqd6CWmKAo+ad49JdoCg=="},{"w":63,"h":40,"hash":"HwgCBIBmUIs2Vol2OVgfOEvSHw=="},{"w":77,"h":90,"hash":"4PeBBAAINniEP5lGAIykwV9XCmS5VmjHCg=="},{"w":78,"h":14,"hash":"3wcCCYBGGoFHx5agj3xrR+8="},{"w":37,"h":40,"hash":"4PeBBQAHy1gEeihmdftWmwDn5DhniHAnSA=="},{"w":79,"h":27,"hash":"H/gBCoCc92ZmanaKgGcGl5k="},{"w":32,"h":86,"hash":"3weCAgAHW6NocGpT+AjOVShetbR/aso="},{"w":14,"h":30,"hash":"H/gBCwLNrIeFxaZPd6YvX1o="},{"w":33,"h":30,"hash":"oAeCBYIHarRwplZVhZj915j1dwd5Y352aQ=="},{"w":62,"h":95,"hash":"H/gBBQB5kGdZ1udkd3lJiV2PbAVc"},{"w":38,"h":93,"hash":"3weCAgAH5D7FRwOFXbAZ+fVpeYk0iHY="},{"w":47,"h":32,"hash":"3/cBDYJ0r1iqSJRkd6p3V3s/a9B/"},{"w":24,"h":9,"hash":"3weCCoIXijxlaV+Eb3RI9odfWVeFVEo="},{"w":17,"h":54,"hash":"4PcBAgSYZHWYgGhbIwMCqHU="},{"w":8,"h":78,"hash":"X/iBAQIIiFJTAjdPiZYEl2PU18bU8nQ="},{"w":8,"h":29,"hash":"HvgBCgYccKvOOXhJ20mAnyg="},{"w":90,"h":54,"hash":"H/iBA4AIb+2Md4RWn/Kfe/FJlyZ1jaQ="},{"w":88,"h":16,"hash":"H/gBCYBnc+aN1jVQYFe1Ov4="},{"w":38,"h":43,"hash":"IAiCBAAHzaaMBvyTQkB3gJ94bziEt4h4AQ=="},{"w":45,"h":33,"hash":"H/gBDYLClnt/2qJ5pLuZZpLzfW+Z"},{"w":9,"h":54,"hash":"3weCAQIIGp+aj3Wd9/GWhYjzxmlbRNg="},{"w":97,"h":47,"hash":"HwgCC4DRtDd8iLn5CHJ3Dnc="},{"w":69,"h":50,"hash":"HwiCBIAHyWepeKMEBzTqh8/5k21oaT90Bw=="},{"w":13,"h":65,"hash":"3/cBCQJpo8dpn410PLfAMvg="},{"w":25,"h":30,"hash":"IPiBBAIHqzf2RJS69XfSTG8Ld2qHiYe2Bw=="},{"w":35,"h":77,"hash":"IPgBAwC/xkaVdZYoy6K/wGU="},{"w":71,"h":99,"hash":"3/eBBAAI8jHjH4eYVgrLOHcQSAdCRrQ3Cw=="},{"w":51,"h":61,"hash":"HwgCDgB4ioRm1K+IiVmHolm68zhYANY="},{"w":99,"h":81,"hash":"4AeCBIAH/JSLl4h51voehr91sfZoWH5pBw=="},{"w":58,"h":89,"hash":"4AcCBQAN9jWNunlhuGOXV/qTrGQG"},{"w":92,"h":16,"hash":"H/iBAYAHzWadOEC11RAUXzefbZKQ2lw="},{"w":20,"h":9,"hash":"3vcBC4R6lm+5cB20+YmS1Ag="},{"w":87,"h":10,"hash":"3+eBAYII7ZVjl0/0dZfQebdwhKU8pfo="},{"w":59,"h":3,"hash":"nwcCCYIU+4fttp4sIM3/v4Y="},{"w":13,"h":39,"hash":"H/iBCgII915YU3MIrA+InAykfK6VeHk="},{"w":22,"h":87,"hash":"3/cBCgJ3MMpXK6a/3GXAf2Y="},{"w":20,"h":61,"hash":"H/iBCgIHcMhmxIC4f1uGBYYJhDl0p9c="},{"w":66,"h":33,"hash":"HwgCBIIVdWCYY5inCWn6Zwzbhg=="},{"w":24,"h":56,"hash":"HwiCAgAIV60ZefWzRA93WHxypWdW1wc="},{"w":36,"h":4,"hash":"HvgBGYaQohaH1cJad41gsFs="},{"w":93,"h":76,"hash":"IPiBBIAIcF8CZi2l97tl7aDCr7ubuXQ4Bg=="},{"w":50,"h":49,"hash":"IAgCB4K8Q4h4hHd5l/iUSzW62DWwG5AE"},{"w":89,"h":33,"hash":"H/iBAoAItPphyoZ4+FLXCCappHhPZLM="},{"w":88,"h":38,"hash":"4PcBA4Dgi0XjhoOT8obwtio="},{"w":92,"h":5,"hash":"4PeBAYII9+Mpe4oUCPQYNXqZtjd56L8="},{"w":16,"h":43,"hash":"HwgCCwLaREjvVTaWYGcna/o="},{"w":43,"h":25,"hash":"H/iBC4IIh7kDmbD1XptgRBwqiJg/xcs="},{"w":23,"h":15,"hash":"IAgCFYLE742XWmeuhhpXpJRT8NAa"},{"w":81,"h":7,"hash":"IPiBAYII/XuJaMxPhJlP16ewLUeXWQQ="},{"w":43,"h":10,"hash":"4PcBCoLS3wRpnEkUcvkwb3Q="},{"w":40,"h":31,"hash":"4PeBBIAHbvoid+O8vCBou5CEhcBWSHukBg=="},{"w":2,"h":54,"hash":"XggCGQh2P1eHhUhzil+FCIM="},{"w":81,"h":51,"hash":"HwiCA4AIXq5fhicHMAp5mmqAO6paeLQ="},{"w":66,"h":34,"hash":"3wcCBIKiaF02Rp91WXV/yMDyNw=="},{"w":82,"h":74,"hash":"4PeBBYAIuB98SKHF1VCErjT2iqiJh49Jlw=="},{"w":68,"h":37,"hash":"H/gBBICMWJmAh1d2Z4isj9tQYw=="},{"w":97,"h":1,"hash":"nxe+GYR3d3iIiIBYh8+FCId4iIiAd/g="},{"w":35,"h":74,"hash":"IAgCAwJaeHZYpGRvC0sBaJg="},{"w":84,"h":16,"hash":"4AeCAYIHtlwCmBe5QIZI/dVrfKfVQJM="},{"w":25,"h":39,"hash":"HwgCDAJR+5x4R7xqGLm9dQAwSA=="},{"w":7,"h":89,"hash":"3weCAQIH71t0jh3R8bisB2CGlqVZa/w="},{"w":11,"h":93,"hash":"4PcBEQIIiUqUVniGpkCGDmY="},{"w":28,"h":16,"hash":"3/eBC4AHZgaLiVrzW/ntHXFCuDtPelc="},{"w":47,"h":1,"hash":"4OdBGYaHiHiIiAh4+ojcfPg="},{"w":83,"h":79,"hash":"4PeBBYAHWU/JlHJmlyqL8DdUi9V3hwhJZQ=="},{"w":31,"h":58,"hash":"3/cBDACMjykH1lXXk4f1m1lXwA=="},{"w":87,"h":27,"hash":"3/eBAoAIWAuadHnSkGf6ZDCpWDbDVHk="},{"w":16,"h":25,"hash":"HxgCDALVgxjVRLTpv0gXoIL7lg=="},{"w":95,"h":6,"hash":"3/eBAYAHNhipdw3ol29wPIUrqUSnB0Y="},{"w":40,"h":79,"hash":"3/cBBACXn5bth4n5V0ifeYb4CA=="},{"w":37,"h":88,"hash":"3/eBAgAHatYMrGagPXqAUTT0aZlkV6k="},{"w":7,"h":64,"hash":"H/gBCQI1U92VyEzQtwv3UiI="},{"w":73,"h":44,"hash":"4AeCA4AI27s6T2uqZi+wyMCreGFnrNs="},{"w":13,"h":53,"hash":"H/gBCgKVPabFiVmfjzmJPgc="},{"w":26,"h":88,"hash":"4AeCAQAH+JrYmobr0Ntl9nJ0DUiHpaU="},{"w":92,"h":96,"hash":"H/gBBwDjZwxVe4qVdpSGh3maxpnPRPAB"},{"w":22,"h":93,"hash":"IPiBAQAIem4GeFLLT5awR4mPYXKA5Tc="},{"w":11,"h":63,"hash":"3/cBCQIEtyd3dmiLRl8zsW8="},{"w":1,"h":16,"hash":"IUbCgRB3CIiHeHj3iIhgOAj3homHiJg="},{"w":86,"h":54,"hash":"3wcCBIBmpUU+NtE5BapGT91XLw=="},{"w":45,"h":45,"hash":"3/eBBQAIjRSWIMBjFweKEy/f4ohbs78Dtw=="},{"w":86,"h":72,"hash":"IPgBBoDpdbynqIevhmp4hEio9NP2gBk="},{"w":56,"h":61,"hash":"HwiCBQAH/ErFesNrqQBrC8foSKc/e7px+A=="},{"w":36,"h":82,"hash":"IPgBAwDXSB9klzdqJkDSCJM="},{"w":19,"h":26,"hash":"IPiBBAIHimHC+WqsTSHQA8CFXPR1aWR7Bw=="},{"w":63,"h":30,"hash":"4AcCC4I3YoJa+ltaWY+Hed8="},{"w":22,"h":67,"hash":"H/iBCgAIdFg69+dzCKkNMJDF6aaPSyc="},{"w":7,"h":4,"hash":"XggGJIgRdYiZWVxIaK+wffh5Sg=="},{"w":65,"h":44,"hash":"H/iBA4AI2jgH3cmnUEkHdX5wtrmFaHQ="},{"w":97,"h":19,"hash":"HwgCCYBPdSVmN45n94dKaAM="},{"w":67,"h":11,"hash":"HwiCCYIImJywpza6bza/d0WndXmo+Dc="},{"w":83,"h":76,"hash":"3wcCBoAIXnqYdVekp+pHk3pn3blfP2g="},{"w":80,"h":98,"hash":"IAiCBAAI39GbiZdfdejP/6+SXIhJSPW3BA=="},{"w":72,"h":86,"hash":"3/cBBgKS+2i4h4h1a46YhnlKwb9G+qc="},{"w":78,"h":29,"hash":"3weCAoAIY2hKCQcA2gp4mpvrhouJX2c="},{"w":91,"h":1,"hash":"IfhBEYZ3iIeIiAiIB4j1i4g="},{"w":68,"h":99,"hash":"IAiCAwAIvewBFkMMqfxbLHqvcVbUZms="},{"w":79,"h":4,"hash":"4AcCCYSbW4Z9nYBL6TpgcE0="},{"w":80,"h":44,"hash":"IAiCA4IHxb+flWn5zAqJiPi0z6Vxk5g="},{"w":43,"h":36,"hash":"4PcBDoC6Qb9xjJWgeX5rc6hoklAaxH8="},{"w":61,"h":21,"hash":"4PeBAoAHoJ2KVFQu/C75JX+S/veGdqI="},{"w":23,"h":93,"hash":"4AcCCgKo/IiJRxNo2ZmfDd0="},{"w":96,"h":65,"hash":"HwiCA4AIgHXGaTw2+itpEHn4d5dxd6c="},{"w":90,"h":27,"hash":"IPgBCoBhgm/V6idYX+m39Sg="},{"w":81,"h":31,"hash":"3weCAoAHn6dXpYipr98KiLRmcGlrmIc="},{"w":73,"h":41,"hash":"3/cBBIA25bcwaFfYQdYCSqbxlg=="},{"w":91,"h":68,"hash":"3/eBBIAI0Jd2OHJIevfJgHx8NNZGpwl/CA=="},{"w":94,"h":74,"hash":"IPgBBoBvTGmIeYhay4x3Z3U3pn8gD0I="},{"w":72,"h":34,"hash":"3weCAoAHQIe83bnPpAqzRjjYpIKvlzc="},{"w":51,"h":34,"hash":"HwgCBYA6fzbdPH26bIaJuMpavwGt"},{"w":55,"h":33,"hash":"H/iBA4AHNLA1nUbPzZ4NNVOQpXiZ11k="},{"w":92,"h":20,"hash":"4PcBAoL2t02KyoishKT/vWc="},{"w":1,"h":94,"hash":"Hvi9CQKICId4iIgYj3tPOAj3h4iHiHg="},{"w":46,"h":34,"hash":"HwgCDYCYTefPhieK2bw3PMCa2Lrw"},{"w":64,"h":30,"hash":"IAiCAoAHTDx/WamEcJBD9saIKnufjmc="},{"w":36,"h":19,"hash":"HwgCDIKHuMmduHeamoBgk/PTeg=="},{"w":90,"h":94,"hash":"IAiCBQAHZy+dmah1KFYwiwVXfURlxoBptQ=="},{"w":89,"h":49,"hash":"H/gBBIBpkIl5rJU5yJrcT/x9UQ=="},{"w":34,"h":2,"hash":"Y8iBCYSIVbsJgXeT+wc2iYeHh4eHiAc="},{"w":25,"h":40,"hash":"3wcCDAKkNHd3x4wKpngw3Pa3Cw=="},{"w":47,"h":32,"hash":"IPiBA4AHl3CHY4VS/WGvOem9n4xZNiY="},{"w":53,"h":96,"hash":"4PcBBAB4zFkHVnZ5Vnb+cQoyqA=="},{"w":47,"h":93,"hash":"4PeBAwAHovPYd5nJhr9y84volUge9qQ="},{"w":86,"h":89,"hash":"4AcCBwIxnclaVpZmhwuXeIqsOUOABWQF"},{"w":40,"h":90,"hash":"IPiBAgAIzWDGoI4lL8cOkpTyeDiGmHY="},{"w":38,"h":66,"hash":"H/gBBAK5wb10WqSEx3BqgHasQA=="},{"w":12,"h":21,"hash":"4AeCAwIHU5iHp/TaqbBfi7ypznOSRIA="},{"w":54,"h":29,"hash":"3/cBBILTCdd3yod7eIVwrqfOnw=="},{"w":78,"h":77,"hash":"3weCBYAIfO9WhGEMTcBzWAd3QJc6hLVohw=="},{"w":88,"h":96,"hash":"3/cBBgCfiF9LQRun2ME2UJ5yuGC1Dgg="},{"w":49,"h":89,"hash":"4PeBAwAIKc/XsRei+QLKXFcyRHdFSvM="},{"w":19,"h":41,"hash":"3wcCCwKIj4pZgGhJXAfKXfQ="},{"w":70,"h":53,"hash":"IPiBBIII2CpoSAcGTfhW94S4b3aFdlqmBQ=="},{"w":55,"h":57,"hash":"4PcBBwAGraR0aVs6blrol9aYrApGsBgG"},{"w":54,"h":29,"hash":"3/eBC4AHyLBqhpD4VxQX9hqwzY9DuVg="},{"w":15,"h":75,"hash":"HwgCCQKJ9qeHapW2EDvPhPc="},{"w":93,"h":81,"hash":"H/iBBIAHC2WedpVnfngIVnX/YBI7RopLBw=="},{"w":64,"h":96,"hash":"4AcCBQCMiJ5GnFu6rDv1yHqfSLQP"},{"w":76,"h":44,"hash":"IPiBA4AHqQeWWI2puG+/SsVZfH+EKFc="},{"w":88,"h":33,"hash":"IAgCA4L4N5JQnp1odH8Id50="},{"w":1,"h":34,"hash":"Xue9MQaICIh3iHj3h3WfiAj3h4iId4g="},{"w":30,"h":4,"hash":"IOgFGYa2AneplIqU+YGncKc="},{"w":92,"h":76,"hash":"HwiCBIAHKrOomZBJlckKC2XFT54JnMSLCA=="},{"w":91,"h":16,"hash":"IPgBCYLldw2ryLmLYHjxVkk="},{"w":3,"h":26,"hash":"3yeGCQIYiY/WpUpL9DmJ/2WYd4anCrk="},{"w":2,"h":14,"hash":"YNcFCQ56SgeK1ld+hvp2+FY="},{"w":93,"h":90,"hash":"IPiBBYAHlR+Hm5gJyPdlAqqImTuPWXlFeA=="},{"w":47,"h":88,"hash":"3/cBBAALd4h3lHZpeIlfs6NTDQ=="},{"w":35,"h":32,"hash":"4AeCBYIHdgJPGVxze8rQrvpIvplllljXBw=="},{"w":38,"h":5,"hash":"4AcCCYRTKNxEawW3UJsl/5Y="},{"w":8,"h":78,"hash":"H/iBCQIHjIUIg8cEM2XQxWp6oKdah5A="},{"w":39,"h":36,"hash":"3/cBDoJQZreZx2RPmKWGpxpkSXA6/EM="},{"w":55,"h":13,"hash":"HwiCAYAHfuUceQxcnO/ZMFE234F3aZY="},{"w":13,"h":56,"hash":"IAgCCgJ7gviHc4ur2H4A1gI="},{"w":26,"h":22,"hash":"3weCBIIHx2iLhiCf+OrZYBToaS9I6Zd1BA=="},{"w":83,"h":19,"hash":"3wcCCoL5dJtjKLdzdZvA/G0="},{"w":7,"h":9,"hash":"IRiCDAQXc8cFtlkd8SmVQIVf+rcJWDm7BA=="},{"w":90,"h":43,"hash":"4PcBA4BWXGyC3oWPkJfDrgo="},{"w":43,"h":77,"hash":"IPiBAwAHhT8asHZ/VIr3tMogVkNnm1g="},{"w":36,"h":27,"hash":"IPgBBYKokWWJhMqLlilFBLsGpL/F"},{"w":86,"h":62,"hash":"3weCBIAHsSsGVZlmSOT8f6tyeRCPwHh6DA=="},{"w":63,"h":89,"hash":"IAgCBQC69pVJd3nafFOo1Xr893oE"},{"w":69,"h":98,"hash":"4AeCBAAHidVTQLuXiQbeVJ9H32iHhUaHAg=="},{"w":12,"h":58,"hash":"HwgCCQKZX0iHh3rGb6X1Nec="},{"w":7,"h":7,"hash":"YPeFFQYXh8baRainTwmCfET7/Km5ia0jxw=="},{"w":40,"h":12,"hash":"IPgBEoSX8Ul3WeSbcLd1Zfg="},{"w":93,"h":47,"hash":"4PeBA4AHY4N7pPu7oJO5AnmeQLYcRLk="},{"w":15,"h":42,"hash":"4AcCCwJ0D/zATmOWM/r3xXM="},{"w":67,"h":45,"hash":"3/eBA4AHVbr4h517mv+eZprFsJioaoQ="},{"w":67,"h":74,"hash":"IAgCBgBqmUc1R2d6iqtrigl4lSf/VXQ="},{"w":29,"h":67,"hash":"3weCAgAHYJRmaGqgmaOAV5q0AZR2akk="},{"w":43,"h":10,"hash":"4AcCCoL8SVDXdVsyiECVaK8="},{"w":75,"h":40,"hash":"IAiCA4AHw2mXw6/AoA5Eo8onAwY4s2o="},{"w":73,"h":91,"hash":"3wcCBgDFhsbJlnmDaYxwRZZUl5GAfzY="},{"w":21,"h":54,"hash":"4AeCAgIHWamZCKf0fNZpTyw6aHxHXzM="},{"w":98,"h":52,"hash":"H/gBBICZZsltiKuMUHewoYEI5g=="},{"w":2,"h":29,"hash":"XwiCCQR3cpuYwKqODEIHWXgHiHiIiIc="}] diff --git a/core/artwork/thumbhash/testdata/golden.json b/core/artwork/thumbhash/testdata/golden.json new file mode 100644 index 000000000..578986e75 --- /dev/null +++ b/core/artwork/thumbhash/testdata/golden.json @@ -0,0 +1,8 @@ +{ + "alpha.png": "JOiFBQ4nkIexh3p4iA8uB+lYhIeAh3d4dw==", + "landscape.png": "3wcOFJpwh4eBh3d4iIePgAj3hw==", + "portrait.png": "3/cNFBqBB4iId4d3d4iAjwj4hw==", + "solid.png": "HoUBBwB4eHeHd3hweId3h3h4B2+Ih4gA", + "square.png": "H/gNBxpwh4dwd3eIiHd3iHeHeJ+dcH8I", + "tiny.png": "HoU9tx4I9wiIh4hwj3CI+AiIcH/494cP" +} diff --git a/core/artwork/thumbhash/testdata/landscape.png b/core/artwork/thumbhash/testdata/landscape.png new file mode 100644 index 0000000000000000000000000000000000000000..413851f47af829397cfbabc5b9ccb09b413a03cb GIT binary patch literal 20198 zcmV)6K*+y|P)*0${%2oMM%K@ubhNr(|4?(XjH?(TNS-QC@l1cDVWUZJ#9naWhAolaXiwW*H0 z=eOvyz8}{*_y1#Ufk3d0oq`>R?hvqBAV5?g!XCjM4hi;hP(*@*g1sExA&Hzo3^~Ce zWVcJ8wEYN*+m4~ST?S==6xt$3(G;A(VA~0FL=@2zQNmDg64MUnE>9d8Soc~8*DUj>bPBGSw!J6iZu(8WIl z-F&sJ=g)pC80Oiw0e;;!#?RX)`CVj~-v!hBA(%w4W48doZUNhNi?D0w9z?h8MN}Zh zzHQ=2iijh%OM=7O4kEQviesV@$P1*A+kFHDQ5n>>A47fDQFM07qb_nB!|iey2^28i zsfyutH7vKOVY^Eei(OjS2sCint%<`$F?ZCcZbuvD~jQrQS=L-^!B0Df_|LXceiKab zpZ_03M07j5MMc;nCW`1jG4}0~;J_{k#CJ;~xl4lMdnJ$)lR$2lBqzlVb8^>l6!u7Q za^Er3MHEomt%%-ES+sX6V<@J8@m@8|_G(}*s)Ut@I@Y_?aNezj>uyb4_UhoaM+euP zdiaUzAfk;?a5?rZwf0r(^=b>#yOD!&WmNSxhsdOJ4?8*r;zJ=i@3e3hE!c0y?ni|pD%Zg z@Qug_KkS*{yWJ!FzGs4;ca8Gv-U$$yLLd+oK}1rNZF@x7DY}P!`}ZO$A;!M_2iU*w z5J&g#=a|F+j)))TgxE3UC601ZLI!2=BdCfWLuIcbr$pt^+^2xv9(nX66fxeXf|-~K zmZDmi?a{)1-)Zdk>fyTQG!Bw_c|S7mD|JQ6}k4@g5&a_j^&c- z9a4MQxqml1#dovk;2y*z#1P*v#-YRekUVsdLkFdh-hTkOLx+&rFT=_GQYajhM@9Su zDhCzNl2SxXLJn<71+)$;Vj!uE;eIuY4rpPuUmFWaO{@=T;3%$-ql5u22aIt)Y=EzX zK3?Lc1RXNQU&4ZLaZ`d1*%N)pf$;ryL`paiFX2Fvlp~2!&SV~NC-0y)g$LXzIOs>w zArA^9gD4jFp=N(5brONpN(9reKZ=I^VKj+{({mt(9*JnW#G~nvNMiVK5)+5h852)t z`d|tR;^{2!&t^p`hZXT$HV@~pxxbiA@nS9>F64!Sgtb_61BZjU*c~&(_K*qghm7z#XoTxw1N^1T2{>eopR@&mN9>6_VoTUzTjGz| zk#NA4xI=Cv9&sS$h!YuyUCBP;M&U6}@}&GJI1)hVAwS9w22gn*j2h_(s*Z%ya4eLD zLou{V#n5>;hF+;Cx{fB$do-24!^un>OlACN3e$(um^qZj+`)9_4`s6~UBv2vLbi^U za89a_v(g1zI8w>wgO%JkDCF9aGHxEL=iZS9?n%{h|40q59%$s%gY~?2td)19nt1xW zN4xpxP!}Ic_3-JDUOqq6!`~0~@wLy91;AaA1n>rAPVx;5fe=nCCx-7y0Gr z694b$A_5UPQMOC(MpSAKJLL8vCcO_)*~1)?l0-^c8Y!v6NJ}5#=n*N7A3K7glmdz} zCr~}Ah^mYVr=;c4mQzAUS{)rZ6-;C_FqGE9{Mc!XW%RH*ri-1FF7~o|ILhkdAZ3h~ zv>9G3t5rLV?(Zxb=fk`NSCoGTh3*vN?wqu;<9utw~y8EqEtOM zJd$qZiEImRNO$s7wwn)*_3+{G0X{q0&u3DDd@Mc2-;a&* zjr1hn93SEPqoaH$J(FMbs6QIelCi-Q%Y*cs_Ye zEah}DRXmNgye<}sy4cGb<9OTzH+f^6<;`(bG{H;3lmK}0iZwitsppPtEw3DF=8T1uzTSJA{!RUacoJR~WGropadavt(Q_h+F8O2zl@b|H%w$kGl?kOZMpZJH zmCt2CC5J`Dd{&i8SWqouRkf6JD#dK5R+fd)N@~@ znV02Td8FFRDNDVP?bVT zS&9RyGDs;M<*2$WN7YXtrzD4psyr$hikwnbMp;7x%~LAqozy{FMHhopS{SP7Vydo> zsgeN}8YWn%nqaSPile#(E*e&NX_(`qYK51w9YGq71S{DRq~b)Bs_UPNRCgsx-Gw9- zcj8ZalcwxJj=B#S>OK@|1dx9+ltQ%-icf}7q7hBGatyUf(bOu()1nbe^T`A{l@jPa znMQ|t8oe5646CFwb}E}m^-LzzvYAoOV@@NFWu+2UPnB}!R3V!x&u<}TRm(Z6RL;ed zHC$1t=7wq&*VLN0ty0U2s?FThXyk!f8;_M*c}=a2H&y$1L%Ewbl!kb!Hpn}w!+fAJ z%qJ@2e55?g$7&ONc4~}&D9`Yv>H^P{miSR^k?%AX`Bim=UsRX*RdtR3s;wXpoIZd+ z>j1m8CD^4QiKyyf_Gw8Yar!6+v}HJ=CC4!>d1SQ|Ij*XLteP5%+NY4$R7OKv0}V|L zbX0XXefl(p+NUwn(!xkn2UB%Dtkm?e(J;a4v<0@Ot#H&b#zDgzcP(q&wQTS_ZG)GV z1Af|eglakwrtLuVX%AwwT!}s9@#j*sy-CyvB2CqoH1%MzG(#xV4y0H!3ZZ5M73!gs zsm4-$DwhXn$Dm`Ha!}t3~S{urjf^}S^+a^c}!@Ru&iCc zta<^frz_c1t71c|hOJYToKvgdvPKQpH5$2cs+KG24S!&kMG)DMHZIJg*kMOb9B%f=J@u~VaU#U;=mDUu`PR;S1<~%>B&heA> z8o#J7^KXp}e$iUve;VruwrLzfpnm`n&7Z?rVO(B z3drdyprCUSC2bXybyQH&Rz+1q0}U-Lw6(O+($Yp(>okV?CYbB!Vyt0`g{B2|y2d!@ zS>dQ-i-(>iE*ds?Y1-hSYmJ|t6QQRa2+(#RLdTgHEl;9OyAi4DN3y0TX?lJnY6Xy` z5kQ8XKY6Fa$ zGnX;lEC#i6nL3@#w0<#j`UNcOl(DQQWKpYx<z1*pQNdY_YF^N9;F@L&SG8)n zq0z=Yy-sdwck;4!3oq;U@KC#h#~S@S*6-$Zts&k#JY&-a>Z{Cs+a-!xbFS$CaZwKw>W_BjOG4W$w29c8!4F?JeA zu}AL+VkXCs)H#NPo&u5viX7KdK~_hVqeg1Tn5c2mND~EPEmZV$&@xg-TSp%)T}^a! zbTBbC!py`JGd&Y5jm@z%FvUU78Yd$g>X8las^pfc@$e>F%gC64oMvd|rF(_ctpqOdnLZ%E#Sv0Itw{qL0joSw8+%@Rrp>8h^b=!GOubU_OeY|SY!<$Bf zylp(r2YREtr#s3=#v^>BJH{6}ll)zGlF#**_)2${f9kLDjnO>c8m;n^-a5Y-ZSuSB zD!=J(@rTKI1UoE_ATU0}9)rW|FqC1x#Sz2|n>292xR%RibzC=X<%&r=*Nr>5W!S+3 zgHG-lwDZuki$^9yykRiFtEMBoWir51lTqF?8091LDLyn9<*#Prd~80+XNEI;Z9c~r z#*2JwzQVUAOZ?MhjUSCx`O$2RUk%RjAG7oP$6^bCz)Bi{&2e^F%OGNXf_)Zp?6;Cf z+*}4J6D1B@rowK{4R+9;XnqG6$lhN&TX*7_Kl>tbYXhJl3{ zre>y?nwnu@Wr3ZAJ=WGXxLVubVPS`>jU&D`E_j$a;cw|hkckID7M=uKcoJgjN4Rw$ z@z&m?mZ0X-(U44eNS%D8zEbJiuy*pxGEQ^~4T8OzocteaJ{YSY9S>w3=G zG;zVAo$FSuykOSJ4U;Zjv}xz2Sq~4b`+3E*i^t|8JhABIiRlEnIofWRO4mer5;9=#6yM;S`_D=ZO zcoSmdMwpcc;Z^~J+XoV5>qERnFbP&6Bw2)$=@3GuRT$aUvE*CCkmC?ZzHKap_6d~Q zCQ)ITM1@rn)eadn*<{jUpH7Qi7Ht+ewA&QY>rhOORS9GErHosYF=Z=c+(yW>O%?N2 zl`Po_S+c8V&AOfq>n1jB8`-jI=ZalB7wkK@V$;rxmaW{e@8f}WFAr?{dEn5`LyJ*f zw;JRv`$3-AjPj<{DDT=%^1j_1?^{jrspTwxwOr;?y9GY8oaal21)kZj@}uP@KiF>Z zy~7qiTb$=#HW&EK?gIa_yM#dCB!j@=C_9|x5p_JteuooCIG$vmt1A0llsW2jiervy z$T*)y&QTo&XHDcCH8|;@hq{9vsRN5ox@?Vbwk zbXH=In-UUE%1FALLc&o4X-8!ad#EDoatb+DU1XfKIq9y=Nhe*D9SzWQG)2?RnA1+? zoc1(B$Jqp9XA6v6tugno$K1mj3r8CqT%ED^aK+Wd0XH`<+&x_Ia`eE<$s0cpf5JVy z2=@pe$|;m^*8t)?!-#c^BE>V5M8|N_Tw}?0N+8cOo_yCtiro_sx}+dRw! zPA!~uYGTv9i7m%=E;zPw*{zpruHC%oG{9Y#9&S1g@UqhY_nk+0=rX|@9uquvn&Pq3 zByYOT@~-nF?>W!$)OnsyU6=Tq`x>9Ptn#Jn8NP5o$1~SW{^@v*f4Q9DUyfV+;BPIUTW<3RYc4~6$u|DqXoog;FzWKDdX4C1JN1IzQ1HOd}_!cwfSOuAJv;abg-YZa^BEi8LAu;JRmhFc4pp3Pix@8ODPE7v^wx$M)=Ri7Si`3!K= zbA*>X#(Bwol2^RPc*AXyN8Yo%={w20p7VU*v%q_9GkoB+!Ut|^eBrUqCvMAp?YY92 zZX0~#c7|tO8~o&XiC=s!@U!=2e)YV-FFsfJ!{;i3?f$X|d}P=apn{mcD&js0i27?F z@f;>l3rR06j`?XI?W4ibKz$T^3{eTxMc!W*B|kG%y^PTGvOw3@6df;HbOLNJ39!a2 z&=%_e2kg9Du?TdvSW-OWN%4*)%PWyg&u9w0;>q(#CC@8~0?!nR{Zgs$&7w9ii)!zD8vXKV_sgN( zuas{8eA>N>=n51v>{Z5KKp7)G)r|XAG38Umf?pF$o=vQHHLw)W#)?-PtAXvD@#^7{ zZ#!3fdbs2_$Q8dqZUy#nH=v)Jfdky}ALOp*Fps=ucsXF2Cjk?@;WNj(0gJpJxWc=> z3w-Rm%==!8eC)HyC;lsZ?R$+~37x|ah7C-o1;P=26_%+}<|IhFC za}YU1g5=p9pvKNn1;j#5AsVcNxStkM{+b*P)IH=O+4a18XqJ;WW)V1HbL{P7D7 zB*@PP{~%w&Li`C14k0Qug4n=NV*H{=3X38oAeyAWXwric$PJ4n*FS~4pmd4?GAQ#) zp~5eRihwLCg0iUhFQ6tcht|+sS_4Yy2q~s5tb*RqVtN9q83`6L=2yu`Km)UZjZ6eI zGab^xa&SBAVI8dax3Ll0&e@<=F8FnEHMpM({yp6AAK`XrKR5jQxfwRh{lF>i1yAy- z|16LE#(67bjyD45c{5<14}uqYKWLec0@nD@Z&mPiPQ9A))w(1`rS&OmJur5#iCqhlddr8beA{EJnL~Z!##0{^1~s@2e)u<+@gF5hz`XsGMu28 za6%#@35|**@;OFC6wxu!#6%^L5*0^cOd{#A$$u^53B4A63C%gpiS#28P0#7>{dVGPZ@;ur{V++gOfjXCmw7jQl@G!;c|YnLAH;6(S@Z=yjXBHTqc8Jy^cG*mT;bc;EBrI+IzL8T=jXT= z`8Di1zeV5Xzp?iaY>!uFTdWe>V%6CjrHNRQ2K(Z*I1sCeM1l^|F}fT})Zu8f5yxYV zIgx09T!J}Dai*M%HA5xA5{)<;v}2txh_ypE!4~}lSBw&!u}pNvF3JnrC|8_fyl_nL z!y(omwiYuo*s)DYBT6&(NL{~BpUB_^2 zGt&tT%qF%m7uU){d<*l59V|q3u^!dSdSX9YaXp-i8s>%QJ}xJYb2D*>Td@<|j-TOf z^aS_g=Xel1%S+KyypcG=Yq3kbov^~&(JQakI390`Lb@f&@m8p(n{z7F3iTu>)Kl%yNwLEu-3|SC z2h37jF-~y7Jjn~|cy}BVeQ`?n!!gYt&$JLc;sfwa3B^AxoWSHL0#jlLPl_QtF`DSq zXyVcniAzo*B_WZtgmluAGssCzr64|+g2Xh65;G}D&7&fR>*tljY=AR+9T!OYCPOv5zy! zJzPrcV=HZl3&|5)PMzXL@+h~`M!Acg; z$;lK;)Y7fd$g<^hx)X*O_86x)V3z5HdA2)NDc+c+_+ypnjbo}8c4>Y%qzB@X5{`Fv z0N$yg1SW?PnjS-FN;u)!(S&5j5uF)BY*svpX~`sKB$AYzLP}~HS=ni1W~GyxRzP7| zJ_Q+hlq45Zo}G^{tAx7rGMcl>X-p}jF|C}Iu>gi2uVj#1T{*-1WQX3f0>R=+P zo$1VW7Lr?8%<5%1vzO)PDA~hoW%sg`GQ!2=F)k#JaXo8BI^d5gb+IiQ>2fqsrBhMBII<+@{?<%wm! zKUTSZ*yQ?Rn;w8uRydw{VYp?4;hP_gZ(a-mx$y*KMG%w|Ph@%mk$FkP=BE&wn?hnv zDrs4nq-ST4laoeXRu0Aa+2rNqP@Y{%d2SIk`Gr)Zm(Y}3OmlW6P5Cvn=G4-fTSHrJ zErYoY^kgXX5JLH^Ot#$KF3ShtGt@A$m7gq-p=3PNya8`NLNEMhNRcwlEkrndAmZ;>}aECz-gsEE00FNi0q!wJ4qR!hCWHvna?bq^P)*qQYXz^2(?w zs-QBblA63~nsRGsD5#>PsE)SWI(oAk=*?@UyQqz!{ALDoT9_>CWV*PU$@~tM3wxQ* z9b~bvkIm=bQ83K8>`6AWC%K$G!sYBSF6Yg0C2yAN*-P9in&VFHGIw*9d62WltIwCa z!sDV1-YeST-Td=>n7hgQ#h3XY_aa~9UF7qki+quNg)a(j@lEzMek{Dr4~4h*vFHIm z7v1NN!n^#EdyoGWzKlRnrj9_Uk4ULLdxd(4m*}vk%!C6)1{^Fk`E!R0%#kU#M7GR^ z<7M_J7dfF;VvTyS1DZvys0rQBE^|k_%nf6qJ7#4*7!`S9F7(E(+#jo=P#lW=aVd(x zy(A2e@(|pMLh&nz!nY_Azw$&v3X+H@j3=xtk$7PeaiwV_3ZG-7ky4yZwlJHF@;ow& z^2jeMB(J0ZVR0c91tkc}Dyc57puVh}+VW}|gmrY3*U(S4Zgkfri|mdc0OC>r5h$rP81#(AM|hHJ&sydYfQYQYjWOP9G* zw91`=RqmFq@M_U2uL{?BRKCjF#TR%_c!BrI&heMBOMFs(k-rvP=98iud|G~;&x>yJ zRp}l6S#XPIrMLK@8f7lEBjb18E36zn2m}lHcBTr zS2D?^@)<5yOmM4Wj%(E`+^t;XZs|JrOV)T;w#>t_HJ(&#@oM#1-Yz-Mo5FKEt+>En zO0M!r#brLNxWr#eZ}3U=b^cy{gJ-4p__p#UKa}3%S=mE=F1f>x)sOg9_%eS~y~e*Q zUPrLK?ljw~wb@-|!cL(H`-G;5)*2yMVTELcF;cZ=NY_{(RqcRWtu2bRj-0GerjS9)Pq9f(zx9~M;s*w+VOQxk!0bqJ0XVR+Vs<69qt zM@=+Ab+H6grVw16KvYE%A(dIgSEmyz%p$Qii-Hu)3Gw=P0#9Obh#& zs~u!jILK1P2*!2SK$Sdm8lF+hEMTW<&Ng7$aU|f@Hl7QqAT_ zHP|9sXN_!wGji3ooUC<5q0SxkCQsCx+&NwEhE}r&2K52xHTYv#=Z#sTFXlC2Sk(t& z-5ic}T`=~|p}5w@<6IMmcU?Rl4bgZvCKBA7KtO#mVGWst)}|9tn?*uh7Kt@Eq}3FX zT$4>&eF@pMdF0fUlUrXzSxp6{wPgsaE2(a%qq@0)hNdPOo118@X`#8Pp4O%|x~g00 zuWO^bv5SG`ZYG=h7_IAJqH&0s>JereMp>zyV7Y0O&E{#&){k?hd7g9i3tX(9<3jZe z*XowIUAMxEwF^9G-r!F2I(Mtj^04j zq`@1r_8^Sg12Jt1!J<7B`^I1#n!<2uiNvuj0@sE_Tw9~@Z%e|fDFOfH3_=@Ih-ywD zq%niYmJA{r^NDZICBC7EwALb08}rF-E+@aeoSb$crA^h8wh5_dsHU>Dk&2dD>e_2) zZm6ZPxt_MBHhNlH=xgexr=gdjmUf1k2bt{XW~5`7@wQ&38;4kI9%HF(jFpa2R+?ux z+cwSFmI=-^FLJ4UhO6z%ywJG7i|q^CZCK%6<2o<5uXDHc0uS3ac+|SdYYi88(s+rd zjn{eFbdh&jZt+RmRX%ID&KDhb`K0YO|7f_!Hw_Q@uHh~}Hs9l?_6Pjf@GAdmdX;}S zJ>tKOkNI!&YX}70x(M1#+16!*XuA>ny3CPiwc=2xH3xfak#4c$NUH;~J$4*xb>&2x zBMLpvsI+==s@nsNZXdL|JUHFqg4SmQFg_TWIO(ptq%y{;qDiI|dnS?P08A zkjb_oX1WGgXdhy+Yn1u+X%^bXSnio-wS9rD)D&T^@1kqezGTy0&@T_`d6Pe(Han@48>-x1Klnt?vzf?|j1V zUGE?e^cf*CY|O4fQ}*^*u)oh5(Oz4`hpmwqw&O^j6NiT#IX>Wke4i%@ecmXK_@dV9 zhw6wQYW)FdkNBh4?}y<~5XQs7nDjaZe+aJ&jZhw$U`wLhV2&js0D;546$K*GbQCH-o)H^!M~K);G#%&lppEqm1{A zGchp3T+ak^{Zp(D&a>3Nz}dbf&h@NvzHf;OBkNr0UFY(^8drxlxjD4Oy@4~_fBqrE z7kRn=JdgUW@VM_PPlhk^bm&FiAGpm2!*}^`=r*79+~SMgm-wRZ0ble!;G6!}csBHq zuX|qRpZ%}#ui+>B*!wnr486hc15f$y;F}0`j9MWWwPO3QEuw=~>>9E`bix72Av+`| zT{$%5$dO4Wq$j;NHsOKXm@i6WekhN4a&pWUmGMBdM?%pU3`A=r6#emVOh!U59*o9f zG7{T~NNgt)aUP1sbvzNrp?F+|5^){R!gnwY@5yWeCNc;f%^+$rpYX9F;)e@~8Z9Mu zu$;uv3X&#DNtvi3b3{naWF0wU4dhQ$Q8eC6*>D|ILygoAwopIZMg3q8Z9|>3kMz?$ zIY7r~4}*h4bPtcvKRL+A=p>`Vqs)v4e z_$F5-*10^i#nrJb?u}gF_QYjg9=*i<$t@m?UFP+XtGqgKleb2$^4|Cj-X6Qb(}~;s zW&8o34!*<}qxbpyee@K_O%qq)S)785^SNWxSh36n)6O_!4~UP0<;HQ5tF@@Jm6k-~`@$|oBUPP8JN zXr^wujp~^WnkG7Fntt9cTBf_{m>Fbnte=6&Q3l3`8J`$pbaafViBTq}=UALrU}NLHl&6AFoe4y3HiFZ$p`4xy!({sZP>g32FkgtndLaS(sTAxN zQm~y($6+=Juc-_?7SjosO2>DyfRM>tLTB;_n$0I&TdIq+qdu;<bb*B5T{WbryrX7BReX=ABo<4 z6gsn^7_G)*x|D$FLd>5SHnRyh%%$V7l!)DY63#1GxG&}4wUCbYY5{@ESp>}%5HeRl z&}tDe^94jNmk_g3Leg>-Y4c@dELW4VQbX2k1DW$RN_p)eSaQHrSk9b{>_-R$XKdNN?!=BwYj&>LvTw}+iDgIjuexw}$rb5MUk)yLBfa8_ z?6MECt3jMx_eW(j9QEa3G}lAXTn$BIGZurjX!MujFn*V?Gu=%jk7i^lanTGsn$-Rz}nZHTV55jxj~=vkRy zaCL(5l^MpCXP8==WnpEG<@HsT*VkEHI>W~L88+8f*j(9UbL~7AS1)p9=`vSVUf|mD zRjzMd;`Z7L++Kc;@gfg5?(p*R9o}5K#XIW{c(QSa56-;A2b=f!_{;-7S$mDYZ9L+O zl{fjv$`ig?dy8*Yp78CNcldtuZGK*Q$}ejl@}HFt`1jgh`S;o<2n1_RY~OTb`&mbJ zoONaYmMfy?-H=@K;NTf|4s5t{_?!>YYk?d+>x<%sFY@a_sBHvsYBQ2k8zJaygrRXZ z7X5QE7_CQPv=)QeW-=xl>DZi2#eORtr?m_m&;NfV(|24G2HwYU+Uxb{yk56$@9K58 zw%4=QUiZ`1_O!!M_uhz#;6NNWP!Sazh>8j*4p7-UAt8(m5(tC@5(ps)VFZ#8HiV7U z&+}@ZKfbTe@BjDfx0&fV{>=CTCXhLqyIGjJo26O1Sdg}x6=^{%N!`Qx^doqu9%5Z? z2tK*T*_;*1*4!ib<($MnEtK8a;RIzx5SVk0y{Tt8lor8})F=*TUL+*@0^vE)L}W&D zGW!araxN2@d7Y@-8(hgwASUxBaXE>^XWiy{)*WtUq>+@K$erwTQq$AO%E%@wJqKZq zklge<3bOJkNE4BtDWot}h%~ndMOG1-+#<9YQZyMdbZK&`Qq|PusHw>;!<<`+Ij0J1 zj-J+34bBW5?hGwm8CAHlO!Vhk7)UcSkYi*h-Naaym3vu@Ok_9W<(2ylURl$4Ks234 zg+K8`=5(GA{lZ@|XY+LKd?w}m%8S_xnIxFQ%c4cRlDCN0g-dxWXE9T53O>wQ z!PK0!e3b3YrdRIr&ZbA9<#;LVp=eteVb&oto{z87ud2f+@0$=u3Mq8-f7*~6lo z02T{^SSAc+NzMUQ2oJDc7|bd`Fl$A@c;_BwbKVIy<%Z*%eS(1O2zKV2CP;Lay@FF5 z6rJNx-X%gaBMA{)CRA{Nu*|C*&%8`z_EjP>V~NbZ!3EJxVzRFjBT67PFOjR6iQE#T z5TBJsN^T-)c`2j{(@4+FL?Fr}D@%wVPefjh7-?P}#eyP~f+7^T`6#j^R0xYu3uKh% z$kB?F)QZZe&s9^GRgOuhqbWy+C98^-ylPy+Djb%z~}_DB8{JyZ~nB1u<6`#L|L&f1oTD?PFQq5tip4Wo_;eHWnPiTM&j{ZWw-o z(*%f)vpx3=J0y_=6r5qN_&hn86)!Hc65UGK9Hg<))J^$s|)KL{cCjFJD4_K_P_& zQi}5nP)H;wBvMrQaw-Z+&ea4fZ?(-Qqer zBz1HbSQ!*HGMLxMh{VQ-sDbg^HpcSX@OrRtIu95B%%jEAc}O;sM+<-9QR%PzHGdXQ z=P%;$9li9KWW)|#Xe(`SR73^j~@m`k5_OmGe2rI+~ zSy6nHb@`!qOT*YwaFQ*BVfYnjA z>G|2@73Pp9DL_~xq99+2Oj3ljK!IGUL{(UVMqGlnP(gX25}ibap-6>6qNYx)!6>e# zNvg#n(bFic!X-B15L;+3Fyoe)>66vdS74#P(8`$D#$8zxzZJWnpcStN6hHBh>_;9^ z&fw4TnLHu;g(r*V@kGf2o-3To)5?WRk}lwd;)T3gw35k1OZi9PTHYvL&O3^Ayj{GS z50z{AXYqPIQ26pukuM)Bw(^N=BcIFt__Aax)08`yrrgRjSs*`_1oBhKUS<^qGGDfv zIr0N6EDUCm>@cgO2Uw;!${NLSyo*lYBM)J-G7P_xaJCeM;V(bSuHv)oC_Yc1;v)N{ zk?faUWS=scP+1Hig;zLHc$L$|*Ev;qg9zC*&J^C_V$mHg$>O;vPvDB;4l%{4#22NL zP?W{(;xv-w0@8}p$tcVvRgp`ET!N@bOrD~Md}$%Y3Ms`!MMz5uQAmqXD`b>O%PEzX zQ(00*jkJ_nSrtZE6}5$x7>jCXlvQJsSJ9-@(^hPuL)t)BiIr}-i5^)K-Gz4gm2Hfc zG%>7nGOB3iZb=(ne^SoEOZhW@Dx1rn)$@3)YynTG=kSzjG0)1E^0aCRlgbwGLg^A- zEm_Q~s@42MwvKmXD|u7Cjw!13d?5E>s%#Te%l!C6;lmf|jr>QogU{pve5>Bhx2m1| zw{#Cb$anHX*=}Yj_A*m-kXed7%uyX+UddtRtBm@EV4XaiHKoV!DGSH9 z^bFgSXYegO$u3zWJ4>Pnl%Hdd{1QQBQ3R=D2~kCJLLSR8^$kwQVmYI@${G1BBIVaP zUviss@+6{5ZgWMRLYzFAo9cApWZB$OXOf~2k}S(3xh$J>m53}w9y!V)gfc1lCB=wK zB}n9jC{;xi%T$yoODR*RDOD(`E-9r-U5T-znmV-(lU#>UUPEJP4J}FoEix0%G9#^W zBb^ExJ<2Bf%Npq^vC}JWV^G<~a9K0ss&*#Iy6}3S;zzv7XY!zO9*%MLJCeUxR& zL%Q!Hr! z6|?Zt{lWtk3wXSGHjkChW;Bk6VA%&FjnhAS*eM@TOYw@{Yf@zBiL3Ifp7T*wriui>iAJwSSEZ$@Qb)a3kEu$JQKP3xZ=peFq^Zh;LuQVyJ)4JXXY-(L4v*H(<1xb`9;x{qX9-VLE#_(C5}q@x zqGCf=>y!iUwqOx61{wQ4*6soKUD`t5wC+r<~U z0KU`h;@g^i%+Ln$b4@Taj0gEie~`Jl5PmfrW`QA;1vST6Y&gN{+S4r8pJJUMoDEeG zY^;gIuR4nDH4y~VM6t6rhHZvu0*zM*tiH@4Lma_1*EpoRPN+VPFhc^Ts_t;2_7*1% zw~5pyb3uEXOWI5>8?%YiW)Ndc&$di+32oqp;y;RXKgDUT`R-J4*F}_7&W@Nr*-jLwHvQLnSTeF!vm(p{K>eONA-(& z!nA^C>Xz`dc^S{ut>&4!wY*~T<`wfQUa9fsHN!^UuG_%dh7G)9_GgM|Gw+$UGS#qy zf19`SiD5fm>UZ*~Ie@QBd-!D3T5 z%T1?QVG3hq?P=DV&#~4J$)?&fY_2_rU(GqT)<>~Zf1YjTs{|Qh*j*RLLGyJE*2i+d z9M56HHBK0Aam@`f_eqgS`~6*35q%mCAFoL)@V=~$|*Bys4y5X7>v{zO_)s8 z)SBzD)>|>x8?hQp*!1YHh+ZNg!+)1hyrx2Bmvb1R-&2mNLTBl<4x)^tK$CteRW z%)`sFh=(lmc*wGp$E?eEs$nTlnpg9jaVgK$E$5|%l{{}-$4lllOm6VzHPa^EGHzgs z(TDfU{`|}6$9qOUKC1KQGiv}}n78qnC4eujd-<|qFW=YgXL|iUW|)Hb$$EfaEWymR zgfh<-!W`o<7Ft4?ZwY0U*r zZn(-W^L6%Eu6Sh_S)w+^rntw-F#WS`wJZE3U%huKW zqsg1UHT&|0)rZ&YzPxMQ#Jjew{LALc6pJ4p*aG;-x}7hqd-$v=fG?YV2ie2-)&qQN zIlwggK7MF9%1lcrGn9Hfsz!n_}5+jV7r1CVT8x2(rZy+?c?ThD1Udk~w0(%`sat5iKd4X~^V4LmC&F z(zw)=MpTP{I9nER%>r&%@`!5^k<=n4sX3oStCY+pDJfPdX^q7QtR;w>Rpd96B5u}D z*rcY=rlG`MNm+x2k`_H>_A06^dNh`5YFdocHk+t#w$jjO#$q?qWV6y@YoM*khNIDi zyU{_H-9dMgn?ZXAeT{C0tsRUs4Dee+5BHjf@bYS2%I|xic^Q9pEaJ(QRXpxk&0p=S zdCI<;=Ni}YZ0mYna;#;t)0e+DZ(wrsW?r}ZGo^6{Z?JM8CZP2iI~`Zp+jyP8wwnYuT_dRR21lE265_l~ zNJ|pOn-e+Nn8MkXWFi_!4+ zo@*=bx9?<1dm#UG@8M%dAfLAd@|kNt|LZ!$SI%I*a_nQejVpHo?{2f;b=(x%Dj#vVn zH#uOx%^`aNM_fr9a>sMnok&s>f0JKGGX^H(LR8eSN9UUx>oUsdnJ##*785@wLJOzTV7c=QYkDGB-F|^poiS{5#jvIQ3R}7^ zgx;2Qp%MrEf5clYi zI#|Vmv|;XP#MWt}-et$$>G}i0(ba~#!-2EC zlP-5VJ)M0Fbo4UP)5lon5O=$OzlRuej^gDtu!KMLFX7SNRXpxl$K##e{I$!QXFJ#Z z(WiUY^HQfTuk>!=l`cPC?%cv#9zWjh58#dNUA)z~hxfX7Fr_b$4}148wLh3oJ^T55 zAc!wJk1(z85I+nY-PXxdAo@QQeI7>YdEbl(U(!TR7>p91o z{%AJz$FQdFGV42I@$b9BmVqnm=#FJqPdvN(;@Q=Elfb?N_6^+OaDO64`V%?Ylg`oJ zWWxG#IMtU=jqe-YRE3rO%tNa|OT(kUmgS4#RoDQR65 z2>Qy&?yn@bM}xRiPky%!nMX@ur;d^?BkGKFw{H9NZ$a%J)?|!eg_#^!Gl9< zd30zc4-I?sWZxgseAa)MZ~8;{t~Z45hmP`%=Lp~RpX8Um6HFftMC?Ejv4b*h z^cE1;uOMNhn8ZFQcZL)sder2MXb|+5BJh+U9IPg9q!Q_n9{GqCF(>tGt|$} zkcWZcQAUOb86Ozp{=jc|d5tW`YkVyac~+M9?Bcbt?YuP>#JipV-WlA*l<|XnFu0$ogTZ_}c95^fkMPxSDBp~Q z@Xb&--wqvR`tUJk4xQp>&sk=Uong-KN#>46v1llYC4*;KHX6h7u_)G0Tw=}0HGDi* z*f4$t|G_x6jo-rGbCYdjiR>J@&FKv$kh=kH%65tjFob0qMX~qN|J`k$r>yt!&6D#gck9T z0fDEA!ci^xV|A2xj3~xyQH>fYov@)EZa_EKNabiFRpX7+4BIhzoLEQOFb}!0Pq=6r zcG5P{L7S%&_ed8#o zi!2&D&;0wBS#d9#6{AtCxf{o-`*HY8#ISk%3cjOP@f(k0%e_RlkKbhbL_C4Nr4n>6 ziQv&>4vc1UU?hcO_XUKEWpn(u93sZWMBJ5dW;~Cm-wL@rR=}kZDVKj!aAQKj_4~>{ zdgopVx5ibZ-mN5Qq?FXLDl+fu$hudJ;JzN=L=Do>D&!+|$nKd?j?_|m*GTE8iOTy8 z=teD6-)}^B*M|OX6E$NlETe5$?mKDtt((TXowSX*X&djSYpfUdSTCI;L-gDqpm%hP lkViXPNF6~_a0s1!3YZCG zF&8Lfx%UW;dr#oFM-}(IC-D#*BUnm>Ab|$ado+lYI)hM3n*@Otsrxj^lrlgpFrZXm zOr^k(I)ORO0#h3HnA0V&rE9MheF7_n1U5|Xab`i_^uH}ixv;s5Re>+p z_j>b`z?Y}?`tsa9UtSah^V+@;-V}uJp;QDP>=p9y-blU{#PPLMJb#yp=DtA4eL*sR z2om{Skd9!l)E)%;_OV}Z0Qr4~I3g{LqLeIZd*sm+97cP;5_$qf4EHNyEUkd0KmluM z73}w_;Vw|aUrG(%Jtql&h!v<4C8b5;K5f$W>yjp=L#DuxJb^wX0(~l^4XN61M6JMr zR)Hn$`>g2PYe~Prk%4`7%BTx&3Zj-0#Dq`vSQk2;xaW zFwY1=cwuiSuS`UeT-bDT@5FyyR zXD|Du4>d@bs&bj2jci%DuH`@;`vP~o%>R${6{7WfndJ? zf%GAyWcMR4BZJ(*!yJ>9M@>eNQ!=t>O39%wtBCRbqnOI5Vk2_`hl46O9#F$Y<}_Y1 zYJ^CgB50o$;rq@I{}6mYhtvbQWF9giOV$*ztOX_eEs;oDQoi4k=7W~BAF`lhzZCcL>HAMoYLeL>vZAHa*!VZ16G z!fOX3xg!(79jPcjJ`lr4(n3Bvl)zn?WWGO`$WOBA{B$sr-wvho=l)CtQU|2jbNC?A zatD!@J&1zbL6qd>QIl6h^RNQia!1fTpn!q2D(3QP*vKEpMn(fSSv9;4oyJGzBmoDs zh&XhH$OD?h$>}4KH6%^Ol(fUfWXqb9d)OGs0aME4O{tT%r%BF&dKqiF4>-{w>%ib) z2PR}3n2~d5TH1@bLmsTkxU+r8oAWaMTsh#&)dT)KekhbD4+n8mHk8|P;XHpRhL_}H zc|$gWw`GNVBp=I12a@?rI*D&&Q~CN}GWTUu`AJ5^uZL6lk8}nCfy@D<6=ab*c$h;n zhf$W7L-B|_$K{WrcK8H(@`@N9If{YY2`pq!VRPgZ4o5U_kyFE6?i8MfbnunYCg`v> zkq5PiKB7b7VFOZR4at--C*z1&Ydwi z52j_jnU-;9MaG?F1z%PV`LQkU$3^*I9y#L2)gwVXc_^6Mha-4VA(9skM)8V#1h31- z@Rm$0e?5}SUD;T^IGDnhGKqXAlgv-DncSDl;$Jd({4Se^V2`W}GKw_)Gw69Y%w7*qCQ zR^E#hd0$qQz1ddsV^=wp%ZG!wA{)fx3L!it7sfM(!+23CoEH>@ys8k-ox?GFs2Ix! zhf}zFIG(>LWbmzQI^QW|a$h-%ALY{cT|ScsN)O+#!a?@QA7;OzB1hzvIHI7yQRO3? zlskd?5f!u))zCkxg4vN1n8>MPuY3~QBbvC&spF!okH4}3KJt1595EpLhygLj3=tkP zCPmSlY$a>Nislq3*&$K3K%(G8xttBn@~*TUaiHypGd=QN^vb(2DCf?MqBrwLyqS~p zX6Z-}n@5A#JsQTYTrgLaLU=+hoLlnIJa;UH+j2r)JsQL7@=3fYm&E%=6ZlvufxB|4 zd?A;@cM7T8lTYJ&#aw<>%;b-w`TTLD2!TNHFj9&NNGmHKqaw#K6(v*^l{uzxoHNIi z(N;W#)(Lg=6jd-+QN#A=8Jrcga6h4oi?S|WD%u1nYZGx?j~HcRgepcPsTh%1dBU9mMQ;XAcrvQs#iD`_^T&f(IvULO z(NH!|1aeL(h^xwBTvrzI^zkrmD++l|C4t*YNxZ3)$h(RO+)<9_uSZk)Tq%jqj-~PC z@f_|Q&*q+THV;&C_(LI^eCaHdqrnR-=M8jd^DaKedJ6>qwgeHc>qWJtw>F|{D(Rf1SJ9>B7SADc=cY%2$G zK`op|kB9PvS~xdUW4V1)$nE2?yrL4%n@3}LM>&BHkEimnQVMsK()da#i?2_l@r`l@ zKPicMaIBE~s)hVpwGe@nx;#?H<&Zt5fQ-5VO2?H^Jf(*EvE!UMu8#IGHS~^aV0hvr zCaUV#9n;7Dqz-N>2DqOz#^<;$!RkhY9XBUj-Gqdb7Q`R7B30D}(Mfx9RP8A|W{2dI zJ!PjHC_m;x^KlPa)ZOVg?o0m(ANo{%7*Y*lQpKCeQ$Z}M1+b^mmyRd%+VObaP)X$7V`;p5DwR);r|{XyJib)P;oFm9?x~3R z^<)nBPZaYnmHdZ=kFrnw1Ttz0$f&7tRP_X^>ZefEP(xGm6q={C&{jQz-f1n&RrIjd z(8F5Q2=`M4xT_fBp=pGlstG|VMns>sB1+wwSXC>MPg@{TwIlbmJ%y(nkf^&*b<&v{ z6*sC?y=Xq=NvEnWJ?b6|o%CW@)tgE6U=~jLv!oHsib^=!r^7jaGKO;}qq(LY#Z}FC zo={2T=~Kx(ub#|HCq=xZn!+2WQhEENi1*cU_*gBQPc`!RMx%gl)e5+$mdnqYVjif9 z`BSwVf#BpZ_G&6~SVM(FTE|e*R6$Wo1yxNA)K8v9M^giBO)d1aG%-1&iLIs~&T57@ zY8v6JX^w}cF+Lhb1ZWu(qh&>`racMjwj`glA@#HqxtgxzXt`3L=18%Y2i2!NsZsZ# z>9j8$T3&Ri2QYjlknxj#%xDHOqaMh@sc@E0g|nd+&F+~n&T9#I^mGi5Y6^KwBbl40 zlX+G>nU^&adGkyP?`UOnM=PE8PG$1x=`21!ox``MviXNr9`{cb@RNEzf2bAnZ}kcU z0u4p>>L??la}0U);~dpKjjEO=YMSb3XrJPYh8B8fbTHD;Y@`XkY-{}@|?{p#ePZ#pr=_3B6Tgn67Vg&m%k8|Mk zF=WoDaZpE%W7_Jd=$=AdN1c;e8ffZhVW_W*;Tc`5G!3xVHo{5E74!3) z7sjAg5HqJkm^~fJqFyBHno(@&N3wM~mR0Vfj`$R;;w!n_jC&QL9c|L^@@3*S;?PT4^LW48G+$(WOdXytgV8g zz9vcrnyBiX=8Vp1PV4EQtF48hp)N*-23Tq9UF7}gDEMlX_i zy(s1lBUv(xWJ@=OO|3W{(GhapFrI6M3EVOgaZ4+m=k(KgK`Voojq-RyC!hDVbNN80 zkh@0t+|?@L@7jgj(<^FXJD2gX(W*Q5%;UQ-pMOw^DuI>iwKb(D>@P&U)&gqZ>Ardnti>!NRJ zh=Hjw#%4yCo0?-|Zi1_c1@7kN_?y`gXlg@7A^bt1sro=8&%Vh!C8TDp*Iq77iJ}VZCcF_=9T}&vDZ?U zL-u+cverVuQir4Vx~Q4!anjNh4Qn%uEUhp#x5CKQ1`B%|9BdtMuye%C(wP8TJA%#K z2)1w_+}wo(J5NORzNA=slWFHqwzV%3%V0`vLnyHhrqUveX4?=N?StvEkEYi)h5_4H zCTwGvHjiP^PQ;RJJgYWItXZe9Yn8+mixjTgk3|JXJm5ZG!VZL7;c2W<}7>Z5FD zjFPoJ$E{7!ur);2+7fLWbM$O2u(Gwn!rBHW2M6389q_Pq!q?WBAS(|-tel9pb|t~a z6QQFUB6}aw9fA;B`;%uCK(T`#rB;E|TZYhN6+)wpkanwZ`fcLrwU1)ZKAs8dL>BCm zS+a^}*(QxO$8>fbvp8>)%5}?hu2|)9%Q2IimSUc<&gEtMd|tOI;x(HR-nA^|6U$=m z+Sl-vRV`oIRq?%jJwMsi@T*N7|8i{NUpCDT3+o_dr;C)WA#!#)$lDm8;%vq#J2Or> zTB2)bilK`wdNvjqIapw2PJU$A z`jYJ&K%Px71rA}9*o9GJ8%?uQG);D~wA(%`oIt-zB4f76j5@|MZ=1-nQz}a?8SFS^ zu;ZM;Ij3x{*ktp#OD2yy=X29Ohv!}LdDXFqHyw(3$Dy3}?2Gxxsg%2p)qL(y%UAXd zd}mw7eVYb;v8m;OV*~$o=tQv3P6vUrAqSmwP;fFt(as1}S2I-Yj8JzmM$^>{U3*K+ zT&*y2w8Pfb0Xrvq9G#qSb8y7d#TkD)55io$33u}(+Qo-hw?I-{A1;JEr(ggji%K_NUv)&L#}a5I3+Xckixvv!^#<)bxmW(F@+0`xm<9{T|@^0wp&~R9%fZ^jjHSjS zng-7}T3wSEa8IDuS;UBA3KO2mOgg8r?2*ZaQwlpSd0ceO;F6P=%Wh(Bxa9Jbb1~1j zhD` zU3~F%_aM;2n`q}iBE24hLrM1zK;#lmhD$iPZlQ=hL#gtJrqU&jM%Ne`ofGNw64K+I z!mxV+)gx_?j79s?&4S1ZvN}lgJ7?lF$cU&+3#hF ztd|9f9_AePG3A7hE$W`OXn8uI>uryrhbxvoE|~keVe93Jo4YffzCHwadJ*XDOR%3W zp>Dy%dH5sp^&{CUm{gBovc1D8@QI<&BZ5lzXe!(jsP&1Z-d#wmUo5@8NsPFsG3uSk zs9z?tZduHFWwY#)&8AN-Tiykn_sr#zPce^qm2%5d!gF3_JmXi%^S%|_@vi0_uWH`+ zY2+iX8b0@_=X0-GzVdA4-ov?d^0Rw8zxj3YACGPX0$+Uuo<_)cm~hzF9K`@@RK2ZH z@wY?6+a4`12lV{yG4pc3)YAbA4^Qm8+;H}H#lzQ=fB+u?{rw5^2_nSDpE$1|gkIq! zc|?-t9ZZ%_3=;on3jL$0@QI?{H^0z?7+Xz{2V-ES)ax~BuC2wm^_}g>J&kk)r zTl9k+F%5RX!p|MMKzAH{-Ej)={vX)KCxAd-e**nNi0}?2Dkzwwz;F_SBT4lYk{u93 zk&lpKzeGyBlc@EMr8X#zHopWq{4?nEO=U1JjbWd3#=Nsw@D{Tekjtu1A#1^6HhuGX z#J7+~{EN9BRKepx#XRX<%Jcr!{3WQC*ZnHF6VSxlLAAW^-M~ja&3qQr!QX?L_&%tU zpM84y(Z7S=gZug2dkDdvP!j~crbq``Bj;z2Qm8G*0v%8bw&P5I6WT$}=mj}p=;wr$ zpEq`XzSso$;uPeGr@tQoenABK1rgyJOju|L!jK3O{X$6yjvzHqNLFAZdA_lT{e(#T zlBxAepe{IpCf`(AeUs@9%4Q%igK@uHrUSB=^vz|}FOOya0@ebG*$pXX*RPa|ex+Os ztl$a%Ql9p&0tU^DLqxAR$G8+U!%`FlVM-}`p(yk)888^a8c`mq~ zXF?izF}#*nLmIdfSj#(s5790BHK3g@B0Bjdpq1}KI`}@YkKZD?`8}eS|Aq`A5QLbs zH^hbmAy&u)+i*148r4WwR3aSF40c5;#1-9e7feHau?%;^E-V1+r~n+Id~u5k#3MYE z;IIfnL!*d@3@1J$irC0lMA1=Xh9@A7N+2&pNKu4{;*eD8!_uhJxzVILKMOL!Oop#cO5BMFHNCMsM= zY-lX;;X+cw5=aY;MjRGLenc|jutX%G$&^QlsE^E`RhU6bcrLwBxr{_*F&0(CbaXCL z;bP{5#heY3un{9+C!&Ijk>y;8DC3E!dTxf+^ITXh&&D+Km#`*Y61MPqSSKGuw(!@8 zPW~Fz%jZ#jd@JncUQ7?)3w!w~x|jQ*1N=K|5`iGj4(T{+q@x{?k8|N@ycQ~{F_1s$f;};|>=D{aZ$uZ#Sj~lNSZJiQH+R;_*4qx zGAK>Vpd>n->V$OaW7BAf%BDLmpN{BUhN2}*B#0S}&Sx^Qh(%!~OL66FM%Sqxqr?H)U9@ouR34{D2c945< zgFJ{I=9WbNOIvotOGJaM~;eIP>OZoRH7?qVm;9m`d}pV$1ph%tGEy> zdxCFA}GRP67QI?QJadI{l zNjWsfWzv+CPkVeJ191}i;`14dEo3ylfZ4=KRuhX^imzlxRK-q0C70srxRzMQwYXYt zCN%S0d>b#swenJY6R(O|c}LX2y9r%u58{XTHGZ5w z;-?;h9oQ4+z@Y>;2gRoBt!!bD=`_xce z62tIMjr|`uFg1>d#8?s%lMyA0NJ>f~Gd`W%gcKyAY)VC`lqF|Tlafz!LME-E0$LM` z=uRl4M^wN_QW+C*Ma-vEGM`+*a&j4Gld9QDu4Om1noEgIJSwW?>G%eoNp9gUiOsy2 z)XvMIZeA00^G<3X?~A&)8{fwlNh5rjJi4`*Siik^3CO$QZ zgw%Ahk~7K8N=KZYMM-KVB^kNYrsUC-nM0GPh}P61dNK>>$tYtat%Q-(Ql>J>SxBp9 zEwz#D)LOPOYB-n)C6@DS5gBLS;c`3DzJ6YX)kU7N1Swq~-7~o6M z2w$X+b1!3pf25D{Tk1T&Wli$mta$``bKH^6_Tq4^53<=_C}n!0lIDX-P9PdNf#_xj zVU!bsepWChIgwapN8pqli9==#ZmF^OWGCUDDkLN=maw#BqBGJFWv7sqlSO7`2Dw=o z2Sq-dY)U%#e!){J3 z7cyG7mfgnnv^E}3ZRb{M2QQ>`aXYJv*E73#J*$ro(+Bx5dzgE0^_(WoXEyRkW(SvZI(R&< zjhh+0Jd@S_KkSQneY}x3$XnS%yq7n|2YG{hnlr{1;xX=K&+tvw4EHkT`8jie-}9FF zJ%1U2AlD1&951Bv{W+W)fMP)aC-Qtz%?m?4Ck(BeFtqc6G0h3Xs2~#ayg01Ii8$xQ z;8q~SFFT&V`~*VsMMPvJ5toxqd|o<9g?XeDW|NgCCO1D9ac&`Hg+3eIWKHsM_84F1jq^>`BwuGw@sGS|ekqvaLEaMo&7ViGFV78` zq5uvR`*9>M5V^cil#2p6nIC|fI24V7aCGv7m=uR$njei_ZVb)^iMZwo@hDEfqd1v> zq9j5jA|l1<2n$n*&CMVoKb!R8EHWfH;oSyDo^xSWQfQd$a1=*+LAJHL{Vq8diTjf{!wSS)E~ zA-|cG!Y0m&yV#L*vs=*4)#6T`DD38m(m|ds9^jeces1Rv@k-$&@03jNmUx1XN@w|` zWRAZTFY-m<6kkgg`6+*ipG#JFP`b+R#cK!zrM~Ph4&*>d0P>}NC>4jGS`o~#(hxLD zglLsTVN?-`eqjt&WzpD`Bw|+~!nrgBmogC^WhsP|r4uO0B)lk#$np&0C1TPFbIB~p zC966{E$Cj26{1Szga0uB3x&6@6SS>gTc2KAw~e^FqlmFBcB;hGdx6%cuFUc#;pw=lQgFj=xD3xmz~R z-%IEDsdW8+b$>5i=0U{MnbJ{iS55Mlig8{oo#Nff8Qw0QQSoBMi&?`;GyfhZ8(gdt4 z(y^~d!?`pY&zdxR%X0`Q%ORvJpXjoDgylIT)D)6cQ%rVcDMjT)6xLQyTwYFnMHvk> zTv`InTX{Wqv8&;K$lcey`r-U)7rk z_SFXcxfkoqh_YjTOKFCw}sm-wn8(&`IItt%#@vIKE$8RF_Ps;bJUEw7}mwt?olIy!1= z>8xv{x4wn(x@N|!+L^9tWu?58mHIxmYkN6Y-_7~@0WQ}K^F;LkH!6pDx@Meb>&CcU zH_2<2Q@mBZ$cL4)d|a{2->Me*tbUy@$~XA7<}5!}Zu3+57WZqm_R>}d?* zP;E3vnqp9Fh~QX*5cNhO>Q%9vsS%;mn1n%18fNtw*j1%qTc3(cT^63T>G;*<;a8tc zV0|{>4Pv5d@=2&IB%x74T3rR%Rh5XVD=4Wiqqw?)nuZ2y>YHe4XrQ&GfzIkC2I^WE zX=-J>u7$apHf9?-SgjdgqiKN6>OL;i4e)4PKaVyHbE9dLr|YJ<-8jR`wd1^0H_2PI zi@aMi&j*dmd|tK2mkq0YQ?tqUjjQ}nzrp>Q4Ib2N@n`)-1bgZt*wYlj!KO%L8idHz zM{%?vhT{!!s5K{`+ayA_CJnu&46N!hv8YMGu_*=Drd-^bvhixjBCx)Qz=i^%8jFZ( zDI%$Xf7qMsf>b}ddh05scvkdwzh%Bnnv2|Tj*|VWTdf;p_U$|n>v}R z?Psy3pVhi<*6RAW*f`3$mSL{djB~AFgr^$Exm7dEi;Yvf(m2J-wF|u7GS9p93;eZq zozI&V`KorEyLFrVP_xPpE$8{AVVeid7x-7>MFe}>LfO|G#-XNI4%Nq@Tpy2Os}QvY z5vLkr(QZvZuO$h+=1k06)39sJ!l5MxhxQEI8guce7vs}VNLXtzArA{TNr-JMBBi;4 ztfo@3n#w3@FQ=fT9!YaG6%DmCwbjzz-b`~#69a86^fz`h(%i;OLno7sgRD09v06XK zX6qp58wYu$euQiFQ(S8rjG~#EpVrGg%4U6`LunNuiDo6 zs&$8M83QiZss7D-1v z;?4%jI%}wGs-v;Jk;bNGTHBlHYieV-wVlECcE(#enQQH4si~jEwgJ{U#@T5b=6ur# z*W1Uq(LTx3T@yUjKE<=m)4b5O%Me8~4b)M&^&U5^; zZHM2w&hekNs|W<0(d=uF;9yr2hg%a-?1<%PPZG!4<2l)rh;~~h+O3(Gv}a<}nTADI z4vsxpICSLU-dTiaTOqz}62jYyiEJ$;zO|CL))JCBYRG6SC%e6YypAf$TAL_utD~l+ znWoMTntM9w?Cznrr<ouZ*(m3dd~)LcWm-O#|9s@ta7(~o4>d3@b|V&?)N;xPdyj7-*TDX z+jkL2^$L;ch~Z#oJPPeXj`qcJv^|XzovEDaNJFDjgm!N>#vN&xbc(TQ&%(Atj9Y&$ zu3bg=wigi6Uru0ODKR|~qS{MI=&2;Js~%Bz4Y}>ri2EBT>}f#K*+6YqC(RuX*Gg-D zD_wmZ4E78%*4xiae?JQyy)1MMveGloM#nH0+ef(4H^rsCNv?Iya-(mS=Q?J1zH^S3 zx)*u5cY!zC&vK_{g}?T0@=@mopLbp0+m3Di(Y3?(9T)hydzbqiSNWsw^25Rj?C*^ift?a9HVy9n>T0s^|r@b9c7 zyjMa*PYnr!WyB8Fk=S2JT30Q3J&hFfHzFBqprogR+Wt1`ds}Gi>7jj~oxXv7db_$A zALwDav!D5{Ayx;5S?rr)ZD5q0zELi8PI0+wmM6NFc&dAbCwo_Uv44SAJD0dKc$U}u zH+a8ulaD)hxZ8P_uX{H6ZeWM+I(PX;_b$J6U*wOjOZ?e$34vfZn!SBVNDaj!*B_7a zPzox&X{ZmTqcM_+PEQv4y}6iwgEWvNMjKCoYAp<2u4ptD; zTT9Y#Bcg#CG6x&T8)~MgubJY$4oZ94sP5~adZ3f$;chy5y67M2qko`}@xcMc`v#dE z9%iL?l(WM#Z1&EuJ3Py!zG)sESmH+SGB<~oxH+=Uivw%C(!0c+o(*0b+~U37b9~af z#otCY`Ml>6-}Rs8>!C;ZrRNg=9J<2&fyeoy_bP%tLy1TaByngY0foUNlt)rf9?as{ zKq_iuS!fK3G3Xa#G@OIkh#2$XBJ2k9aqgGkF;Ik0Um5;mRRr|a5;;^u^k^ONLk%Pi zw~#$pPyS#FlCc&fqwPpWTB#lBrLn)0`k^k`M|Sb_Xkde_rM*ByZ92{Y8Y?9^C zaW)3$xHvM;`MxakgMi?ELWM*QVrO8Q_CgwOhGRf}H z9OouhxH7TK_2G4%o><`4_*q^UTIH3&HSP><@W$9S?+-;iuh2Ms*^2g*;2n3_?>={dD|7Z$wqvY{G6kJ_moTE=_+2ksjkXLx#&iIGVrrl*)0n`UKtnzLh5>`czGJ2lUxkrl3vEpTmo zg~z5hd3yLPwNfA<0BIIY&P@2g=buyciV_BS@E2x2G$$n}l251-? zp?P9}*10iy$H(ZI8ewo|lCha-=BLJ49GhWrc8-n7S#~Ct*qvD6^6Uan%$()s)E3Xq zZgYEPik$Wa*P+su$rsFajqJdnKGQ` zs_>kzCwQibz{xs7W}1kZZy{->iKNL6G8Ws&UudIXv5&H;PO9ensF>=ZcB+s1`EfcX zN9bIfpnqYEk=apZ7H625TVQp1j-}aU)+Sfjo?YYo!V(u3Hh6SqjVBhjd1~$)&n#^4 zm-$^@o!;T?sq=g|d6AE%ukp#`b-tRq#H*M zVUpqH8Doa4orU0zzg z%sX?Jd2i_vKAgMANAp+seC|5mE?(uk<;VDO;X1#}-QfQ64IV5#gFvv9gVbs^`xi2i zpU**lA%_!d`KYc*&|J<(Z>1QWg-XokD=?X_z;?b8$E9+d=Ns{wt07>miI9Z`!j~I} zSZX9@rH$l;4ie|O$yjVBZ?&78#X*V|d#GF*q-JrHy2WAIR>o*qouF@VlKzzm1{bFo zS(#>fb&=KidDfOzSzlP4n z<}dT^>Z5$R`Z#}EeVi}n9_QZD4SraDnt!g`;*W(}{CDLhf<4Q*>|4*_z(xTFm-10u zEad279;eoeIJqD}d$AJTr4kI*DzRLz#(t$5=k;n_R_kzEuE&45j-bURBGwv-T5KnF zwTJlSUQ#zYN!#cobG;w&#xP||15_>yQ?)ut-O>n68xyoFkI}I>&B)pmBkPNdEiN*> zJkRXPI;$(otSzo`acPYU3un2sa-Qq!+dQ>?p63=H;r9APZm(S7rIic3yLgp%Hm>l= z;uStwc#^Lc9^>2P8+^U;1m7<_$xka!^XtYf{=0D#fnYt0Jz(| zIkr-U`bs%xRw~h5DMxRu6yvQrtd{GrKU<6YMiU;Zt$3_8eq&7*c_)}eU|p+8TwZz>0Mi3Vs)0WttA%L7Fbx` zU}@tln`bxK-Q3~g<~c5JZE$t{0#7cVj^m9iO+f`5$oNAueK7m(@*l|Afk;P zvNs0FTOB~MHcs*85XI|5RBcbwusK7^#sqCUi}Y_zF}S+O=-L7!JIgGtFR-|^!pin4 zTU%S~uCH@>YnzMPJ6vDA$Ybj}+}yd!3uiC!;^t*u-?+?cXRq+~&UN11ev*&2p5V*P z8{9qnG+(dZ;>WdHJXpWQpJ!j<-|H_S*n2JysjXZNo-5$+b^%If3psY7h*KL?sGhAt z`$7#m=WEd0sKR)s5sS?xoG&!sveAI|g+~0(w&AngM(|EMVdpy$ZuB7B8X)Py2$|b` zWS$!&cW0R53nNr*jZ?QXO~ckW4d-TP-I}6zW1gO^MaIu9GkI=}>9fl$Zfvu-xz6gw zHs{XmaB=4%mo98`ZF`5Ow=eMA*3FmX|cyD*$ztu&=#XiE$cN4SQMf~Onsk_67cKXS@ zFi6hUFeN)vNOotaIX6wyxhWcVXK2}+rT6?IgXb3+xwyd8xm9M*tuuRWm8I=1Hg>kS zuzjB0tqWY)yucINyWBc|jc3na=lRXYdFA|Lyng;FZ*Je<&bg=gVCNY=+kBSKx1Zz7 zt>^jr;xqi?+--i`y3Oy~ukicsYX}7ABplc+K=xu82QNuD_DBUME|+lXkupy2)}nK% z27~iWn4E9KY_}Qf^NrX&(uVWp7Cd)4@ZIepb`uZOsV|Q?*kq!P zJyxAgx8dY*>Tv25#mPWGZYT(pLZS4g7xYe>q|JRcM{_sL(KKnB^hl5NNE_Ow2jJ&< z;Sb+G;Qji1&#+UN&*9p9zNng~tEv2NkoFZX074_BBR#zgdszy>LLQ>m^ zMkt}Vp$SWslopW$dxH#DwG>a4mSCNpo+>>9LLGh8dPb{_j8qv&)i{{1vN0#R43=!< zs)jGQR`NO5ivP>?wO_JS^fh-hY~?obx7=N`jR$MDvb<&|%jWbJc zE@4+)DTf=%IZ|83k@^xcYf8wLRFT(E$yrGimBJb-g|*bx)=^(0K`w4YRxd#(5u+EY zFp1T;Yh?I@N`gWKokA6%1|u7ztgb)EEA{ET zE;`B^4afMK@EGq%PVt`j6ze3Jd{}>)PsF)=-gufXrTP3vQpk2;F59Gq91xXqL{vnE z53z z4*6#8lx^kSrtLhe_?8F6dwE2@k0%-rutL6u}2qEdD?7O}6XoD5kNnTkp><>lmy zswk9JQYfyaLR?3cqye!=f>0zu*(5Ze)>kBa4+=`KfXXOJtk5wQ(D_HEm;=Y8$_l z?PIxgCyz+ed9vvMD^%&MXiR6NES(pm$9PeZNtz^sKdZ8Mv+)$~O0rqkn9F)u7V9PX ze5@?wQ&m1+$Edg342s!>~E@MpQ3`pvMMs<)tr=7ks}jQ(pXPnV=ZO!dTOMN zG{~C}Hpx*+WGI?c=;T_Om1^uwdfXBfE~OezQ!`yEE1ilKqAEKvm6IWbm4wpDgwn~h zqLl@e2Z2ETB|nmH;%d#;TrbUAJgM5xlbZcJ zqdm;?@}sO$Wbm@)2!Bu>CQW*hG<6n#Rc5hHeVX^xS^S?ikM+tN{-r783t1jt$;#NG zC}gv?h`sU>_R1?bs43&1x{~ASYBFV26sW5x(MmX@mQX5{P@!p}Rv|;GRUlEx&`A~O zrCRhV9d<=C9)%viR)vk8}lmq{!tfMIm47&ahQq!WKm_d(#~d2Ku2tGo!VFl^z+y3PE=_+PHoZ{ud&H!M-_ zW=Zp2?osXHZsR^4)b8N{%|0GiAK@wWL7q`%vO;r+RfgldpvmABVU@&6W>Tv2zxQs1y=&S@)HlivA0~$Aj zs#ZpHF2+GOEYl*2mxY2Gsw^MTYtA;$%EZMx(lFK`mBK~12WSu3SPxZxoYAok7Ln#{#=h)P8 zmd(~`cAG0nH`H*@+Q4Cb4Oz{#oHR6%XK0|%*hG;*!8v0SH3k*+1|^MF4Pt#WDy!}X ztWn>Bxy69PY{k=T!Dn(4)O&~+-9#)7`V3CS&2C2Y0VYlDq?$vdn*9iVWd4S$>^u00 zc@H<(cXOk07dILYaJzLs_gMCGU-Nf7WJ~7(<3S!b9pf3x34UWc&U5CItT3KtwI!RE zZCRwX7W&FoSDY z4IFHiaKtR+L`wswtc?^j%Q$P2Q)$ysV^<=ws1VzmX|(E)n+#};dMsuuR=W{bivw4) zn}E?q(9}xA?4sM^W4O7UQHzg+-OEHvm}zr}1xpu#E6v-vrsZ3%w(RFx%N}lY?cqj8 zI=8eO=1$9D?sFaBVb@_Ewq^2!Xv1mU7;Amhh3agbl7LHd@Z|rK^hn+AG*%u3}G1Eqg4r9JER}*dijsCL-G+;*>*9 ziBm?2OG~9o#W|aSdaIs#n;NmhfYRBFw#9_m(Spt5#BO)vv)T#R+URKU&}nrOv$xW3 z3o__zXWSWL+S0*-E64?F5P`t9lPg@`a+U1>KWp8^jn=)~>`Z5A>mhD;rL)X^gkLx^ zdBk>vN1aD`qBWDJTXT5MnZxf|PqNCB!EOrEhfVyBF z)r!hvM(46)cG|Fc?YLWgc-=k%9uE;uE3wuPz20^P+5#j!VJ2K%%(}W*a7PgcTK92P z>s}VO?Pan15ZAj8u*7|cCB6*qb{*lDz7zZ+aGYgr$N7~znig?vq$Q!LCyzMLJT~8(N`$|~vtK?&UEgyMn`7F@D22VBHydt*yMC@=&*w-fF zuvf;hRwYOMGLCx{m8L zkB7E44}N!$Zht#H-VXYG9Spm=Ncy`t@9k#R-NSZ#@}A*u z!BW8fajnzlN`ZLN@!wZ1y*@J0NFIP{jT=IY&H7vix#RdKBaY z^%Mj&6b1~O^_Zy&nyB*_X>4sl95kZ{I5B!%=z>mcUN0`M2Uoj?wst=upO2_FOrJl@ zK%kqEK$JwFlSyw6bHNz1o*05F0(-bBaDYYrbgl~>;%4tbZVVsi4qpbh2ebHP_$13h znJf#RX1VVqPy6zCsw0Qr`SMxm%j4xx0WSrLc-4P~G;c9)wU_Zudl~QhYk1#V$)~B;WYQV=pz9BiSYy_t$&3k{(b8rq%6+8t;*TG0kv7(3fA zb@;J{y|_Dqv<3VG!a+jq5&FBr^o6?_3U`ud?;_bA<$PC+R7XDoLGUQoM31tl^Dv8o zhq&P~w(AskbYyTx*J+kTvUwny&BM`b9_=pRSCImqi?p}3SR4~ zWNo;dzjoHKE?mb49kqNC7V=q8!e<>qzU~yUDI(#UjwW`7r0fn$*&o$&B&6e5NX6-> zk=!m5XQC#GyRDRW*r|#*sOh#N3EOGxaG(kMFm`ycbbE1xz1YJ6+)*Ea4nJL8Av(Jw z^mla9AB{5F(M=-UPbwT`D%eXZJcK|HPG?a#gKHv(xGr++2k_Ex7E3}|{7>(3?&`_o z!B9RAMGJW>oX@X9#jNNl^VzXZw+sT&+>kaxSPdB3NY55f(6 z9BJTR;d(Yk4X^(@t zt`_Q}4kRHL;)okvPb-F~2SYf3BhrpF+>Wz5Kp-3@9PK31*-a$cM=TO$psSCG-ae)~ z`#9e-%xv!RB5R@m_x|pG2GZq+iMxJxzSs zBV}`!j4i!NcJ|9T5Yv+$QS)6y$FUw0IbBvxMO(=2HBugNQr2sss^39fw+Bg=m!_B> zO@AAPeht`iS=pkc-vsyQ~zmh8OY-HNDjBf^0+^Gn)@QTJQ~gA(ZM1f?=9x(o?=!+%J_Y6 z39BPzyfRqDpQ2T~)?dTgzG~L?i&+;F@_z$OtdC0BFxbS#UIm+ar2J=4%l2L+d;1im z52!fMr{~asf#dx~PWM>I>$6kPXQ5=!!MPq6H8DH2F(-|KE)+2zvVm4~u^@(l4s4MS zo@fY9Z-$b|ODvyT2Mf7(FpqnN@_BHekVj($JkeXovjfFE7eC92cqz}vD|u<~9BYPZ_~TF= zuMXGqcC3zf`)hf3P{Kb4<@|F%%11E?8)J=p(W~K`Avrtyv}_yJv#Vdtfj%<_2drca zo5&h4QrPR@%#ef9UOVT8J)9eIQZwvC*ysPDusR+>9rs}v2;u0B;OXteKiGqBxRbze zl%9AWvA!YV!@Z0R4>B?^N-{RY{NNZDh7t$_u`GTxkj1qF*<3%I#VzrZEEz3eNi2`s zM{>Atuz+90idY^i;>p+3Ah8hHF?cT+WN5b-XlO$E#x^-iS;1YrKxX4TyMW zNW}XijeHcB@X4T*4Fg&>46FENM8oE|hHv9K(&Gm9#|<1Evyl-ukrQhnci2YJP%EV| z7iA+Zsz*K4jI`4<6hJW$LOv8iKNP|+&`HZs7|(DoZG(NZ4fYTo?WH$9L~L|`_(+`5 z;TXxG5hmi}OpT25{optP!T52miDz@&NH&YcviaHIDQ+3f;kNN&?u?(|-gpuB#Y zRL=6@GM*VNW5vi>R*qJ(YOsPeiAvTC*YU?u34b1vuy(kKzmJLddtAXk$7HOJH?m(9bCMf(P&{hqY{Eh5ke72KZmLJU zG>&`PxHnP8{UaqjI8n@_<0UK~t>CG#a-L1p z^4swmRwrv&lMsgLIGgGmso6K03s3VwAD5QKlx&voJA5Dv?AW7(KBLjf+?{DdFX$ zgjc4dygDV}%|sJ#Cl$P#l=9(}luwg-HjHZcY(mA?=M8Kcv#@j0%(qD^yAmxN9Ch;D zxSgyi8=0ea^2XXIPIxIwv{N=7pk~TX{Zt6yXcywC4m6`tv=d=kk`c@kQS6gZeCK=d zBx7_<#R*Lg5gi{THZjh~M1sV`B+1DH=M&S+PE8>YOy_XbbRpMG=dgIDn4eGNa^rLn zcP7jDpHwM#rz*LBvWy2N&$4{7l3z^=d3sXFvorOqNY=4BDdvAC#JoNsBP}W7t%Q`d z(=y(jRI`3k%O?p9pHJ)f^t_r4lNPp47}-2yPWNL;^kbhH#5L1T+fWPjZCznL+%?VM3`P`ll!9PmM90nkF%sAd#G8 zYIcrPa`tlJe6F0&=emU=u1}S4)BG8JK68#GGbP-WDrMPBCCjF2d1$_xM^lwNxgg@# zGa^<_*YfiAZOjQihnE^_{Wrvf6klubf%dNDI*)ETKLa} z7Pd~C*gfT7@2r*dX$ME=TRE0;ae5&@!JL=k=^&+39hA%ms9p$CJ0GHcwu`2@K9uwQ zXf6z*OZ8%!8N@w3gnKGZ@WKe;nF&HuWAx39(mOxJ*whq>g(ToTI=eTF?EDv0&;=!339-FV>nHdSsTokeLqLANT zkg)oqoV1H7(k>`TyQt)il!~<%wY)#CSrUYsG3N;0*uz|^G$F3zVATzMgntLO7sbfJL7 zoa1K~in-}x3AcV<&0SP*|9mC)&kI>LSH-V2j-E+l?f tWNw;%k_^nxF*!fOG%02-eotxv{vVst%N>$Zv=smV002ovPDHLkV1jp7Hqih8 literal 0 HcmV?d00001 diff --git a/core/artwork/thumbhash/testdata/solid.png b/core/artwork/thumbhash/testdata/solid.png new file mode 100644 index 0000000000000000000000000000000000000000..3c53a36bfbe8af789592aece32ddcedddfdf3899 GIT binary patch literal 209 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7TyC=Gdx`!Ln`LHy8H|lFW*9Gq_ueaxSKK5{((FB) zq!~>!n$e_gMw6yVoMtt%O~3EwIG^W-d+s0bI`4a~Kp=2og}@CjHvygk0bT+(yair_ z2)qb(_a#{1MW~w}aRM*m1cAi5`jFxpLXt};>8=r^3IfS>4<<_xNs&t=dG0Y3xW^(E z#8B!MMVWgdm4Zanf)r{6iPQ_y(Fszhb4kY_$fC(rh(VBnNsx)vC7(_~E;d0PR`+}y zZepB*A_iSc84;AS%C(qrfrK$v2@`H5tQW|cb}MH_AZM$46`NhvY<5+!Q&7Wp_ZoJ& zD>)!gv0qTn5kV~n1&y3^ZQ!Vjj*|idx4M`(=dS0RtBETDGj|GFxa!u6n0dh6&O?H3o)mQPtgD@;1>L+T=;b9r4==hpdDX?qn{I==;p*hif>pdN7~*e& zK|XLF<^#78J`}9spMo`f=Cby;{iR?7H(Vz9(PftJT{rTx`xHM5=J`di5rNwZ0fH3* zTvoW@;pT;x!27rH6|5xC)rT;5UqS@|gt-M1<>5nuAegun!6bS_AQXg>=^jj;TNIhD zp@;<$6bqs#aZ5lfNJK73pvpZSnR_agf+T8JB%^W@q7|f|7o?$gOQmH+Hmx2RviFZThD(58~8~u z&wmAT2;AHSxUX=*bA=n;UY>Y)d*kcvOOTr%e(rt*y7?0A=}VlK4{>h3Bzpyr>=sU< zM?afrR5DDg~0>X}U0iWtgWlBjTtr^Y=6je8Qcp6S$iq@i(5 zr+GyNI`>SPy|OWQ){y`q=r z-Fo@sihf@C|I2v8b&$7KjPbVTFzgsRdVAuv@+OKu{+@wEdixNy z(vJwQK;k_@Nmv<5^2%^hy@E*h3L$Mp3>j`wWO+tW;2ufA%4kYFYjqq zGZp0uAsY8oR6atqo+7j>b7=C+z~r5Y(JLQ|R{>TxF>P*nbi3u#=~Ik-MKKQVVg|g+ z7+6`%(8^L)uP9~Gvw{si6->HUGUZ*)2Jb3n-IOe>tYz6l$+CMLOK#QdbgN;nhmQST zjU0As=AcJ2C)}Di>)ps1w^q)38M)+P;fkk)+uho^&7+m8ULD-yVdEb6Ztn54@zBaH z9$wMQqi(%C7WICa=)HvKJaTC96=Cqor`;o) zR^MVeeI>Meim`f?(BoB1pMMDs|58R)${6>lWMZX^wLS{g`N~-9t!C1rl1-kqZ1GaE z*-OnfPc7R#YgzQDW#7sM_IYX9<5AB(F9S#YnmOgy!U>ON&iWX*;L*ZmPYbvCw{e?C zJ6HWIT=leb)x*wxel{NTvGS-#_iyv0cQ?;?_VD=1L7rRb;6=Y7UhrDQOP<60$#axf zy+?V~e~9<|$GGmfns+?b@R84IKJuL6eZMI_@?6X3{_FU1%E$tc*kIlSG-1kV^kVRNg`qfg-9_W>dd1 z3!S$JjZX#!-&~90T+r;a`l?zl32QDWm>nOa#gq z_f@jiU&Yi)1=HS2=DcdzxR~#}l^@Pkem6@d^$i z#K)H~-(bRg0tojFA<{39=zt)Se4p2kAz+OKShx{8k=40kWU>j%r44m@o;F50}R|0L^7Szt2AvSIg?%@vKZmtG* z^MFqu4+iz|fbRfL1vq)qXOPE3oIK+@$g@69{un&Qi-BXj?7NEBf=77Ocbs>E*YIAz zTK*cio{#+3@sZDZ-uIp59|6;R;k%J90;l-ef10m+XZUyE7Jl-d=jWg;{Oq#;!3zjn z0(}q!`rsMtjeD3s-hM%>3<@SNIGEtTU;_O^2n`A)A~1;PfM{X@qKFHLAvGkP)PQ)> z1ER?cizUZDmVEy-iu_Y43Q0yDlul__DitB=$imW4hGbLipG{pz7BwNcXu`75g=S+6 z5o7Ty#uQRaONbP6NHOg}Vyt1sIKyN(Ln;{vtYFAr&Tz1j@ennmVQSX+RWT7(!*sBQ z4T1G+4A!zWtd=c-I(CFMvdh1oeIbqP4%D$Pz`*_h1Bd-uIThN>u`n~|f?GHnV&-g+ zjSImxE(O}SBcz?XLp%9>n1icf-TXeFhX;ducqq7!Cjxr87S_*m0Ruc8=;TGe5nc{l z&5QoS{4sPDuZNEDmfslfhD>oicmvl1*YSSH1RwZM^Y@?`{t>!?e+JF*S?Dxh1kLlW z(9L`kwv}%~7x_74k)QpR_)q9I1cGo6Jc51ji15Zc%pbo%KLR2{@C^$iASi;+kVt~V z!ifnDCov+1xbP^F!lOtGN+3NXiS*!TGD73Yj*KTeEP=v^WW!B2qy%el3HD$K-N6!iBFnHxmNFPr&T!yO zj4Fo0s+b5>u`XP}hA<`TLR3tJ)i57f$Hrg{i$M)6hw9i7qGNZsfqj9^>$nl64&IPq|A;QANkPfbdb#WoGlPi&3+#O-#&PWGWgSvPy)WQ9cLp%~O z%)=r5Tnlvabl@P*MvU-6&?;Vz9OlKq)w~ip%Im>vcr$E*H^QcPJ7SXe!q)S*h;@7r zypfM1=lN&EH2(=O7EuMj4eubyOa;(S_)ub7>0CM;BU5OSFX6kRn>bB-p~s zej96)6kAj&ePJ?sf-4vdl`$4o$#7I96VXc61lKSTQNv_#E$gEjm=0}VbBLDBp-n7D zH?S0}XLm>wdxK5viZZc3+QiYY7EVN3I33l>sR#?VM748kbUT+ry0{YA%^i_eu7=%2 z>E*8QZte={=DuJj4~6ydSh(Xil&8X+JQ+U1Gtq-Q7ct5!A*=Xf#3-+aujAFwHM|_0oZ+9LoA@+*8#h80`8Hx3Uqx@@$M8jd2w&vK z@MV6DSU@0%^+yonk7t|@9#Ot{g$3Xn6HGvC2tjdSM8rf86%$2lObpRs@x+EFk`$Lj zLUb&mxMW1p$>fEllM|bYI4*_am=ubm(~vm1B>Q(iI`6D?)}dt^!Atn!&It1|!w1idHibr(!Z% z%S?0~vyo~xgz1=%(6TwandR69mf}n-MYOOx!pP3B77j+)M0IdH+{!KC z?VO71;&PmobCDffiFI%_yq`N`oZJ)P;O_8V9*FAa(Wqe_is#4Oi`mGH zh%I~-y@hYWHuGKFGCxEv@}H=k{1CIm|Du)=xI_jbNc6)!DiqJyK)jDH!6t0 z$S8s%Ly3qABQ`OLxcDd%<6=mOjUzQKj+EGV(j$`)MW>J(ol16e8u`&Fh!e9Zi%X{@ zG6z{yK8kn|s)RzStmd zFhPMMTE(hJCBq3-jK|b57GJ|;oSL=qwM-?{vMI5Fjj_#aNzk!1&cM$2W|k9LSdKEW zC&9q}C=2_e+Bp$#=5TBqXJX8pO|)}sqLs^W-CR!S;bOF%%Q5}j9^b{)o0UZN@nGT* zk3_BF;bmsm)Bash_qQVfY@m=a~Q zCzfK3F2$atz@8|lE1`m}cm;!T6^z7GF_xfVIJ$=MBn{&U8a71NFrBPnI$6hbQUhC( z^eiSfuqDC7QbG$$ab|YJHnT6jg*`DX>`St7Jh6jg@m5a9+c}$L<4k-P7ZV)Z8r#Li zSO>SqcXM~_Aa}$$xf<`};rIdWiyh|iq*0zq9_6X{F`kJZ=lPg*JRdX4A7j_>THHEb zN?yb3iR<}u(j4!_PV;umG}n`+xE?dZC((2KBW8||;}-cWW)uI4TmB8@%fw~Ai`~vo zi97i@W|5!b_V7#cUIcDwAqW!v@s10`Ej}Fov><%qBJfQJBP1o9u=r?#6XS@Ai}|gN zy-rDc$tm_cz|7R3qKh~x8+C5R}C%SD-5ggT)JRdNaS z357JINzud=p-mE_kCS6bm10bkVvd*6kyJ)!s)Ek6N}S16^d_hnj#Dv`Qp0Gxnz7Vc zR>#$|CP~M-ga&5fo7t4o%*F&0TjKRB#J8}RW@cx+h3(0v-#`wgw6ibG#)%Xw$5L$^ zO|f$(u9I8StlW~?!-YgUSJJwNiaH_G$LXZe4e_Q zPZGEBW$GedCN1!3#DiiB^XjGXi1RMl2T40T#l&%5NuK7ll(oE;ILm9PGrXNV#XCv!yqi43 zd&!&lFmVg-Cv4->m)eh}{9+r&Nml(LUs(s%P~!XX5L z)Ck;Df^ZQ=;*%MIzc7rI=}`ov$KjWeNML#_VK+yVOl(R5(a8y} z${6H#X@lIEHq2GwF!!Xc=An!+?#*1yL+N8YnX-zfGuQE4+62!DCwWe^j^~qS_*3e7 z-pHKfwe)FTPu;*h2<;@dqg&Nq**zf*~Q+>E{+O& zIhJMRY^sB^qHa!Q^m8HG!EM4`E@lpLr)Y?)LML~oj&OhW7!Rb6@j%up9uST3WZF8O z$XLzeY2!SXIl)V*)4Z55#cLTGc_Vc_Z>CQ1dfFD=6>a9N)Op^`*vfU`7CsVg;^XXX ze3re$7sBoQOSGM@)0g=wb2tA^-@*53`}jG1A3q8Y@oVZ~1cKa9+;bvu&5LG5W*pwx zu>@qq5hzL`K$uLpFqY8Vcw$8f#AOMI$xJ0VCynHcbW(*GWC?T0%FZGuBafWC9P%== zDap*EIIDoN+!D%i3y=v5QDv2)%q>GBDyA;Gg8JN2bV510j0&1_m9*!U)1IrsmQhQ4 zmV)j)4P8Pl&b%5N!deEibc|-`8P98EO;$6LIZe!n49sL3nawt^F{7EqjCPhXI@p$N zWjWi-p3DvoWOQ*L!_HA*7l-n?IG*d^R7N)^a(X$FHORRv2j_(YT*z>8DPxqoGKRUD zGsHc@G42(v;(_dSJd(ABCo|UbjA$*73nzIYW0Gexr+Hp9$E&$>yppl;w|PBlGjC zdl3loqi`36;g%bNPi_RBxrz7}MB|ekPe6VQ;rVGqh|-D6O(7;Pk!VpWaXDF}<_Zz! zWRsekOIBV6`JyaxvJ1$|&80Lq4@rIzC7DH(XBSeHQ$nSvjOuJTYEc=Q%u=)>8QMHK zO*vIqa;j;~uc1w(!jfB!J->$D0yW)v)%1xp^yStwnA^mvEG?sXTGnNoSd-Jj`ut`# z&MF?sp5%f2NgmE$%M>Yd{THy1X1wIw+;0w`qZshLZ zyPW;}kiVDj^A7N1&K`cqKgcinhY+|Hh9k%e!=or3pTY$E@}lr6OeP>F0e^8SfjKEe z6(kX!mq?^Ilf*m`Y2q{z3e!m`$R#~TL{?57`T05I<%r2EDnwFLh*VreMNtWgoHAtM zGE{|9s&mSy%_&1CmeZ77i6KXZNnB2=Sb?EHNo%g=x0BT+uBR)%n!a2e&g>?fxjI(m z=o!h@GLh5Bc%G5*96f8Z4NMjonaww|S!`xL*UCac2RjQa>@4VJZ(#>Jb8YMq_i!|) zo5Mw29LwwHWPTrK@*G?!7~xX(Ft_Claj9sG+j2&^A|B(8oC)sFALF6CwcKAY#X|*C zJRzRonWA+(E}rD+!bzUVp5o=4O}tRFiPwr|c{OhfZxt=@u6T*J^0)GS{sQmjY~%gB zU3^fuz~}k9`80ng|18+Ww>i7`I%gmM&e_d31$+2ee1IQ|j`05qP9Sj2kHEbs63>D} zR+L2HQ=EvGI0nDGRKoI;2^FUiR+vIuk&xJ;ETScuq!ne5UYtp0K^CcbSqMw=C@2zB zR8T}-p%_VC5%K~FWrZbFm6RYC%TVW+QC(O;eX)!Nu@X&@lID_XTJkC}6jjnzTt%z6 z23tuTU4=E+^Xlo7)Z;AB(pOl|P?4V1k_J}i8yGKYVO>Eh8}bay=Cv_fU}m<+%+`D> zn+h##FKJ_YK{vaKJJ})WWq(O0`wIFwChp-xo`YirL!2ud=2XE5XNyOc-EPtA(O4jjM{yH9)Oz~{tJlDi?yp+F%m-A+Lqj(Fi z7H;MBf-Sr!-p0E{3tTVQ&R_GE_)xsSKMHp8S@90O$lJviMLW15-pyAfHxUl-op?Y0 zE#A*Bg@^dH;0OX2X*dE&0`7$|xD~|UCym3qFrMI&c)|*k2`)$_xmT9qaUrE*2$SEXjLVl=iT*xRX7lJsd3U<#0(4 z$I6B{A|BzSd=;l3D_zTF$v9VvN4ZPBhO6=oJY2S(dy6M|ymXSs z<#RkGo8#%SS*}TDxmL277t1#DvV1GAmn`z9;sxH8F7Q_IGVh6Z@owpE-jnU%Bk4Xq zEZfCDqzCw1x|`3+4)LYzApa^o!ndVI_+EaTAH|3HNqT}`Wv3Ci%HnX5#^54NVr5AR zJ{3v$%2EiDCJ`V{C%iPBsPa@IC0RsEvxqOxAWoi3N?8uc(j3y{d5Fr2$d#5-Bo&h{ zFQiajMsay5@^T4^k_xIyq^L{Fsg^5HNtM)9RMJ$grny2*i(Eykqz0p`4y(M5c3Cam zQa$#HI-C`H2FjZmlA9Q-XkkQdV!X`2I*Eyi@;0W*+nAHLv#Gp;S=r6q#a4MIJLGm2 z%X-*Z(#szC0Q*XMI4J4mkYs?P(h<(ehd5V0#;s+mxF8$heEDiFl&smV2FY;%}5`QjP z;x8r3yf4|#yRz+kRJNTDWV`shbRVC|_w&!PJ^V|4m>Xrs_)&VCpQOk5L3W&=B&I5dgsNPkrP(AZ3P_a}kg3Qa zqq2ZZc`2EdV)81AkSJsnN{f+J+$^M&GI<3R(lV-LRj3sTs;iVVRMnuBD$rNdVyMz! zD%WDFXrNu*K)Xy!XJtLzie|dYn{k#kF<92ZP@6GQP~|WOtA;r$AK|oYgi{r(xkbL3^Ht+q zE?>oM<&*qQHp!i`b=*6+3xNvBaC@yLhW&nRn!S_?u!6A6M zD@DY}@`;h>kXD&Xsw|IWc|O9*0z}Fpaw;W=tHhMZi;*fSC{xKOmscW{D=Al0P+eY* zqC$bHQbny?L!(MbV|fiGSq+xT1}ySg+ErT2Djl7Q2D+4uI4YXyuWF)CX24l)VwK9s zh|>UmLH1XSa7Z!4LHR09 zl@D@KxtdeTG0rN+xFB1_MdbvS6%$;RP4YX{2JTU<BsI3%sM+$vcW={-WB;yQ;msFW=9HH+R(m{vq4X zC(5IIB|F6DRmb^Gb&_u@Pw`#lX@00Y%75f%q2e?GfjSvMMH24nbllWw_$WjKszmr! zrV*;nBD6Ax2xS)0Rhh(B8YSh`3M#9ssH#w+R%mEYYpAQNMPJi^UfqDPx(TB~LyM{rYlR-Gx|yyT16|by z94aG&3Nu5>7RFRY#uaT$)|eSrS(sLKGhN-urWz}=6`d?rce12(uvp_{yJ~>#)&1a6%<=n*johzT z&qL}d9#&8DxMCBJRc_>Q^+ukm`9F+5D3^GtavQHyE%4`>9lTb#lQ&d5c(ZCR?<@E4 zLB$^aUbTl0)Q9<8agcwikMo7%C|_2c;70W^zEd6J2h~}At~kSgR44hl<}3m?O$vhQ zM7)*hxG98qs8aB+&B9M1B&0f*kg9yb)H#G{a)_-hAVHHyno3NvI-e9}0YX(F*~(%> zwWZ`M%P7!PQc|rzT%$m$R#C35LatI#rBI^OXsA)BsMplcSX)m+bsf#hddx~a7LAtH zDkB|Ljo380|2tVK3%$xV92x@ywI+sYtc=yPvQ}kdou-?0HFh?ry4h6Q%bcQ*O{#tt zl}@&)`&h0WV25&m{Z)hPSFK`i?HEU@hdHbm;dIS7XEkd%T|LI-nhDM;r?^-%!PTk_ z+@s#W-Bla8TD6e}s-}3LYK8|?TX;ydnMbP@d7^eRPb(LBMzO$iitW6lSmq_=0nbT$%PG@Vpr}_;uC7H@Q$?-1p8DEawDoH0 z)oPkm_2|`gG}r4fs|<8hH_}$uM2}Wax5k7++lHgYh_lARsMf-8eLExScGlF{7_YT5 zbF&OpCo^?@%-8p_wXU1Z_5Ezq4zR1fpPe;B?9vQyNHxF#?P`wKjdEPOieuVwPS;Ox zp>~{e>b0ELtmjhwIxf|3;Le*Mwevinp5ejj%{)>w&x6`+Jgi#eam^x6tCx9NwZt=; zC0NO2C)YjA3(1=m3r&X)RTx+7ep%F`cGo7`~bk&;Zt#8L!+eV+Z zoe^z2gLR#ZXgV0Lv$Cen&O}2ObJ{*;)J|r!11#1Kv03e4yVk)@?Fc)yL+q*>N$R=p5o5>dG4&4PMf%;jV zsNcdPx+R{`ZsVDn?L4dA&hxbkJYT!SYxR40NwKOf#i zsX59Ay2JcKbA%f;r}JxmcJI{}GxA05DdHz#>5rJz{DlQGFcxtm* zp~=BllY>{Ih`_o60_(B}Ys?{BmrGcKgt*2ck{Tr>>q<$~i%F}OlB$s*tScw4v5M?E z8Tr}@3hT>}HdIr76GW>(saH~|tD~wxO?6!j4NbM^^$los^|UnUFxDGsZD_<+r^j4x zp{JpRE{z#SqZwztnL$k(t2B0owe76c+gPi!v$m;=37vzP#$GmRdzoo;vboX0HocP_ zO#>`94zfo-%+9)T4r<0Zq#gYY<8Z?SM;lgix^V+%HS4&waXnXV7E-r?+w14JQ$Ncc z+Bxp6+sb{7Tez=bGY{$Jc~Y~)GmTq#yly*BYZiFEZh=2E?%+>N+j&K|oi`fy^J@Km z-m5>z^~OWI({z}>HSOnp%|SlV9p+=rQ9jijz4Wev5IH>s#FYN%+eMrl-|(KS)i&`4urBU-(d z#%4XO#zu@y&9v#wSadCP=*;vsnsGF=G1y?EzroC4Qx~JUcE%ceSlwi0LxY1UT^BQ2 z2lM(t=8Z#aGmNlc9Ab*ui=n!4W|w3INrFHQ_WM{qMhcP zeumq0b6hq|bJaM{T}`vxVcg2yP20GyX$$vi=Xs=Yfu|Z5c~ZBP=Zrgf+PKU!+Fd-~ zxSiLU_wYu;F5b}X=N;{C-Zkvu9qm!x)g9q_!%?p54)Q_sDL!pD!l$~k+|VB9M#EXY zGoIp`#&i6jJ|~3nk0qUx1^oa!nnu}Y7~w$ETK4JIain>aBl;;$ zx2)k<%LYyw*K)RTii`RgE*WRItz`qZ={IxNO_b(Y?$$4GziFO_O^ZC(xP>Q8TX{ye z$hF30UN9~5jA0+on|AQBVF!QI?d7$`{k+q7gm;?u@@DgKJ}@5SBi&&>X*tCQ#uNNg zcZx4`XZT8Yl5h1V`Ob8jZ*}MRchfok({wApHlF8K{doieb2L#qD4Ylb2%AiIU;i<*{w2iOyv}`C@5@JBQa|z zHB}>Tsim^D9;KlUm7x)pzJXeU4z;O?2D2W$z7dnffYD^2t;K}JV4}m^LU*$Thq)6+ zb0>plJ3|I5qo!U~H}|r(wU=>=lMSYRrki`%sCP2eGQ_6l5f+<=Sh5VTpdV+KafId8 z)$G@gbFg&{dreauZeGg?(|S%Drnsebk_*Q5Tx^}@ym6ijh8Zqf=DA|r#y!mo{Jv!q z_qT52-j+okHg4sy<|UpoEbz2`2iGinct*dUmrQ$k$-JLeOb2_w%mt2-hu# z`K$RD@3tP{@2zL}r1>}>nU3?B`4pd;PVtrD7QQy0O>lag$flW3NZWR#KFRzY@) z9ASGE1ttXrtxAg8swilwMQqd}Gc-_cu0?5Tq_VXUjj5SBlYtsT6B>&iLyHl;QIFne z#L#BIW--xeY@ws2o!&M(z3oi<5~q2a~1&*0&EZ+cwOm zn-Hz5*=AnNqHzt|+s4@5GQsY)aSpUku&-^3BQ0w9<_Jle9zW33B3W?JIOmhC)k-ov$)9Xw~;!3*t&c)_%r zms|GnC({AmG#%zI#zVYkIL39$N&aR&%16did}uhyr-rk9ZamMY?PvMgbdm4cF7j2& zMSg0%$WN9_{A|3$&!*qqEJTF6RfI=-4lB)h_?rvxY8B&WEg--uCc-Qs)LKGxTQLdd zauUszq}mh+TNR|=+!htNHYEj?D#RTsN~~%mt@TJPYD(McklLCkZ_%M_Z${DDgtonj z23r&Q)@F>BW}2;L^fn`gb~7FA9a!4iX}5IHY3-uh(t*>~i__AHv#pacYd7N^Jxp5q zSl{AcL;DbuEkn$;3^Ut4#-eS6&28f>Sk|z!ZIpfH)$Fsb;c)8)4z{f2gn5SJEwh|4 zPjj+mmQ!t;xV2@DTP+*8y?rZ}EZextyujTp%lzKFjeBi7c&K$d540`uaK}y_@7Tk& zw(UIIvYThD`*^{!hnLLTT>nZ-)ae@z8j`B(CSw1nJ;gim|Nz{T;t_T;%7rJNdQ!5(2l50t6j}xVDM$GUwxADZ;C> zgg}d!KzkV>mJ%ZD62k2jM0LuEvnoijDoAaYlWeUb%~nlrdo3cflI-?c3T+xB?P^NQ zT4eS*sygeavg@g_Xwlg8R9ke^TAI+v|zJ$&~ELdr_F|~qnj>s z4~~vr`Z~LCcJ?vS-ou!EkTL5JYdRgQvyHH!ZGerIA-0-F*xI&+t>y`KSjO4eKFMDD zdUkcJ<*<2*y=~JRw6Et_$3~8~O>;~8MsBff=DcN&%a+Ytwrt{d+ZJxOZ{_aJW$tNT z;(psM9_n1?0m~j9w(j9t`#zrPJirSb`+3&Bo9C?i`BVELUg|u?8|?>q+jfLMTaWNg z=L!B|JHv@d^% z8%QVC_BQN2c6zKfdhJ$vJFWC}^)PJhWuU#EQCkmdy89Tl4KrcuW4d#g8QUl`wqdq( zjWXZ8nnl|YU+_b&8X=^&D@X=8SEc(_LG*Xxql6&W&7b z-^`WvC9ZZYa)*6^`#YAnr+WtvcJJol?%h1nzME${ck_gOKhJj^LoZ_91qrBgJiodp>=A(}Dd}KYt$6dE_qx&LXbzb1x?(=+Ozs$F` z%lu@!#4jCJ_+Qs;{J)N?2wb|wxY&#FbQIv>$j7s{1Yd`gkj|T@N=is~1!0|1A|2&K z*=0oADoN|9Af;DLs;!!IyAoknJvm)9r>s{;MXw&E&4{wsKvh=@ zsxCb`TPs?J38T$~-fF{aZNp-3r=_a{v(-vRmkpc4j?>YJqpO=fYd51^gADf!v(_=h zWcL8$wo%sGM%iFn&5UgoGxibY9TRM|u4SobExWtdv%7Pe9rpF?>6+zG_eKtN&vUAC zj%r1Meey-NbeGNb?xSU>rNi&T;@UhUY_dQ$>WZj zW$fXa^$6FxkML5@QC{sm!Jm3h@S5W=uXUZ`ZR>IV+Ixa`dvD>d_H$gfU*LWFEqv@a z&p#Zu@VWgm|FT}z(}E^E?*{ktB62* z2|@kE1UV#xJIe{~lM>OdAhxTLMRspJ!tQzsy0zrl)f79MD0S#5 z?a@-vqo>l|gz6@QqX|W~o|=9O^^P`lJti8RHcWl3wAihfyR5W0JF$0n(An3Cz28n> zUmt_rT@3g2lk8NGHl!i5S520) zhOE9CL_<1?`n44HG$D30A#oaz_ctN!YeF{Ah-%1)#@ULd-$2b^D|P+tH1wD;^|xc{ z?WV1_gO>g-+8tfkdV6sU_0#R_p>MDUXU{OJ28S5z>toe`lhNKm*7uFEc5s}T{?*L) zk1{v7n)#k_7M$x?c22N;Xf1pCr`XfKf!+P<+2@$$@J$TICQkHj;!MwGE;u%G*}2SR z=XS32?Bq`84z4;Exa!!+)&9NQ*SDXCdJgcA;~bjcyH(=?+={jgZ}gUeehO3aool~9hdm5{|a9?F7f5S@A!J? zHootN~0pdjJ18&g#1OiD2VTBPA zAdG~BFcUJEAz=o>3?uA4+S*~Q+v~o1z4lhU_xt>v_V>rzhn6rITFOjF zDYM5coDx`B46ERb(99V@HRlAie0I#nm!Xw>9a_nEp;dep?&2q5BR_{U@Uy_dzmL~* zDZG*Yg}3l~#N|Q+FXI>XDp!f#;Ks-m+!Fdaw}{^2w$PQ_DR`IrBi`Y@@b|c1xRM7& z@3ACm4Ua{x=gEloSQfT{r^7bzLf8ggirB)-;oEpUbQ`Y=ce6sUo7aU~SrN9Em4bb& zj@-?=qW!E9`t$zfQbY&X84 z5a<~UGcXZZ#F(&@@rY8UMdh3jSvW1Qa4O8o2a#1=h^XRhST&zSRr6J3HD83*^F>58 zUkU2@XJj+~6gBc=R6Q3%8~Km0fnUQ~_`k50%OJ0FefV;&3V)NEBj4nf@He<4`YrB? zS;e0tR&&4LT^@*9!-LV6cP)=bt>xkH_jy9_K2Jt$;i;%?EQ{RE3&QO@7rl$WMQ`V| z=&igd+{zo#`*)Cdu_|IOs|5Sl6tkE0kw@4f^k<9kDBA^r?2id#Z$uz_BLg`g z48>m*PGIkk093eyy9*H0#nwZFNMB&jyM@xu_i6$;eiab(=JVr)(OdJ`4L{deG zs3Viej7laqGLgKN6~p9*XFKB}H?qU-rSriot!E&L*E;bL?%7b6?_E!@K;kq5u4qu=0q z@mt&|ew}NjZ*ybxTihslhdUzQ=FZ5q{8{u4cS+yjp{UjTC29i?OV;p^cmq#}H}Y8I z2L3AEz>Cry{8hY@=Y>0XNwSkyMZ0-hx|cU%cC%WzhgHG@yc2Vf^`gUU5*=iV)SpeF z!|W6vW{2nyyTk$PlOAPHWGDxP!5kEaaWpcVV2O|jk%)+BAp(&QkuaJViI|wESYm}T z;v_N1MDfU@5=f4bk{Tr^OB_#TOx%^t6Q-dTrIR0>hEb}dBsvq5P)TuA78Nl$m?XJW zO0uyL!#z=}xKp-<`(svfZ}b`- z7QN3>(K;T9+QQ?}>v>wbm8YWL=LOjoo{!$jbJE?sEZWVh(*3+9-ocxaJ*+EAUnl}*eVHTchoWVh>vqPCWr&lFao3kf<*#CB%y@Kga~3U z!w3*c#7LvXL`O@JNaIL|5tAU65ig5JDNZ0QDiL*55;@TdG*PMKiId5VQc@^VVHBrQ zC`qI23QRUOkp{acm#XM2>Lfax(b+Ua>%&O1+YeZkTvonY?cJDNfyjz$q}~7g4iKF&d&Hy{AD2=kc4nV zBH*Yvih#IqLgYfiOpaQdKt_BD>X-y_ zC5hyUm1xBZ3ZzPOVl~Cmbjrk8n8a$V@!6PS@~Di@!67N2E~bDwc`h!Qo+gQome^w2 zVoK?h7Sj=Dpi69INLGY5#*8o4%&5f5h@_l3shQb$J13dNiR-vux{f8%%{(O8#!}fvo{(?iiTG_S zlkDK-*qywZu#eXgckyQ2Zr)7v=gs*2tcX3pO6fk<$^u!N=+6du5bGqz*dh&JYitNR zWkGC@3+I4Dz`?jM4kjMwpgf8rvPgpCM1)Bq5yVCjCXGcP6%(B(CMrITnAim3r12yq z#*&&CPfDBul{A@jX#&}?>1blp$WKt9lVzZbRZ|?7hA~b_X<`=TiCI*{=3`CBp;D&7 zk*LQh&8IF=k4vt_CDG9yTS%MKK!>ahPizU@am5Tui}A*r8BZ`XnpnwnqJ{CeYUX5B z%*I)ni?8BTLN%vjYxyYN!N<}@F36ktQs(5VxK_T7ZRTrP6FE%FeyD8ks99LAoc5Oya4W#}i_R zN{S|27K{mHumIPEOOnIt(N6c8&`-cQBS?Bh}MK6niGp~CzRt(Fw>h*OlO>#fdni4 zaTbPTW_$`0V{vvS6}8MHRWci2$?1ezK1izJq`Za;ibg(6bZ{Zb$rlOrd@XnJkECY4 zN@(F*MF&4z#)xa>XL%35C3o{%LI=MmcH`%l_;;>P`8!v~ujIy*)!dZ)9ycgfb93T4 zZcliRJ5$$kPx2P-OWMij)9o=M!p3yHgVDQPb+B^=;y z3Hw=|w4XQP53@YppH=aPc}H>d3dlwPXF!<0MF{IsgV~T2%=Y*&wk3zKD>0~D>(aP26(nI#ow~Vj+%X11$*! zG$a<$lw_nO-b_bIIbF#nx)mn+me^R^opS*+rCU)U> zjq>kYuUN@dY3sNqWi2-*zQ-+T?{in`TJBV?=bqF}+?Be8C28APqTI-mv2QJF?cQZni!B^haHF3zPlaVFitxukl|Cp!5o(aq`;fYRT0i^WjH&PVH{RQv0o+Ruu?)mnv{@K5y5Fv zB9v0XQ{_aZ$`Gd~5|bh)R+WS_U4bkknS}H-l2VdM$w(tLHIr<$5_N_OO@@;E)C_c~ zYP9Mc^ywN(6$MnN3#drfVp8N`O)sEUS&TEK0Ee=a7F7w&>JnPj#k6IZajQ)9s_hJ< zS?N!+GMG}yxT+SPs)jLT4fAQ0Os6|pNUP^$ii5LhPR=QsIh#_?$LTIEsG9gBrI{~N zTlgxig@35K`60E7A5+`-Rqf^9N-w{ud%2V`z#l0-{QNT4;-_B2^~!g-F6}*TQEuXn z%Y~>n@@M7y+@;*iJy{!hFl`f$sJ8JJ^==+d+s>m|yLl#UC(kJN@uG4U%To97oYJ3{ z)AqAGEr2&v{#P(oq#x$pv|!#z4PtdxAZs$i*_0a2CbfWVX<=+hjbeXh1P9W>*{c?C zAWK9*W(Zw&5s7WiKQB{H?tpsOA zG48Z-I@BgQ(<|su+v!nR=uz76W!V``x8cjIVKl3nxzrjaQ>$6bbZ|oLMex z&2n);*~ll#Mm|?H@kK@#UuSgiU6zOMRbBj&)x$qCy7)D%n~Uj#{5Rdp|J1|y`Ki|9 zr&`BVstsJHS;MXBP28;6#2uNNxIJ?d_b4}Wch)u@&}?RD&Tbx3ZDXlw7msD`W0}&Q zC)5XcI%^LvsgCeM);?a*9OAW%qbyeju{`G(?`V#)S`)~-S;u%cCzN%nFgE9eu~{W# zmr}@%>}d8Y!`Yt`#er-wM^sV{<%kGW#uAbvAviMup;AJmDuKw%IAXFCh&2hsDN~SS zr;?nJj9i&QdPWLqIZD#hDpV>J*~(1v)hhCoY6^4mD9*^GFe?vpW*+5Pg;ZqgFl&mi zXB6YeDZ;5Pr9pN1UqQ3F6i;ReU79l5Gc9yzEO@i5_*7N+GHV&itYkE&j!Bh+nd};- zvuin#ooXKwCtfrNZm2SSsXyr5YWsEMq(6sZls)L`EUVh2x=0|lGzvlGt zi@KlRvj_M?(~qBD?rQvU)^NRg9oOZ&$Mx!s+>*78TQ%GHvt|o-H(k zZXJW!b&Tb?nA9{dtEpv1?PNBqo)b9@e4uf1IQ zWsKb2+^5;iy_%gotlh<9c?WnxyO*c)5AaOx9{!r+&og<4S(bm8*EENCS$l*xbAos~ zH<;J+gLpeDkazNq@vc_DdpQxT%L!v`P9z($qu8twvO7=6{@f_`<;CEC`CRFuIiiW> za9#q(vlEEWBoUDxM|e&O(fLV;auSebry@<{`OqBT=lv)iLx;!#9 zd1&+V$6h+^yMqW$(=2!ULM!EXm))lAOIfsM*bv`aL|QJILd?hj~&Hz_Ymr zSf)M13;9QQNq3Cb^+CK@5WoseD66$0tSN|KjW(S1IgxD4i(+GLG@JA>Y|9t1BVWjV zeGLAW3(-nBkS8HP7tb+W0s&e%L7D`F+C(A>5{S-CK$@3EtTqj4eku}e8u0}gr06n8 z)u~9&(~zF8B2$}>Mw?AuZUGvt9<9!RUT?&pFQ6#TK(VeEv#t=k-axI+j6+jKO@1-1 z0yEAWJ8rF&W?dz%c{VyUwRGlI(XY4Dqp8A|Q_onQlcBsu#&R2((Yu(_H#4JgGpBFj zjK;(1{ANDZweewoI~TNVd|BY(Q(YI|>%DxX?cuxJK7Py}Ll@0{ql7(18T$<}95N;lP!LaWo{Ye}1VW7|2(>8)^$7@!X-Eo_h%u;< z=c!33%p$Q+MY3K^l0ik9A&)FWCK-kKWESL-Ys^8PuSZ{4NU^~{iJ^c}T`{HlQY?9e zn6%~Cjm1|mstu*o6j*67UIsDaE-=%kuc6&&r_WHsfU%Mxy%V3Vmhpl*#&u0h6uOww zIhoQnvQXH->3lbf1#O%&wDNIY7w7Xj_|(|TS9!gBk?-Z(`~kkz_3?fF0RJ)!@NdH) zKNpU1vCzkFIv@Y1o4gFN9>1b3T&vy4pNcneOVLhl*Y3Vj%RNQgxl6l)dv*I+Qn;J@ zb$fWwxQE9Lhk4YvpC^q+c}DBc)4D?}D+=a0UC@<6UNr{uc2OvA>BCuR3}t0OB<~c2 zu~r|>rh+IoYQ=0aMzd8P$@U@{dkSRi)5UP0FadvKEJ4L_1Q-;AXcIYBlz>o|OjMB) zp*EE$qZ)C6l32YGi8d2iaVCkn4AQh3(hX{qIt^LIJhJpTXp0OK6zV9{6;h}zL~qnn zT4bQqXr$awf~C-m-B>|ov6U*liP{1yO*$(LT08Br-(TPu6&tOp< z!-Wn;idz`dH!-7YVXCl&*`j6^^=*8h?c`jchcgA;d|KSW1$_sf7kBfywui3`J^Y{@ z;`^dOek$_vZ=;W2ipKb(XoBC2<6J5lzYOv|SC?+##-c6UP`H`f^jo;qu$|kDd$?7< zpZf~;@@K;y?kn-LaQKloUFb`Ep9(hIs zdBsL@jXDe^MHCepG3rYwDk;ZOR8B>i8B0+a)um-r8qL%fS5m7tQ(st3V_6kVr8T(A zYG^BR&}XP(K<}i#)X9*(j*(&qBZhjW%9@xlG%#1z%uI0;^TjPJ7Ityg(9YR14J_&fs^oX(QK_Z|7R$ zcCIbi%njxp++4Pm+l#kzYuRq@E8foo<$HOk=l~Cv?Bh}60Uj|0@_5lf9xFe{Go{CP zruZn&mxS_iX()d)h4X4r7;hOvSYe7}xhax0#SyG5iDF%`i1$lHY$%Ooi!p}HC33cv z#Imnk#_l312g;JzZ%ib}m_$I4oPhFFf=U&Hl%*3@t{|pNjkqKYQMsD95*4vUStONY zlVHpy$(WDQlt)Hs0h#7}a!q=2OA5#@E=F5aindr!Sy?$HrgBP6C0NVKu$!$^6`80j zH&I(;rq*0VLx~-CX&oMO6>UW|w3%w@Dyhd?R!5Jyf&NklK9iH7A~zG|txOiTGgaEk zqOq0JC2gE2Y2%#9!}(G#AC>oVzOW+VUOTRJMyd%saWeVn26P?BOoUF77uSfZKX1HTcqr*h~rIUvU|vi*pqNoGJdzbw5${w8SX~j#YD*OBOft4q$avox!>)2Ud#v$nx5?ROOX9FKo+GwY4wt2J z%$i7mIf)>vg3yX|BFsue73qk}a}ZlJ#9FeEnDbEBbfj1{D9yR3%JRvu>Co5=WSjJ8 z?1ktoWf-l+=q)9b*h?ugm1D74sjyq9G+VKn?bKCNP;IkPQ(i+$Sv7961CQN-+w7#x z&Kl^sBvhCt}%YJUL z?dNv;?*ETrI>^1Y{oGfvj|a^Mc*yF{V-*K^(t4CfECDR54CYx|ATL-#SXLRzOO*m% zuL$SWip!@flD8}ZR#!&yPE|CkDq~n%6~)F18SAQI*=85BsUnU&=0tW{;y6%|h<{}g zM=a?av?vI&q!C=DAlROcz@{Y9l15}j4ibwBNmUMUmTVI2Iua~ebhpDO#=1rX}nEE(d(apKa9!{GF zIB)6We3g%{>;rsl8s(d+0lu>g^HaqL-)A6Tjefpwd~;z%jI)*h#F0}V2@*$C5|2T zM7CQL_}3({*OJUZODg`BbPiQz5NK5qQLRR3&nBu`NrWXAVQnU{_Dtffxx`wtk=5u( zs4gU>Qcp&u0aaxIS(U}8ZDnNH4Crf0FxFI1SZ$)zX2x7qj>%Sm#a4l}#)iGRib`uO zj@l|5b_Y%NTHI9*+N?FSR5sFWchXx~kGHac-kMehYnvIjw=-GW$%M6)*%}Xv);3O9 zx;a(b!?`LiXDr=UAU>-0@_AK1pIZm`+V10f>loiw5A&majPGhD`L$}Ae_vkfJil8O z`ETtMe%IRea-D5Af3ol9I{Q9uvmfLp+X3#f@8?eYLGG^d=K)6$4^#*7a9sco)`al5 zEriGI$9bkUh^MQM^L$+hf2|Ypl1;#il|uej9l_f*0^Y2avZ^wgm39g5+2UATD`P{I zgbj7^Y^#i8SFMaKbqVaVC9}6KjlFdW0%|T}s0gf85nP>4s3VPtIt`Kb93rbTiK)#* zX4ett$RW|DLtdvNsZNK|ZXlzkn6%m=GV6@w)*8?_OcdD5C~%act2I$rWux3-#bmc) zv70g3ELd!IY8^E=s_JptYH6u<;;e0;sj``l>K1xz4RlsE&}nPMYj2{jrj;>U7h|;@ z%v5$VY42vXs)Lg?oy=EuamvxhXiYS};pHP+KcCu0_{=fF*OkNkqk5WeY!m!Y zJ;A>!C;8bn$wkK^mn!GDR5_2IpKC9d-*r{{xT$&%x7F?C#@a*NRke@XodMii6~u$i zKpw0PWJy&357hDR2Ps`l}}Q&f%JMUs_Ig*s!CAR8pw1OlU-{jx2BBz z>QV}v77R6Z%vE;G&I-&mRw`Vz*z2pPb2_PZ*5hBj5oV$9LbWPLYd^_@)B_cHJ5WUYr#sGH;`#{xe)=eSfo#c$3N{Nb3x&#&e@YA?>Nl$HHWy_ zwVyj04{?|C2=~NKF>ut~{a|bckHpi0g8Y)o4kq(UVwLNQ%Qis#8x&V=<|& zVscz&a%#)StF0iKkflX>6di zu90R(GwqHBI_lc-)VJYvxEZSHWVq48V0{-ub-j!^Jd8K?GgaHq$=Y5P>%E+C4sqJ$ z<3htI7aB+T$T`NB&T+n}ndUpk6kpfR@z*IsC3^ z*~N7Zf39lu=SJrtZgm~z)`kFXcLs7#%VF+pImEq}OQ{d!q2?eSX$}yP6f1?Wj<_vZopTP+RZ9)!c;3bs5J+tFr-ja}#b?3q1{PyiIKkHg?eO>R{N>!*EkK;Y#icjy^l{_gPd<3;>+d{K5ZQ5`^Fi5Xqw>%$0R>D&+>Eq48J(% zx!AJ6e;ZCf%PIW)nhtPX>jAEAJ;Zel`?#qgkXzkHxwSc%yPATy+jW!&oT1#;dW@yb z;Vfwm=gDRPk2FQ_SW6_&H3@j}GKMpf=iE`e(jeva1_>(~qWOEHgw?H5*0?0R*OJUS zcRcHz@oa8Yu)&$kwuZ|ug*}ZK>~m+b&#fZBtt6mHLr}AZWA0o+8!`!L$|lO0hoB{g zsK!FXZX;q>5%I195?hVLH|tTh7)Wh3qjHs#;jSR3rGjjy9c_b+JeLK7s~UZi1+%k~ zipDC+8!NFk*J5?nQ`z94+U3I0;K0%1qNTYRcS{3Z?k2jM-E=uS=xp@R=kCPY*vUv! z7b6Xwj5ZB0-qOoV^C0sLeOFM@{@@lbOZkG4ecL~}S#H%IeaYXr}=h^*c-)P2wzkpP z)`q90gTD4IdRjdUdb|v{`x$QSW};<)*(M)TZC)1IMp$U_vDiAy`Id1`w@-4RahfkY z(|qZf;T!iP-!#tfUGp^Gwa@Wm>pcJV%<@a)DSmA}%^!`Y_`l{;_+8^Z!gZbdxS{PR zH?;+FOIHxLbsgqb&oOT6JjVU*Fdk?L;m@4{9%v2Wp>`opbVc!~Cz7W;0-kLV^IWTx z7doSOsa?eD9x<J`6V#fApi_&$osXzJpQv^t;#Mu<_5xzt3Q6iHL(y4; zqP+s8rwnzc8D)!&oYr!(J8bAW%^2EjXj^JAwpL@ zoNgD5?M*n_T4`=+qP?>nkK04Hr<-24hrzB6Mm$}Nwf8dI+Q)QTFXL`6^DRCWyGA(a z8Rlf$DCa$sobya^wq=U*Z8Ln*KF#Nz8NO|qzf#G!tuy?qMSR_$3|9r6}&j_>Kqs+NS_@I4))7_Jt>zv`kwi&+gOz?5%4FBkv;fu}%zUrLi`|eqO z={Uu&o(2BZaf07^KIHfI(_HGfe6qTZa!uD^ZtMu=n%)3z?K#37-GSWEdyG4Kk8@v# zfV;W{Jk%A=1ASpE=@jr#r+}xsggo9O;pv`eUhIzIuibIH+#%x?ubh|r5_z*Dme;*W zEborzoz7I=?MmX^?j+WFQh2{l$@|?YZ1twHwJVby-I?s^%4ScGhJ8J`9PP>{uqU5@ zZY^OQxrF!W5q4-1b`>M;Ek)E_L~LIfGEXUS9VH}rO{DZ$Pj}YxsH0OdR)|c+Gyx%qN%qTS62tEeO-7w?Rffn z>GpW(>g{3B+l{xY4_{v|Zbct$zXH_7?FF+T2^<>Sr? zzUZCei^~vw3w+bPz|WpJe)4|6k6mZ@PxmQ)?>x!Hp0nURkKa}O{#-o}z>R%@+}IPq zEq%e<(H+EH-67o86UKer$GNXFjC*@UJkTA+Bi-RV)-T|xP6>~7$MS5qlxO>-ywoG* zmCjgR>z4Coe>`t@CGmP!BJT{O@^-g^Ro$tq>rZ8Er;_&vlx*^5vZYte&aMphbY`)q zQ^lU{90I!Y@$b_S=+zV2Q%HDUJ|UfjME2{5@EQ?&3yJ9|A-=a1S+|+Q?lO{ltR(i7 zliq6~t-p$l9y7VUHR!xH@_Q@E@2*1IUyq@;nxdXYOr6bGy$;NsE-HK6R|={1dZ_Pi zr(vLjhVCxfd%9`&w$a|xMMqC3{r$ZR_4hE?+s}~Khp*Smly`)=zA^1PKwWjs^5m%TaCtRCC^udzNa3ouLk2#10{Vf%s$7JwfP#T z>UC1p-$R|{W4x3PU1CRGH(saS>aW%(wol8 z{uJIBN@KM*lTAJ)8wa!5(3i<}Zw6a^dF=CP*yGLO;E;wxzI+b%X*tqgz_F1+f(La( z4C;v*&=ct^LfB^})?0?eXC`*ULPEcl)PV|8hwP;Js!8j!qa3=7QA5sPE!x2&C>Y}^9 zpFVFdLw&=H`^K0Y9A$E3l-Xe)v%X0dydx}*OmTW}f|Gqyd^|A2r@k4^_n+d^zD54w zUF3_wll;SbmVf#_;K#lX`FY?1KYK6m%kU@s&-)3#_kV`p)dRs?H5$aVqaoZh7|N~V z!QAKz>>SC)e$m118c2D@Ylu zCT-k~X1Ipj@mli6s?ZKO(fR7pk2Inma9|p8Q99g+eV~o1fktct9aIguagDffjJMM; z+KFqtgQkIQJVQOWeFO9i^f53pMBmT=gX2C%MtzKq4KX$1V`g-k*@0>1N2fS3JkE)M zY0eKUa(3VZ7e;6KXmE~CM(6orY>}@<7Wr=E1HKtO$B!fD`El?pKl{$`@4=7xb>KYz z^L>h+-$(#gPafl%u?TJ$3FfwuW8C38&dtN2+&(7YuE}uj^+odFkdP|&QNcw$`6 zBa?AFJrvKfi8!7aPh#0v0xyoF@TxC`m&a21+gLhpO(gM_FO}63D%OmuSTmf)+R-dF z59hLZD2pBAIqV$HVed#D{u6o*4(GCOsF1^B1q6&2a%`-CV`D}_M$3sBGZ8*gOyqDe z(GynWBX$zUEW}NiQH)zj^jT4l)so?}kvUXL-lUWK$vSd}YtRkXQRr)+aHJ9ASSw`{ z&6s>{tiE>azAmcAJE$M)z%|rH{bU!;Q7>&Hy|j<_&^gje&-gGszF`K3M;P#pF+McP z*vKd|<0DLt&agN`C|MupO2p7i^-FGHF1V- zhtBie$jAKX`;hg|ol^4NbPij_GTAno#jdejwoT@+drHgxsY3RS88|p*;P7M- zfs;m#juaC#TE?-Na>B;VL`|58nYI!=YDGM4Lq1_4eyW1_87s=s8k7?bR1;3Jrt8R_ zaFR1ohjz?G-h>P7NF$|F%@mC_Q!>?zb*z=D$rfs+x~ZM+rf$kZ{a7!K(O%p$12m2G z&^q0Zd%U0S>A@?7^iPe@KQhkv*d!w(Q;dzwFf%^RF%UdaMDR=zq0>f=k5>>eUPkn636fbG(UTR#OqxiT zt3oknMKM)P;-n4rOf^~aHE3pBWKX)#&N|7TaG;%N#yH-9exeEUR0}0@?O0~ps2p#@ zHq$}XR44V*9XQ5&XqoH8J=;&y!~mVsgLvi#>6q`wJMLp(dX#~QF$QKP7?~VrYI2h4 zscGhBCt0|>b90=TTHwRkIWA0`;G?M%d^)$tr}Jm{$LvYIpFG14m%*BO z4J#LOSwEA_#>H&5Ol7lmM$3+w9JVcJ*)x^L{>dWzXY~Xu7&tm#MBrjM0ShGrPnIE^ zF(H^LBYMVybk;`9jER^98p(s2LN(by=3*n7DJS{!4HV2aP%!1f zFxg7cWGiKpO_a`eP%+toZMKWbnNBJfdZ?M{pnjo?#sx183%#^W_t7!eN88L0z0(8q zO${+LH^#`!2)>0W#wMqknVn>IZjSkdd1fzzOr7H7;v65$pXS5q1Hl&D43mnLNi&lOOW);)nb?{{_EJU*O{6r(Bx*oZlC}!Ow42fZwSwu018hw_CsDoZFcu}0gE8=`!lIQc*_-5Xf7x13EfcM3P{7anAr=z8O zDB8)trA2%uE@FWQ3X1w2CH0wTdK!9s>UtR5R~f(%gqF(Mna_51_*d?htzw#THTTO_Fkm(ugKw9aURc$xA2lEpVw6RysF&J>&nf% zso2K5lI@q9Eh^yO;$l9L?Bq+uZa!1);w$AIzLoFd2gOdlRqW*_@opBV%2^=Z$6{3# z3skkNkk+z7)D%OsDD`1CsL$$cGq^ zo@Y>XfwSUqMpPn(Bo~l~$C1b-$ixcNN*QXA8bPALD3YUBs<244SQP>`kqM{RfJdw+ zqA=o@2_#ew;!-n7g_X1^ss*IU7ICd&AvdUh;TqXuu2(JQHq{DlS1spG$ztx2Ea6`D zYVK7nNW)wM>_-;ZeHu3rX+_a<=M=XNu%Tx4bnbZlxH|98RW2{pAPjP#}#Kd zrWvG9aqeDiv z*WtKyl_mm86ETg0gwjk><|HL^k(Q=gM4DzESL=S|I_(l})Gp*E-LKrLSj^4p72Ksw z=Xctb+@n~=-O3E^Rb=veO(qYh*D_t5$>XXlW~kQlq&A0{ifsO(%H^-}9G=nS@(56)?H3!L2G_YCI$R#Jw~avolwS~1K?orodb1qQVj7*a?XRY(}uDv;~tNOW?fY6S|l4uf2c zPN~GCRb$bbuxs@=^cGwSBVM^cSYyF2cM;RuN$9;y=p3Y7rT>+yl#97uKcA~rOSnP* zD>tcDa-(56x2TsgRlkCJbZfXzm%%-Tl}uA*FioGuG))$N(q{0Ob}fI_Z{$(!MxIoy z=NWAte^GAaIZZCl8uIwNs(_cY+jvox&r9keUR4(Iwt5?H=?nP4P{M5OZa&r&^P#$w zPYmUJrLJI(ZZBUN_A^&m#kWEYbCrAfQCG(=s%jPq2U)7DXNj_r<(fKHYU^38uV<~Q ziEMQfIoc-jgl2M7&1})OlV@n7K;2D=ww+?laY}W^DA#sUq3WVS*GG-Mk2?JcYW028 zYWr!_ouNh5e;K1gKS-DA99_a7y@oMPXh-PR40BQ?<&1EF(}poFC}mtQNEy}1kQo%H z^a^Bx9GymiPNPRB=n!-oOiB|Doe8(WibJpyG&l&UZG@Co5?VV^y`7XQK-v`JJgzb? z;#%D=Tx0r`>-8(SS+|nk>X&kxeht49R&u*BgS$;@xl6l_Y1(y6*Q{f@A)D#uT>fZW z&kVx`W|%heXVV5|YI1qTkk2!^Z9H$-#Pfy%UKV!nigp_>>kD~Xx1Bdk#k^-M<$YZl z9~n#dKq%!S&2Huxb~8uV%jd>@{GYa(AI#PKXsG5V?S6hV*RasIpLwPl7HDf(Bs8#0 z-@r=qK~@`@$1>PwUK9PW0S6fE!s|s4eb;Ny%Y+)lxw@G&~;Iv?V&<5 zz<&Kn>UDio>-woT4A7(>piy^@stq!AEK+p(SbOKhr1Gm;h&}1WIa1k@Rh?#7p zOdjGICuvve7jdQjSFSTIWQu+T*IHL{t7!!{nU`~$A%olXtC=dKbEj?<_Zl;J(2~tV z<{Tb2ZQzflbv$C-z>}t2o-l6a3H?_7N4JUpF>K-){bv4V%;RNUAukw;c*V4he;SK; zTep*c>UZ#_zKD0NyZP8$%52?kKGIk4rFAc#n#=i8*vGe~O1?8z^PRPZd8S(at2@Aa z;Sh_h4VPE4Sg0r6e3)fIGiwY-$Tl~VB{Y+1X=Q`CldXnUHdrYzLOn>6O@@c zDKi|W%-m18;W$;+J`Na8P;DKg)_j&)^AOFt^R(*E(IyPhE{xG8oV)z8jvGhl5yYG{ zNjPN?aoQ;5yiv)hpkhp?WL!`pF>4VS)F^Zs^cF2zqkustU@_}4=}oxx4jfh^9*dcP z-a|le;5T}S3vQx9fTYe%+7!zorr4Kry=^hqSQm4HZ7DYk>HOBXhFeVO++oV#c1s3# z3Ryg0%VwG-i{INb`GbAqWsJuy8+lS#&ttY69=GQ5lzj_NJGL{^v6)%I4qh}g1Rx(GZ;%n1><{ImmYp!Fi z@gV;-*R#;tzyfnEOO4H}5Du}_)WRA|1L?LFGK6+=E}>Z4* z6q$S3W$L8d)=jCckG+l)>=pXi@93k(e41Keh#$JmSb@hBb#LoOwKL+saJyHlA{9VWxd6&$tTtyJI_l zvuxu9XAy6>OL@&*%eR$g-D@E&KV>UBl=01AOJ!$2Ybr zzPHu!le?B*oQGIwIlv;zK^B+~v((wl3VRdjmPS^akCJI=yIiiLooq`hn_Qi2v>zwW z*1--(AEk~yb~*bfcJ#2v)yqC}KUMBN4%kmoWj)0q%P{rU5e_-e(O?^;**1I`rOP_Z zF{hYg7AYs)B2L=HoHdI$;}mn=rr^9y&5&Epm{Y;HT|i>jBDI>4+sp_q9U7Mvi`{|I zV!~o|;;~!snccV@9s(8*F}I(P)5D}S0QLxJX|82l?ODo|-gK_AF6S28N^Z5LGu6GC z+db*r=FZ|C&uZ?oWpkHv0}ohp_@jLt4?44X#FfVk&t@KRZ{*L;e4e!CG0VG!=Ujz6 z?=9e2Yd+7pN_gH~$V-kg-msPMwyTUcJbQTGzKi#)dw9>imrvZ~%y#YPW7{77ZQsY| z)>^)`*71#_j=8pKezMjv&vS_X+77Y6-NI69Gs~S#tg<$d?rvg*`zUMd?PNQ;$aQs- zXYFL8w}X6l7kRdBioM+wy1FTJ_OjF2OR47!WzIqNcu!JkA7a1f6!qQ_Y8_{3bdJ(u z9iq)PPN!2$heJY-Ym82(oNlX#UayjqRyBi8IfEVzgH8>j4jm(24RVJ8l~<3%YeeN1 z5FBQVRy%sD1HtOV?X}>xT5;JOm-D$jMD2bOUO$tbFmbz|v@6_;xjL|lE8NSN5?IN# zo|W9>Tfr^<)!gF9fXu| zo;;p)7ckSejhW8v{MA{+KRl(pER_ z_VKCn0CQY5eB-QRj=O>H19kl1t>H&UBlA7Y{2XXzexQ*BoBXQ|$K-u-84z0smR5 zJww#H#yIF0rNuEqvvY(a{&Bh-qx5)WbUCFA1Vr?E6!bY|3_0Ws2jmQU^b9-IjC!?5 zJX(|vE$V;)x!;7|ZNm_-V|BW5IBZw~UR(h$e!r7|-;3YrCG7E$@xgHn0|3VOV9dO{ioeOiV>Du#nP zE`)T9dDTe#8piwril7x$(2m+~Lg_K0b2$)P7VJSgj*uHiz=zN4#O3i333!PGq9k2m zCVXM=B}q#QFXPI(hr4{a+#ky2!N7VR zjOH;txRHm$TbU8s&J(_T9`hIQRB$^_hf8=mSir16G0#Sec`jJYOM!A;@RsvRXb-PO z_wcTN4{rr3_%Kw>?9e_w@>lV3ppH+1HGJW#<{MuvUq>7GD%`+a??L|SKgf^4BP{f` zvLxEVvd|HhdfQm#Yh#VKm6hIBvVzB18|)y{e~g?+4_l*sZ1eU~;O(W@H^7cyA0^SV zltxdp$2UY(_#EYtvm6MXEjfLB9* zK*PzXk~1M4gI)upAw9zp6JnncncsxSXFwLUp$=Hk1e_QnHUzH?OUQvW;Kd&C;1Br- z1w!~QttA*E5(<&>obC6KjDjc0Rvcs+N- za=0h9p8LXEFN0hzRKVl$0v-u&p9alkh%12~{&Ea*)qsHOvVen4Xf%7pvqag!hVLg(F1zEt3BIrOBvZ09BQHGruV_qy#7pABeXT*yu z7Qhwq;0yXLLxchmVv!)xa0H?W(xyb$a7A)8Q=;iyoyz8hR0cOh)^l4jn_Hsm`E4qT z--WZeKbpr~@eSM?+06Zsd>#trF(aJEqoFN4882W)qJXF4rOZkdF*8-l%y1F2LM6Ny zFXh!p881dFc_p@+wR@rSjm60}R;G@U5$Pp6)MlOW3j3h0HLq=p#0cpyFEMY;P^q`M9(8p|8 zBOXk#0PchzYt)S=9L5!j5e|ik#)5<{EhH5to=B0F7GK4bWCmBq)44X0#dV1cZcSx! zQ*u316YIDwk;l~N2JV~K%-xYK+#k#156L_pp4`lgQ~{4gwq5SYi4vYj6)`JT#xtp% zJfAG*uki|Ai0|T+R2i=)D|vacoL48RcqdlH`^jqFiPiAG$$fmBtmE^^dOnWT@maEg zZ=;9!F4e?$i3Yw)9AWN68}noB%ulwkIC_|csiQ279c5XhlQq#E)}%Vfoa`ny-b-fc zB_`q#J~_=ha)ic|jK-9h zrlf@Cq=dGVjMkKtV{r|=2|axY4ZU%JQq(a84`jm%9o^20 100 || h > 100) throw new Error(`${w}x${h} doesn't fit in 100x100`) + let { PI, round, max, cos, abs } = Math + + // Determine the average color + let avg_r = 0, avg_g = 0, avg_b = 0, avg_a = 0 + for (let i = 0, j = 0; i < w * h; i++, j += 4) { + let alpha = rgba[j + 3] / 255 + avg_r += alpha / 255 * rgba[j] + avg_g += alpha / 255 * rgba[j + 1] + avg_b += alpha / 255 * rgba[j + 2] + avg_a += alpha + } + if (avg_a) { + avg_r /= avg_a + avg_g /= avg_a + avg_b /= avg_a + } + + let hasAlpha = avg_a < w * h + let l_limit = hasAlpha ? 5 : 7 // Use fewer luminance bits if there's alpha + let lx = max(1, round(l_limit * w / max(w, h))) + let ly = max(1, round(l_limit * h / max(w, h))) + let l = [] // luminance + let p = [] // yellow - blue + let q = [] // red - green + let a = [] // alpha + + // Convert the image from RGBA to LPQA (composite atop the average color) + for (let i = 0, j = 0; i < w * h; i++, j += 4) { + let alpha = rgba[j + 3] / 255 + let r = avg_r * (1 - alpha) + alpha / 255 * rgba[j] + let g = avg_g * (1 - alpha) + alpha / 255 * rgba[j + 1] + let b = avg_b * (1 - alpha) + alpha / 255 * rgba[j + 2] + l[i] = (r + g + b) / 3 + p[i] = (r + g) / 2 - b + q[i] = r - g + a[i] = alpha + } + + // Encode using the DCT into DC (constant) and normalized AC (varying) terms + let encodeChannel = (channel, nx, ny) => { + let dc = 0, ac = [], scale = 0, fx = [] + for (let cy = 0; cy < ny; cy++) { + for (let cx = 0; cx * ny < nx * (ny - cy); cx++) { + let f = 0 + for (let x = 0; x < w; x++) + fx[x] = cos(PI / w * cx * (x + 0.5)) + for (let y = 0; y < h; y++) + for (let x = 0, fy = cos(PI / h * cy * (y + 0.5)); x < w; x++) + f += channel[x + y * w] * fx[x] * fy + f /= w * h + if (cx || cy) { + ac.push(f) + scale = max(scale, abs(f)) + } else { + dc = f + } + } + } + if (scale) + for (let i = 0; i < ac.length; i++) + ac[i] = 0.5 + 0.5 / scale * ac[i] + return [dc, ac, scale] + } + let [l_dc, l_ac, l_scale] = encodeChannel(l, max(3, lx), max(3, ly)) + let [p_dc, p_ac, p_scale] = encodeChannel(p, 3, 3) + let [q_dc, q_ac, q_scale] = encodeChannel(q, 3, 3) + let [a_dc, a_ac, a_scale] = hasAlpha ? encodeChannel(a, 5, 5) : [] + + // Write the constants + let isLandscape = w > h + let header24 = round(63 * l_dc) | (round(31.5 + 31.5 * p_dc) << 6) | (round(31.5 + 31.5 * q_dc) << 12) | (round(31 * l_scale) << 18) | (hasAlpha << 23) + let header16 = (isLandscape ? ly : lx) | (round(63 * p_scale) << 3) | (round(63 * q_scale) << 9) | (isLandscape << 15) + let hash = [header24 & 255, (header24 >> 8) & 255, header24 >> 16, header16 & 255, header16 >> 8] + let ac_start = hasAlpha ? 6 : 5 + let ac_index = 0 + if (hasAlpha) hash.push(round(15 * a_dc) | (round(15 * a_scale) << 4)) + + // Write the varying factors + for (let ac of hasAlpha ? [l_ac, p_ac, q_ac, a_ac] : [l_ac, p_ac, q_ac]) + for (let f of ac) + hash[ac_start + (ac_index >> 1)] |= round(15 * f) << ((ac_index++ & 1) << 2) + return new Uint8Array(hash) +} + +/** + * Decodes a ThumbHash to an RGBA image. RGB is not be premultiplied by A. + * + * @param hash The bytes of the ThumbHash. + * @returns The width, height, and pixels of the rendered placeholder image. + */ +export function thumbHashToRGBA(hash) { + let { PI, min, max, cos, round } = Math + + // Read the constants + let header24 = hash[0] | (hash[1] << 8) | (hash[2] << 16) + let header16 = hash[3] | (hash[4] << 8) + let l_dc = (header24 & 63) / 63 + let p_dc = ((header24 >> 6) & 63) / 31.5 - 1 + let q_dc = ((header24 >> 12) & 63) / 31.5 - 1 + let l_scale = ((header24 >> 18) & 31) / 31 + let hasAlpha = header24 >> 23 + let p_scale = ((header16 >> 3) & 63) / 63 + let q_scale = ((header16 >> 9) & 63) / 63 + let isLandscape = header16 >> 15 + let lx = max(3, isLandscape ? hasAlpha ? 5 : 7 : header16 & 7) + let ly = max(3, isLandscape ? header16 & 7 : hasAlpha ? 5 : 7) + let a_dc = hasAlpha ? (hash[5] & 15) / 15 : 1 + let a_scale = (hash[5] >> 4) / 15 + + // Read the varying factors (boost saturation by 1.25x to compensate for quantization) + let ac_start = hasAlpha ? 6 : 5 + let ac_index = 0 + let decodeChannel = (nx, ny, scale) => { + let ac = [] + for (let cy = 0; cy < ny; cy++) + for (let cx = cy ? 0 : 1; cx * ny < nx * (ny - cy); cx++) + ac.push((((hash[ac_start + (ac_index >> 1)] >> ((ac_index++ & 1) << 2)) & 15) / 7.5 - 1) * scale) + return ac + } + let l_ac = decodeChannel(lx, ly, l_scale) + let p_ac = decodeChannel(3, 3, p_scale * 1.25) + let q_ac = decodeChannel(3, 3, q_scale * 1.25) + let a_ac = hasAlpha && decodeChannel(5, 5, a_scale) + + // Decode using the DCT into RGB + let ratio = thumbHashToApproximateAspectRatio(hash) + let w = round(ratio > 1 ? 32 : 32 * ratio) + let h = round(ratio > 1 ? 32 / ratio : 32) + let rgba = new Uint8Array(w * h * 4), fx = [], fy = [] + for (let y = 0, i = 0; y < h; y++) { + for (let x = 0; x < w; x++, i += 4) { + let l = l_dc, p = p_dc, q = q_dc, a = a_dc + + // Precompute the coefficients + for (let cx = 0, n = max(lx, hasAlpha ? 5 : 3); cx < n; cx++) + fx[cx] = cos(PI / w * (x + 0.5) * cx) + for (let cy = 0, n = max(ly, hasAlpha ? 5 : 3); cy < n; cy++) + fy[cy] = cos(PI / h * (y + 0.5) * cy) + + // Decode L + for (let cy = 0, j = 0; cy < ly; cy++) + for (let cx = cy ? 0 : 1, fy2 = fy[cy] * 2; cx * ly < lx * (ly - cy); cx++, j++) + l += l_ac[j] * fx[cx] * fy2 + + // Decode P and Q + for (let cy = 0, j = 0; cy < 3; cy++) { + for (let cx = cy ? 0 : 1, fy2 = fy[cy] * 2; cx < 3 - cy; cx++, j++) { + let f = fx[cx] * fy2 + p += p_ac[j] * f + q += q_ac[j] * f + } + } + + // Decode A + if (hasAlpha) + for (let cy = 0, j = 0; cy < 5; cy++) + for (let cx = cy ? 0 : 1, fy2 = fy[cy] * 2; cx < 5 - cy; cx++, j++) + a += a_ac[j] * fx[cx] * fy2 + + // Convert to RGB + let b = l - 2 / 3 * p + let r = (3 * l - b + q) / 2 + let g = r - q + rgba[i] = max(0, 255 * min(1, r)) + rgba[i + 1] = max(0, 255 * min(1, g)) + rgba[i + 2] = max(0, 255 * min(1, b)) + rgba[i + 3] = max(0, 255 * min(1, a)) + } + } + return { w, h, rgba } +} + +/** + * Extracts the average color from a ThumbHash. RGB is not be premultiplied by A. + * + * @param hash The bytes of the ThumbHash. + * @returns The RGBA values for the average color. Each value ranges from 0 to 1. + */ +export function thumbHashToAverageRGBA(hash) { + let { min, max } = Math + let header = hash[0] | (hash[1] << 8) | (hash[2] << 16) + let l = (header & 63) / 63 + let p = ((header >> 6) & 63) / 31.5 - 1 + let q = ((header >> 12) & 63) / 31.5 - 1 + let hasAlpha = header >> 23 + let a = hasAlpha ? (hash[5] & 15) / 15 : 1 + let b = l - 2 / 3 * p + let r = (3 * l - b + q) / 2 + let g = r - q + return { + r: max(0, min(1, r)), + g: max(0, min(1, g)), + b: max(0, min(1, b)), + a + } +} + +/** + * Extracts the approximate aspect ratio of the original image. + * + * @param hash The bytes of the ThumbHash. + * @returns The approximate aspect ratio (i.e. width / height). + */ +export function thumbHashToApproximateAspectRatio(hash) { + let header = hash[3] + let hasAlpha = hash[2] & 0x80 + let isLandscape = hash[4] & 0x80 + let lx = isLandscape ? hasAlpha ? 5 : 7 : header & 7 + let ly = isLandscape ? header & 7 : hasAlpha ? 5 : 7 + return lx / ly +} + +/** + * Encodes an RGBA image to a PNG data URL. RGB should not be premultiplied by + * A. This is optimized for speed and simplicity and does not optimize for size + * at all. This doesn't do any compression (all values are stored uncompressed). + * + * @param w The width of the input image. Must be ≤100px. + * @param h The height of the input image. Must be ≤100px. + * @param rgba The pixels in the input image, row-by-row. Must have w*h*4 elements. + * @returns A data URL containing a PNG for the input image. + */ +export function rgbaToDataURL(w, h, rgba) { + let row = w * 4 + 1 + let idat = 6 + h * (5 + row) + let bytes = [ + 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, + w >> 8, w & 255, 0, 0, h >> 8, h & 255, 8, 6, 0, 0, 0, 0, 0, 0, 0, + idat >>> 24, (idat >> 16) & 255, (idat >> 8) & 255, idat & 255, + 73, 68, 65, 84, 120, 1 + ] + let table = [ + 0, 498536548, 997073096, 651767980, 1994146192, 1802195444, 1303535960, + 1342533948, -306674912, -267414716, -690576408, -882789492, -1687895376, + -2032938284, -1609899400, -1111625188 + ] + let a = 1, b = 0 + for (let y = 0, i = 0, end = row - 1; y < h; y++, end += row - 1) { + bytes.push(y + 1 < h ? 0 : 1, row & 255, row >> 8, ~row & 255, (row >> 8) ^ 255, 0) + for (b = (b + a) % 65521; i < end; i++) { + let u = rgba[i] & 255 + bytes.push(u) + a = (a + u) % 65521 + b = (b + a) % 65521 + } + } + bytes.push( + b >> 8, b & 255, a >> 8, a & 255, 0, 0, 0, 0, + 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130 + ) + for (let [start, end] of [[12, 29], [37, 41 + idat]]) { + let c = ~0 + for (let i = start; i < end; i++) { + c ^= bytes[i] + c = (c >>> 4) ^ table[c & 15] + c = (c >>> 4) ^ table[c & 15] + } + c = ~c + bytes[end++] = c >>> 24 + bytes[end++] = (c >> 16) & 255 + bytes[end++] = (c >> 8) & 255 + bytes[end++] = c & 255 + } + return 'data:image/png;base64,' + btoa(String.fromCharCode(...bytes)) +} + +/** + * Decodes a ThumbHash to a PNG data URL. This is a convenience function that + * just calls "thumbHashToRGBA" followed by "rgbaToDataURL". + * + * @param hash The bytes of the ThumbHash. + * @returns A data URL containing a PNG for the rendered ThumbHash. + */ +export function thumbHashToDataURL(hash) { + let image = thumbHashToRGBA(hash) + return rgbaToDataURL(image.w, image.h, image.rgba) +} diff --git a/core/artwork/thumbhash/testdata/tiny.png b/core/artwork/thumbhash/testdata/tiny.png new file mode 100644 index 0000000000000000000000000000000000000000..ab635e11a904d71fce72f9361aa4fb420e9f5d18 GIT binary patch literal 70 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k92}1TpU9x<|J=edE=8j1IrPnj0%CR Qyg&&CPgg&ebxsLQ0L54j#Q*>R literal 0 HcmV?d00001 diff --git a/core/artwork/thumbhash/thumbhash.go b/core/artwork/thumbhash/thumbhash.go new file mode 100644 index 000000000..a255bec42 --- /dev/null +++ b/core/artwork/thumbhash/thumbhash.go @@ -0,0 +1,257 @@ +// Package thumbhash implements the ThumbHash encoding algorithm (https://github.com/evanw/thumbhash). +package thumbhash + +import ( + "errors" + "image" + "image/draw" + "math" + + xdraw "golang.org/x/image/draw" +) + +// maxInputSize is the algorithm's hard limit: larger inputs are rejected by every implementation. +const maxInputSize = 100 + +// term is one DCT coefficient's frequency pair, in the reference's triangular scan order. +type term struct{ cx, cy int } + +// Encode returns the ThumbHash of img: 24 bytes when opaque, 25 with alpha. Output matches +// evanw/thumbhash except where a coefficient lands on a quantization tie, where a nibble may differ by 1. +func Encode(img image.Image) ([]byte, error) { + rgba := toNRGBA(downscale(img)) + b := rgba.Bounds() + w, h := b.Dx(), b.Dy() + if w == 0 || h == 0 { + return nil, errors.New("thumbhash: empty image") + } + + avgR, avgG, avgB, avgA := averageColor(rgba, w, h) + hasAlpha := avgA < float64(w*h) + if avgA > 0 { + avgR /= avgA + avgG /= avgA + avgB /= avgA + } + + lLimit := 7.0 + if hasAlpha { + lLimit = 5.0 + } + maxWH := float64(max(w, h)) + lx := max(1, int(math.Round(lLimit*float64(w)/maxWH))) + ly := max(1, int(math.Round(lLimit*float64(h)/maxWH))) + + lTerms := terms(max(3, lx), max(3, ly)) + pTerms := terms(3, 3) + qTerms := terms(3, 3) + var aTerms []term + if hasAlpha { + aTerms = terms(5, 5) + } + + // The widest coefficient region wins: 3x3 chroma, 5x5 alpha, and luma's own lx by ly. + chan5 := 3 + if hasAlpha { + chan5 = 5 + } + nx := max(max(3, lx), chan5) + cosX := cosTable(nx, w) + cosY := cosTable(max(max(3, ly), chan5), h) + + lAcc := make([]float64, len(lTerms)) + pAcc := make([]float64, len(pTerms)) + qAcc := make([]float64, len(qTerms)) + aAcc := make([]float64, len(aTerms)) + rowL := make([]float64, nx) + rowP := make([]float64, nx) + rowQ := make([]float64, nx) + rowA := make([]float64, nx) + + for y := range h { + clear(rowL) + clear(rowP) + clear(rowQ) + clear(rowA) + row := rgba.Pix[y*rgba.Stride:] + for x := range w { + j := x * 4 + alpha := float64(row[j+3]) / 255 + r := avgR*(1-alpha) + alpha/255*float64(row[j]) + g := avgG*(1-alpha) + alpha/255*float64(row[j+1]) + bl := avgB*(1-alpha) + alpha/255*float64(row[j+2]) + lv := (r + g + bl) / 3 + pv := (r+g)/2 - bl + qv := r - g + for cx := range nx { + f := cosX[cx][x] + rowL[cx] += lv * f + rowP[cx] += pv * f + rowQ[cx] += qv * f + } + // hasAlpha is loop-invariant, so this costs a predicted branch rather than a quarter + // of the inner loop that opaque images never need. + if hasAlpha { + for cx := range nx { + rowA[cx] += alpha * cosX[cx][x] + } + } + } + accumulate(lAcc, lTerms, rowL, cosY, y) + accumulate(pAcc, pTerms, rowP, cosY, y) + accumulate(qAcc, qTerms, rowQ, cosY, y) + accumulate(aAcc, aTerms, rowA, cosY, y) + } + + n := float64(w * h) + lDC, lAC, lScale := normalize(lAcc, n) + pDC, pAC, pScale := normalize(pAcc, n) + qDC, qAC, qScale := normalize(qAcc, n) + aDC, aAC, aScale := normalize(aAcc, n) + + return pack(w, h, hasAlpha, lx, ly, + lDC, pDC, qDC, aDC, lScale, pScale, qScale, aScale, lAC, pAC, qAC, aAC), nil +} + +// terms lists the (cx, cy) pairs of the reference's triangular coefficient region, in write order. +func terms(nx, ny int) []term { + var ts []term + for cy := range ny { + for cx := 0; cx*ny < nx*(ny-cy); cx++ { + ts = append(ts, term{cx, cy}) + } + } + return ts +} + +// cosTable precomputes cos(pi/size * c * (i+0.5)) with the reference's exact expression, so the +// table values are bit-identical to recomputing them per coefficient. +func cosTable(n, size int) [][]float64 { + t := make([][]float64, n) + for c := range n { + t[c] = make([]float64, size) + for i := range size { + t[c][i] = math.Cos(math.Pi / float64(size) * float64(c) * (float64(i) + 0.5)) + } + } + return t +} + +func averageColor(rgba *image.NRGBA, w, h int) (r, g, b, a float64) { + for y := range h { + row := rgba.Pix[y*rgba.Stride:] + for x := range w { + j := x * 4 + alpha := float64(row[j+3]) / 255 + r += alpha / 255 * float64(row[j]) + g += alpha / 255 * float64(row[j+1]) + b += alpha / 255 * float64(row[j+2]) + a += alpha + } + } + return r, g, b, a +} + +// accumulate folds one row's per-cx sums into the term accumulators, so the pixel loop costs +// nx multiplies per pixel instead of one per coefficient. +func accumulate(acc []float64, ts []term, row []float64, cosY [][]float64, y int) { + for k, t := range ts { + acc[k] += row[t.cx] * cosY[t.cy][y] + } +} + +// normalize splits the accumulators into DC and scaled AC terms, matching the reference: a constant +// image leaves scale at 0, which skips normalization rather than mapping the terms to the midpoint. +func normalize(acc []float64, n float64) (dc float64, ac []float64, scale float64) { + if len(acc) == 0 { + return 0, nil, 0 + } + dc = acc[0] / n + ac = make([]float64, len(acc)-1) + for i, v := range acc[1:] { + ac[i] = v / n + scale = max(scale, math.Abs(ac[i])) + } + if scale > 0 { + for i := range ac { + ac[i] = 0.5 + 0.5/scale*ac[i] + } + } + return dc, ac, scale +} + +func pack(w, h int, hasAlpha bool, lx, ly int, + lDC, pDC, qDC, aDC, lScale, pScale, qScale, aScale float64, + lAC, pAC, qAC, aAC []float64, +) []byte { + isLandscape := 0 + if w > h { + isLandscape = 1 + } + alphaBit := 0 + if hasAlpha { + alphaBit = 1 + } + header24 := int(math.Round(63*lDC)) | int(math.Round(31.5+31.5*pDC))<<6 | + int(math.Round(31.5+31.5*qDC))<<12 | int(math.Round(31*lScale))<<18 | alphaBit<<23 + lead := lx + if isLandscape == 1 { + lead = ly + } + header16 := lead | int(math.Round(63*pScale))<<3 | int(math.Round(63*qScale))<<9 | isLandscape<<15 + + acs := [][]float64{lAC, pAC, qAC} + acStart := 5 + if hasAlpha { + acs = append(acs, aAC) + acStart = 6 + } + acCount := 0 + for _, ac := range acs { + acCount += len(ac) + } + + hash := make([]byte, acStart+(acCount+1)/2) + hash[0] = byte(header24 & 255) + hash[1] = byte((header24 >> 8) & 255) + hash[2] = byte(header24 >> 16) + hash[3] = byte(header16 & 255) + hash[4] = byte(header16 >> 8) + if hasAlpha { + hash[5] = byte(int(math.Round(15*aDC)) | int(math.Round(15*aScale))<<4) + } + acIndex := 0 + for _, ac := range acs { + for _, f := range ac { + hash[acStart+(acIndex>>1)] |= byte(int(math.Round(15*f)) << ((acIndex & 1) << 2)) + acIndex++ + } + } + return hash +} + +// NRGBA, not RGBA: ThumbHash requires non-premultiplied RGB and the pipeline hands us a +// premultiplied *image.RGBA, which draw.Draw un-premultiplies on the way in. +func toNRGBA(img image.Image) *image.NRGBA { + // Conservative: a sub-image re-slices Pix so the loops would read it correctly too, but the + // copy costs nothing on the origin-anchored images the pipeline actually produces. + if nrgba, ok := img.(*image.NRGBA); ok && nrgba.Rect.Min == (image.Point{}) { + return nrgba + } + b := img.Bounds() + dst := image.NewNRGBA(image.Rect(0, 0, b.Dx(), b.Dy())) + draw.Draw(dst, dst.Bounds(), img, b.Min, draw.Src) + return dst +} + +func downscale(img image.Image) image.Image { + b := img.Bounds() + w, h := b.Dx(), b.Dy() + if w <= maxInputSize && h <= maxInputSize { + return img + } + scale := float64(maxInputSize) / float64(max(w, h)) + dst := image.NewNRGBA(image.Rect(0, 0, max(1, int(float64(w)*scale)), max(1, int(float64(h)*scale)))) + xdraw.ApproxBiLinear.Scale(dst, dst.Bounds(), img, b, draw.Src, nil) + return dst +} diff --git a/core/artwork/thumbhash/thumbhash_suite_test.go b/core/artwork/thumbhash/thumbhash_suite_test.go new file mode 100644 index 000000000..11a6f0e25 --- /dev/null +++ b/core/artwork/thumbhash/thumbhash_suite_test.go @@ -0,0 +1,17 @@ +package thumbhash_test + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestThumbHash(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "ThumbHash Suite") +} diff --git a/core/artwork/thumbhash/thumbhash_test.go b/core/artwork/thumbhash/thumbhash_test.go new file mode 100644 index 000000000..4832c25bc --- /dev/null +++ b/core/artwork/thumbhash/thumbhash_test.go @@ -0,0 +1,194 @@ +package thumbhash_test + +import ( + "encoding/base64" + "encoding/json" + "image" + "image/color" + "image/draw" + _ "image/png" + "os" + "path/filepath" + "runtime" + "slices" + + "github.com/navidrome/navidrome/core/artwork/thumbhash" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// testdataDir is resolved via runtime.Caller because tests.Init (thumbhash_suite_test.go) chdirs +// the process to the repo root, which would break a plain relative "testdata" path. +var testdataDir = func() string { + _, file, _, _ := runtime.Caller(0) + return filepath.Join(filepath.Dir(file), "testdata") +}() + +// fixtureImage decodes a testdata PNG. NRGBA, not RGBA: ThumbHash needs non-premultiplied pixels, +// and RGBA would silently premultiply every fixture that has alpha. +func fixtureImage(name string) *image.NRGBA { + GinkgoHelper() + f, err := os.Open(filepath.Join(testdataDir, name)) + Expect(err).ToNot(HaveOccurred()) + defer f.Close() + src, _, err := image.Decode(f) + Expect(err).ToNot(HaveOccurred()) + b := src.Bounds() + dst := image.NewNRGBA(image.Rect(0, 0, b.Dx(), b.Dy())) + draw.Draw(dst, dst.Bounds(), src, b.Min, draw.Src) + return dst +} + +// headerOnlyFixtures have mathematically-zero AC content, so every AC nibble is float rounding +// noise sitting on a quantization tie; only the header bytes carry signal. +var headerOnlyFixtures = []string{"solid.png", "tiny.png"} + +func loadJSON[T any](name string) T { + GinkgoHelper() + data, err := os.ReadFile(filepath.Join(testdataDir, name)) + Expect(err).ToNot(HaveOccurred()) + var out T + Expect(json.Unmarshal(data, &out)).To(Succeed()) + return out +} + +func loadGoldens() map[string]string { + GinkgoHelper() + golden := loadJSON[map[string]string]("golden.json") + Expect(golden).ToNot(BeEmpty()) + return golden +} + +// mix is a stateless 32-bit finaliser matching gen_generated.mjs, so both languages build the +// same synthetic pixels and only the reference's hashes need committing. +func mix(n uint32) uint32 { + n = (n ^ (n >> 16)) * 2246822507 + n = (n ^ (n >> 13)) * 3266489909 + return n ^ (n >> 16) +} + +func generatedImage(i int) *image.NRGBA { + w := 1 + int(mix(uint32(i)*3+1)%100) + h := 1 + int(mix(uint32(i)*3+2)%100) + img := image.NewNRGBA(image.Rect(0, 0, w, h)) + for k := range img.Pix { + img.Pix[k] = byte(mix(uint32(i)*1000003 + uint32(k))) + } + if i%2 == 0 { + for k := 3; k < len(img.Pix); k += 4 { + img.Pix[k] = 255 + } + } + return img +} + +var _ = Describe("Encode", func() { + It("matches every golden vector", func() { + for name, want := range loadGoldens() { + if slices.Contains(headerOnlyFixtures, name) { + continue // see the dedicated header-only spec below + } + got, err := thumbhash.Encode(fixtureImage(name)) + Expect(err).ToNot(HaveOccurred(), "fixture %s", name) + Expect(base64.StdEncoding.EncodeToString(got)).To(Equal(want), "fixture %s", name) + } + }) + + It("reproduces the well-conditioned header of the ill-conditioned fixtures", func() { + for _, name := range headerOnlyFixtures { + want, err := base64.StdEncoding.DecodeString(loadGoldens()[name]) + Expect(err).ToNot(HaveOccurred(), "fixture %s", name) + got, err := thumbhash.Encode(fixtureImage(name)) + Expect(err).ToNot(HaveOccurred(), "fixture %s", name) + Expect(got[:5]).To(Equal(want[:5]), "fixture %s header bytes", name) + } + }) + + // The PNG fixtures cannot reach every layout; these sweep random sizes, aspects and both the + // 7x7 no-alpha and 5x5-plus-alpha coefficient regions against the same reference. + It("matches the reference on 300 generated images", func() { + vectors := loadJSON[[]struct { + W, H int + Hash string + }]("generated.json") + Expect(vectors).ToNot(BeEmpty()) + for i, want := range vectors { + img := generatedImage(i) + Expect(img.Bounds().Dx()).To(Equal(want.W), "vector %d width", i) + Expect(img.Bounds().Dy()).To(Equal(want.H), "vector %d height", i) + got, err := thumbhash.Encode(img) + Expect(err).ToNot(HaveOccurred(), "vector %d", i) + Expect(base64.StdEncoding.EncodeToString(got)).To(Equal(want.Hash), "vector %d (%dx%d)", i, want.W, want.H) + } + }) + + It("quantizes a uniform image's scales to zero", func() { + got, err := thumbhash.Encode(fixtureImage("solid.png")) + Expect(err).ToNot(HaveOccurred()) + header24 := int(got[0]) | int(got[1])<<8 | int(got[2])<<16 + header16 := int(got[3]) | int(got[4])<<8 + Expect((header24>>18)&31).To(Equal(0), "lScale") + Expect((header16>>3)&63).To(Equal(0), "pScale") + Expect((header16>>9)&63).To(Equal(0), "qScale") + }) + + It("produces 24 bytes for a square opaque image", func() { + got, err := thumbhash.Encode(fixtureImage("square.png")) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(HaveLen(24)) + }) + + It("produces 25 bytes when the image has alpha", func() { + got, err := thumbhash.Encode(fixtureImage("alpha.png")) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(HaveLen(25)) + }) + + It("downscales an oversized image rather than failing", func() { + img := image.NewNRGBA(image.Rect(0, 0, 500, 300)) + for i := range img.Pix { + img.Pix[i] = byte(i) + } + got, err := thumbhash.Encode(img) + Expect(err).ToNot(HaveOccurred()) + Expect(len(got)).To(BeNumerically(">=", 5)) + }) + + It("encodes a 1x1 image", func() { + img := image.NewNRGBA(image.Rect(0, 0, 1, 1)) + img.Set(0, 0, color.NRGBA{R: 60, G: 120, B: 180, A: 255}) + got, err := thumbhash.Encode(img) + Expect(err).ToNot(HaveOccurred()) + Expect(got).ToNot(BeEmpty()) + }) + + It("encodes a sub-image like an origin-anchored copy of the same region", func() { + parent := image.NewNRGBA(image.Rect(0, 0, 60, 50)) + for i := range parent.Pix { + parent.Pix[i] = byte(i * 7 % 251) + } + region := image.Rect(10, 7, 40, 30) + cropped := image.NewNRGBA(image.Rect(0, 0, region.Dx(), region.Dy())) + draw.Draw(cropped, cropped.Bounds(), parent, region.Min, draw.Src) + + got, err := thumbhash.Encode(parent.SubImage(region)) + Expect(err).ToNot(HaveOccurred()) + want, err := thumbhash.Encode(cropped) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(Equal(want)) + }) + + It("rejects an empty image", func() { + _, err := thumbhash.Encode(image.NewRGBA(image.Rect(0, 0, 0, 0))) + Expect(err).To(HaveOccurred()) + }) + + It("is deterministic", func() { + img := fixtureImage("square.png") + first, err := thumbhash.Encode(img) + Expect(err).ToNot(HaveOccurred()) + second, err := thumbhash.Encode(img) + Expect(err).ToNot(HaveOccurred()) + Expect(first).To(Equal(second)) + }) +}) diff --git a/core/image_upload.go b/core/artwork/uploader.go similarity index 55% rename from core/image_upload.go rename to core/artwork/uploader.go index eb61b225a..9c38ae2e5 100644 --- a/core/image_upload.go +++ b/core/artwork/uploader.go @@ -1,4 +1,4 @@ -package core +package artwork import ( "context" @@ -15,13 +15,7 @@ import ( "github.com/navidrome/navidrome/utils" ) -type ImageUploadService interface { - SetImage(ctx context.Context, entityType string, entityID string, name string, oldPath string, reader io.Reader, ext string) (filename string, err error) - 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. +// MaxImageUploadSize returns the configured max upload size in bytes, or the built-in default. func MaxImageUploadSize() int64 { if size, err := humanize.ParseBytes(conf.Server.MaxImageUploadSize); err == nil && size > 0 { return int64(size) @@ -30,13 +24,30 @@ func MaxImageUploadSize() int64 { return int64(size) } -type imageUploadService struct{} - -func NewImageUploadService() ImageUploadService { - return &imageUploadService{} +// Uploader stores a user-uploaded entity image and invalidates that entity's artwork state. +type Uploader interface { + SetImage(ctx context.Context, entityType string, entityID string, name string, oldPath string, reader io.Reader, ext string) (filename string, err error) + RemoveImage(ctx context.Context, path string) error + // EnqueueArtwork re-resolves the item's artwork. Call it AFTER persisting the new + // filename, or the worker resolves the old one. + EnqueueArtwork(ctx context.Context, entityType, entityID string) } -func (s *imageUploadService) SetImage(ctx context.Context, entityType string, entityID string, name string, oldPath string, reader io.Reader, ext string) (string, error) { +var uploadEntityKind = map[string]model.Kind{ + consts.EntityArtist: model.KindArtistArtwork, + consts.EntityPlaylist: model.KindPlaylistArtwork, + consts.EntityRadio: model.KindRadioArtwork, +} + +type uploader struct { + ds model.DataStore +} + +func NewUploader(ds model.DataStore) Uploader { + return &uploader{ds: ds} +} + +func (s *uploader) SetImage(ctx context.Context, entityType string, entityID string, name string, oldPath string, reader io.Reader, ext string) (string, error) { filename := imageFilename(entityID, name, ext) absPath := model.UploadedImagePath(entityType, filename) @@ -44,14 +55,12 @@ func (s *imageUploadService) SetImage(ctx context.Context, entityType string, en return "", fmt.Errorf("creating image directory: %w", err) } - // Remove old image if it exists if oldPath != "" { if err := os.Remove(oldPath); err != nil && !os.IsNotExist(err) { - log.Warn(ctx, "Failed to remove old image", "path", oldPath, err) + log.Warn(ctx, "Artwork: Failed to remove old image", "path", oldPath, err) } } - // Save new image f, err := os.Create(absPath) if err != nil { return "", fmt.Errorf("creating image file: %w", err) @@ -61,11 +70,20 @@ func (s *imageUploadService) SetImage(ctx context.Context, entityType string, en if _, err := io.Copy(f, reader); err != nil { return "", fmt.Errorf("writing image file: %w", err) } - return filename, nil } -func (s *imageUploadService) RemoveImage(ctx context.Context, path string) error { +func (s *uploader) EnqueueArtwork(ctx context.Context, entityType, id string) { + kind, ok := uploadEntityKind[entityType] + if !ok { + return + } + if err := Refresh(ctx, s.ds, kind, id); err != nil { + log.Warn(ctx, "Artwork: Could not refresh artwork after upload", "kind", kind, "id", id, err) + } +} + +func (s *uploader) RemoveImage(ctx context.Context, path string) error { if path == "" { return nil } diff --git a/core/image_upload_test.go b/core/artwork/uploader_test.go similarity index 60% rename from core/image_upload_test.go rename to core/artwork/uploader_test.go index e7648df34..44f5ede26 100644 --- a/core/image_upload_test.go +++ b/core/artwork/uploader_test.go @@ -1,4 +1,4 @@ -package core_test +package artwork import ( "context" @@ -9,20 +9,26 @@ import ( "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/model" + "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) -var _ = Describe("ImageUploadService", func() { - var svc core.ImageUploadService +var _ = Describe("Uploader", func() { + var svc Uploader var tmpDir string + var artRepo *tests.MockArtworkRepo + var queueRepo *tests.MockArtworkQueueRepo BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) tmpDir = GinkgoT().TempDir() conf.Server.DataFolder = conf.NewDir(tmpDir) - svc = core.NewImageUploadService() + artRepo = tests.CreateMockArtworkRepo() + queueRepo = tests.CreateMockArtworkQueueRepo() + ds := &tests.MockDataStore{MockedArtwork: artRepo, MockedArtworkQueue: queueRepo} + svc = NewUploader(ds) }) Describe("SetImage", func() { @@ -69,6 +75,49 @@ var _ = Describe("ImageUploadService", func() { _, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "Name", "/nonexistent/path.jpg", reader, ".jpg") Expect(err).ToNot(HaveOccurred()) }) + + It("does not touch artwork state or the queue (that is EnqueueArtwork's job, post-Put)", func() { + ctx := context.Background() + Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ + ItemKind: "ar", ItemID: "ar-1", Hash: "oldhash", Source: "external", + })).To(Succeed()) + + _, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "Pink Floyd", "", strings.NewReader("img"), ".jpg") + Expect(err).ToNot(HaveOccurred()) + + // SetImage only writes the file; the state row survives and nothing is queued until + // the caller has persisted the new filename and called EnqueueArtwork. + _, err = artRepo.GetItemArtwork(model.KindArtistArtwork, "ar-1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(queueRepo.DequeueBatch(1000)).To(BeEmpty()) + }) + }) + + Describe("EnqueueArtwork", func() { + It("clears artwork state and enqueues a Bump", func() { + ctx := context.Background() + Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ + ItemKind: "ar", ItemID: "ar-1", Hash: "oldhash", Source: "external", + })).To(Succeed()) + + svc.EnqueueArtwork(ctx, consts.EntityArtist, "ar-1") + + _, err := artRepo.GetItemArtwork(model.KindArtistArtwork, "ar-1", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + + queued, err := queueRepo.DequeueBatch(1000) + Expect(err).ToNot(HaveOccurred()) + Expect(queued).To(ContainElement(SatisfyAll( + HaveField("ItemKind", "ar"), + HaveField("ItemID", "ar-1"), + HaveField("Priority", model.ArtworkPriorityBump), + ))) + }) + + It("is a no-op for an unknown entity type", func() { + svc.EnqueueArtwork(context.Background(), "unknown", "x-1") + Expect(queueRepo.DequeueBatch(1000)).To(BeEmpty()) + }) }) Describe("RemoveImage", func() { @@ -105,21 +154,21 @@ var _ = Describe("MaxImageUploadSize", func() { It("returns the configured size when valid", func() { conf.Server.MaxImageUploadSize = "20MB" - Expect(core.MaxImageUploadSize()).To(Equal(int64(20_000_000))) + Expect(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))) + 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(core.MaxImageUploadSize()).To(Equal(int64(10_000_000))) + Expect(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))) + Expect(MaxImageUploadSize()).To(Equal(int64(52_428_800))) }) }) diff --git a/core/artwork/wire_providers.go b/core/artwork/wire_providers.go index 63231b54a..54c2ad397 100644 --- a/core/artwork/wire_providers.go +++ b/core/artwork/wire_providers.go @@ -7,5 +7,7 @@ import ( var Set = wire.NewSet( NewArtwork, GetImageCache, - NewCacheWarmer, + NewWorker, + GetImageStore, + NewUploader, ) diff --git a/core/artwork/worker.go b/core/artwork/worker.go new file mode 100644 index 000000000..3042c47b0 --- /dev/null +++ b/core/artwork/worker.go @@ -0,0 +1,318 @@ +package artwork + +import ( + "bytes" + "cmp" + "context" + "io" + "math" + "math/rand/v2" + "sync" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/core/ffmpeg" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/utils/cache" +) + +const ( + workerPollInterval = 5 * time.Second + backoffBase = 5 * time.Second + // giveUpAfter bounds the retry budget from enqueue; past it the item falls to the + // periodic stale-absent recheck. + giveUpAfter = 12 * time.Hour +) + +// drainPool drains one class of work with its own slot budget, so a blocking kind cannot +// occupy slots another kind needs. +type drainPool struct { + name string + kinds []string + concurrency int +} + +// Worker drains the artwork queue: each external agent is rate-limited and circuit-broken +// independently, and pruneMu serializes prune against the store-write window. +type Worker struct { + proc *processor + cache cache.FileCache + ffmpeg ffmpeg.FFmpeg + broker events.Broker + pruneMu sync.RWMutex + pools []*drainPool + runCtx context.Context + + gatesMu sync.Mutex + gates map[string]*extGate +} + +func NewWorker(ds model.DataStore, store *ImageStore, ag *agents.Agents, ffmpeg ffmpeg.FFmpeg, broker events.Broker, imgCache cache.FileCache) *Worker { + w := &Worker{ + proc: &processor{ds: ds, store: store}, + cache: imgCache, + ffmpeg: ffmpeg, + broker: broker, + pools: newDrainPools(), + runCtx: context.Background(), + gates: map[string]*extGate{}, + } + w.proc.resolver = newResolver(ds, ag, ffmpeg, w.gate) + w.proc.pruneLock = w.pruneMu.RLocker() + return w +} + +// newDrainPools splits the drain by what bounds it: gate() holds a slot while waiting for its +// rate-limit permit, so a sleeping lookup would crowd out a cover sitting on disk. +func newDrainPools() []*drainPool { + budget := conf.MaxOpenConns() // floored at 4, so both remainders below stay positive + local := min(max(1, conf.Server.DevArtworkWorkerConcurrency), budget-1) + // More external slots than the rate allows would only sleep in the limiter. + external := min(max(2, 2*conf.Server.DevArtworkExternalMaxRPS), budget-local) + return []*drainPool{ + {name: "local", kinds: localDrainKinds, concurrency: local}, + {name: "external", kinds: externalDrainKinds, concurrency: external}, + } +} + +// Kind is a proxy for cost: an album that reaches an external agent still costs a local slot. +var ( + externalDrainKinds = []string{model.KindArtistArtwork.Prefix()} + localDrainKinds = []string{ + model.KindAlbumArtwork.Prefix(), + model.KindPlaylistArtwork.Prefix(), + model.KindRadioArtwork.Prefix(), + model.KindMediaFileArtwork.Prefix(), + } +) + +// Run blocks draining the queue until ctx is cancelled. +func (w *Worker) Run(ctx context.Context) error { + w.runCtx = ctx + var wg sync.WaitGroup + for _, p := range w.pools { + wg.Go(func() { w.runPool(ctx, p) }) + } + wg.Wait() + return nil +} + +func (w *Worker) runPool(ctx context.Context, p *drainPool) { + ticker := time.NewTicker(workerPollInterval) + defer ticker.Stop() + for { + n, err := w.drain(ctx, p.concurrency, p.kinds...) + if err != nil && ctx.Err() == nil { + log.Warn(ctx, "Artwork: Worker drain failed", "pool", p.name, err) + } + if ctx.Err() != nil { + return + } + if n > 0 { + continue + } + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } +} + +// RunPrune runs prune under the worker's write lock, so no acquisition can place +// a file while orphans are being reclaimed. This is the only sanctioned prune path. +func (w *Worker) RunPrune(ctx context.Context) error { + w.pruneMu.Lock() + defer w.pruneMu.Unlock() + return prune(ctx, w.proc.ds, w.proc.store) +} + +// Backfill enqueues every entity for re-resolution when the artwork config fingerprint changed, +// artists first. It reports whether anything was enqueued. +func (w *Worker) Backfill(ctx context.Context) (bool, error) { + return backfill(ctx, w.proc.ds) +} + +// EnqueueStaleAbsentAll requeues known-absent entries older than staleAbsentAge. +func (w *Worker) EnqueueStaleAbsentAll(ctx context.Context) error { + return enqueueStaleAbsentAll(ctx, w.proc.ds) +} + +// EnqueueMissingAll requeues entities with no artwork state row: the safety net for anything +// a scan never enqueued. +func (w *Worker) EnqueueMissingAll(ctx context.Context) error { + return enqueueMissingAll(ctx, w.proc.ds) +} + +func (w *Worker) drain(ctx context.Context, concurrency int, kinds ...string) (int, error) { + // Dequeue well past the pool size so a slow external lookup never idles the other slots. + // DequeueBatch does not mark rows taken, so this is one query per pass, not per slot. + items, err := w.proc.ds.ArtworkQueue(ctx).DequeueBatch(max(16, 4*concurrency), kinds...) + if err != nil { + return 0, err + } + if len(items) == 0 { + return 0, nil + } + drainStart := time.Now() + // Private playlists need an admin identity; resolved per drain because the worker can + // start before any admin exists. + ctx = auth.WithAdminUser(ctx, w.proc.ds) + sem := make(chan struct{}, concurrency) + var wg sync.WaitGroup + var refreshMu sync.Mutex + var refresh []model.ArtworkQueueItem + for _, item := range items { + select { + case sem <- struct{}{}: + case <-ctx.Done(): + wg.Wait() + return len(items), nil + } + wg.Go(func() { + defer func() { <-sem }() + out, got := w.process(ctx, item) + // Absent counts as a visible change too: clients must drop a previously-served + // immutable cover. + if out == outcomeFound || out == outcomeFoundStale || out == outcomeAbsent { + refreshMu.Lock() + refresh = append(refresh, item) + refreshMu.Unlock() + } + // Post-outcome: the queue row is already settled, so warming the cache can't + // block or alter queue ops. + if got != nil { + w.precache(ctx, got) + } + }) + } + wg.Wait() + w.broadcastRefresh(ctx, refresh) + log.Debug(ctx, "Artwork: Drained a batch", "kinds", kinds, "items", len(items), + "refreshed", len(refresh), "concurrency", concurrency, "elapsed", time.Since(drainStart)) + return len(items), nil +} + +// artworkKindToResource maps a kind to its UI resource name; media_file maps to "song", so +// this can't derive from Kind.String(). +var artworkKindToResource = map[model.Kind]string{ + model.KindAlbumArtwork: "album", + model.KindArtistArtwork: "artist", + model.KindPlaylistArtwork: "playlist", + model.KindRadioArtwork: "radio", + model.KindMediaFileArtwork: "song", +} + +// broadcastRefresh emits one coalesced RefreshResource for the batch, so UIs re-fetch the +// affected records and pick up the new coverArt id. +func (w *Worker) broadcastRefresh(ctx context.Context, found []model.ArtworkQueueItem) { + if len(found) == 0 { + return + } + event := &events.RefreshResource{} + byResource := map[string][]string{} + for _, it := range found { + kind, _ := model.ParseKind(it.ItemKind) + if res, ok := artworkKindToResource[kind]; ok { + byResource[res] = append(byResource[res], it.ItemID) + } + } + if len(byResource) == 0 { + return + } + for res, ids := range byResource { + event = event.With(res, ids...) + } + w.broker.SendBroadcastMessage(ctx, event) +} + +func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outcome, *acquired) { + item.ImageType = cmp.Or(item.ImageType, model.ImageTypePrimary) + out, got := w.proc.acquire(ctx, item) + + queue := w.proc.ds.ArtworkQueue(ctx) + switch out { + case outcomeFound, outcomeAbsent: + // A scan that re-enqueued this row mid-flight reset its retry_at, so the row survives + // here and the next drain re-resolves it. + if err := queue.DeleteIfUnchanged(item.ItemKind, item.ItemID, item.ImageType, item.RetryAt); err != nil { + log.Warn(ctx, "Artwork: Could not delete processed queue item", "kind", item.ItemKind, "id", item.ItemID, err) + } + case outcomeFoundStale, outcomeFailed: + retryAt := time.Now().Add(backoff(item.Attempts)) + if retryAt.Before(item.EnqueuedAt.Add(giveUpAfter)) { + // A mid-flight re-enqueue reset retry_at; stale backoff must not stomp its + // fresh, immediate eligibility. + if err := queue.MarkFailedIfUnchanged(item.ItemKind, item.ItemID, item.ImageType, item.RetryAt, retryAt); err != nil { + log.Warn(ctx, "Artwork: Could not reschedule failed queue item", "kind", item.ItemKind, "id", item.ItemID, err) + } + log.Debug(ctx, "Artwork: Rescheduled item", "kind", item.ItemKind, "id", item.ItemID, + "outcome", out, "attempts", item.Attempts+1, "retryIn", time.Until(retryAt), + "budgetLeft", time.Until(item.EnqueuedAt.Add(giveUpAfter))) + break + } + // Absent is only recoverable where a periodic recheck revisits it, so other kinds keep + // no row; art already being served is kept, as exhaustion means unreachable, not removed. + settled := "kept previous state" + if out == outcomeFailed && hasRecheckPath(item.ItemKind) && !w.hasResolvedArtwork(ctx, item) { + writeAbsent(ctx, w.proc.ds.Artwork(ctx), item) + settled = "recorded absent" + } + log.Info(ctx, "Artwork: Retry budget exhausted, giving up", "kind", item.ItemKind, "id", item.ItemID, + "outcome", out, "attempts", item.Attempts+1, "budget", giveUpAfter, "settled", settled) + if err := queue.DeleteIfUnchanged(item.ItemKind, item.ItemID, item.ImageType, item.RetryAt); err != nil { + log.Warn(ctx, "Artwork: Could not remove exhausted queue item", "kind", item.ItemKind, "id", item.ItemID, err) + } + } + return out, got +} + +func (w *Worker) hasResolvedArtwork(ctx context.Context, item model.ArtworkQueueItem) bool { + kind, ok := model.ParseKind(item.ItemKind) + if !ok { + return false + } + ia, err := w.proc.ds.Artwork(ctx).GetItemArtwork(kind, item.ItemID, item.ImageType) + return err == nil && ia.Hash != "" +} + +// precache warms the resize cache at the UI cover size from the bytes just acquired, so the +// first UI request hits without re-reading the rows or the file. +func (w *Worker) precache(ctx context.Context, got *acquired) { + if !conf.Server.EnableArtworkPrecache || w.cache == nil || w.cache.Disabled(ctx) { + return + } + precacheStart := time.Now() + // Same key as the serving path: square must match what the list surfaces request, or this + // warms a key nothing reads. + item := &resizedItem{ + hash: got.ia.Hash, + size: conf.Server.UICoverArtSize, + square: true, + ffmpeg: w.ffmpeg, + open: func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(got.data)), nil }, + } + stream, err := w.cache.Get(ctx, item) + if err != nil { + log.Debug(ctx, "Artwork: Precache failed", "kind", got.ia.ItemKind, "id", got.ia.ItemID, err) + return + } + _, _ = io.Copy(io.Discard, stream) + _ = stream.Close() + log.Trace(ctx, "Artwork: Precached UI size", "kind", got.ia.ItemKind, "id", got.ia.ItemID, + "size", conf.Server.UICoverArtSize, "elapsed", time.Since(precacheStart)) +} + +// backoffFor returns min(5s×4^n, giveUpAfter) scaled by (1+jitter), with jitter in [-0.4, 0.4]. +func backoffFor(attempts int, jitter float64) time.Duration { + d := min(float64(backoffBase)*math.Pow(4, float64(attempts)), float64(giveUpAfter)) + return time.Duration(d * (1 + jitter)) +} + +func backoff(attempts int) time.Duration { + return backoffFor(attempts, rand.Float64()*0.8-0.4) //nolint:gosec // retry jitter, not security-sensitive +} diff --git a/core/artwork/worker_soak_test.go b/core/artwork/worker_soak_test.go new file mode 100644 index 000000000..eb7346102 --- /dev/null +++ b/core/artwork/worker_soak_test.go @@ -0,0 +1,146 @@ +package artwork + +import ( + "context" + "io" + "os" + "runtime" + "testing" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Deliberately >2000: a leak guard favors a stable signal over speed. +const soakCycles = 2200 + +var _ = Describe("Worker soak", func() { + It("does not leak goroutines, heap, or fds over many acquisition cycles", func() { + if testing.Short() { + Skip("skipping soak test in short mode") + } + DeferCleanup(configtest.SetupConfig()) + + repoRoot, err := os.Getwd() + Expect(err).ToNot(HaveOccurred()) + + libRepo := &tests.MockLibraryRepo{} + libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) + folderRepo := &fakeFolderRepo{result: []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"cover.jpg"}, + }}} + ffm := tests.NewMockFFmpeg("") + ag := agents.GetAgents(&tests.MockDataStore{}, nil) + artRepo := tests.CreateMockArtworkRepo() + albumRepo := tests.CreateMockAlbumRepo() + albumRepo.SetData(model.Albums{ + {ID: "al-folder", Name: "Folder Album", FolderIDs: []string{"f1"}}, + {ID: "al-embed", Name: "Embedded Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}}, + }) + ds := &tests.MockDataStore{ + MockedFolder: folderRepo, + MockedLibrary: libRepo, + MockedArtwork: artRepo, + MockedAlbum: albumRepo, + } + store := NewImageStore(GinkgoT().TempDir()) + proc := &processor{ds: ds, store: store, resolver: newResolver(ds, ag, ffm, nil)} + conf.Server.CoverArtPriority = "cover.jpg, embedded" + + // Dangling refs mirror an entity deleted after being enqueued. + items := []model.ArtworkQueueItem{ + {ItemKind: "al", ItemID: "al-folder"}, + {ItemKind: "al", ItemID: "al-embed"}, + {ItemKind: "al", ItemID: "al-does-not-exist"}, + {ItemKind: "ra", ItemID: "ra-does-not-exist"}, + } + + fdCount := func() int { + if runtime.GOOS != "linux" { + return -1 + } + entries, err := os.ReadDir("/proc/self/fd") + if err != nil { + return -1 + } + return len(entries) + } + + settleGoroutines := func() int { + // Background goroutines wind down late, so poll for two consecutive equal samples. + prev := -1 + for range 100 { + runtime.GC() + n := runtime.NumGoroutine() + if n == prev { + return n + } + prev = n + time.Sleep(10 * time.Millisecond) + } + return prev + } + + baselineGoroutines := settleGoroutines() + baselineFDs := fdCount() + + var heapAt10Pct uint64 + start := time.Now() + for i := range soakCycles { + it := items[i%len(items)] + out, _ := proc.acquire(context.Background(), it) + + // Read-back exercises the surfaces a caller would use after acquisition. + if out == outcomeFound { + kind, _ := model.ParseKind(it.ItemKind) + ia, err := artRepo.GetItemArtwork(kind, it.ItemID, model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred(), "cycle %d: GetItemArtwork", i) + art, err := artRepo.GetImage(ia.Hash) + Expect(err).ToNot(HaveOccurred(), "cycle %d: GetImage", i) + rc, err := store.Open(ia.Hash, art.Mime) + switch { + case err == nil: + _, _ = io.Copy(io.Discard, rc) + rc.Close() + case os.IsNotExist(err): + // Folder-backed art has no store file; that's expected. + default: + Expect(err).ToNot(HaveOccurred(), "cycle %d: store.Open", i) + } + } + + if i == soakCycles/10 { + runtime.GC() + var ms runtime.MemStats + runtime.ReadMemStats(&ms) + heapAt10Pct = ms.HeapAlloc + } + } + elapsed := time.Since(start) + + finalGoroutines := settleGoroutines() + finalFDs := fdCount() + + runtime.GC() + var ms runtime.MemStats + runtime.ReadMemStats(&ms) + + GinkgoWriter.Printf("soak: cycles=%d elapsed=%s goroutines(baseline=%d final=%d) heap(10%%-mark=%d final=%d) fds(baseline=%d final=%d)\n", + soakCycles, elapsed, baselineGoroutines, finalGoroutines, heapAt10Pct, ms.HeapAlloc, baselineFDs, finalFDs) + + Expect(finalGoroutines).To(BeNumerically("<=", baselineGoroutines), "goroutine count grew: baseline=%d final=%d", baselineGoroutines, finalGoroutines) + if heapAt10Pct > 0 { + Expect(ms.HeapAlloc).To(BeNumerically("<=", 2*heapAt10Pct), "heap did not plateau: 10%%-mark=%d final=%d (final > 2x 10%%-mark)", heapAt10Pct, ms.HeapAlloc) + } + if runtime.GOOS == "linux" && baselineFDs >= 0 { + Expect(finalFDs).To(BeNumerically("<=", baselineFDs), "fd count grew: baseline=%d final=%d", baselineFDs, finalFDs) + } + }) +}) diff --git a/core/artwork/worker_test.go b/core/artwork/worker_test.go new file mode 100644 index 000000000..1f3b52840 --- /dev/null +++ b/core/artwork/worker_test.go @@ -0,0 +1,813 @@ +package artwork + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "os" + "slices" + "sync" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/utils/cache" + "github.com/navidrome/navidrome/utils/slice" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/goleak" +) + +type recordingCache struct { + cache.FileCache + mu sync.Mutex + keys []string + disabled bool +} + +func (c *recordingCache) Disabled(ctx context.Context) bool { + return c.disabled || c.FileCache.Disabled(ctx) +} + +func (c *recordingCache) Get(ctx context.Context, arg cache.Item) (*cache.CachedStream, error) { + c.mu.Lock() + c.keys = append(c.keys, arg.Key()) + c.mu.Unlock() + return c.FileCache.Get(ctx, arg) +} + +func (c *recordingCache) getKeys() []string { + c.mu.Lock() + defer c.mu.Unlock() + return slices.Clone(c.keys) +} + +// Simulates a concurrent Enqueue between DequeueBatch and the worker's delete, so +// DeleteIfUnchanged on the dequeued value no-ops. +type reenqueueOnDequeue struct { + *tests.MockArtworkQueueRepo + done bool +} + +func (r *reenqueueOnDequeue) DequeueBatch(n int, kinds ...string) ([]model.ArtworkQueueItem, error) { + items, err := r.MockArtworkQueueRepo.DequeueBatch(n, kinds...) + if !r.done && len(items) > 0 { + r.done = true + for k, it := range r.Data { + if it.ItemKind == items[0].ItemKind && it.ItemID == items[0].ItemID { + it.RetryAt = items[0].RetryAt.Add(time.Minute) + r.Data[k] = it + } + } + } + return items, err +} + +type fakeEventBroker struct { + http.Handler + mu sync.Mutex + events []events.Event +} + +func (f *fakeEventBroker) SendMessage(_ context.Context, event events.Event) { + f.mu.Lock() + defer f.mu.Unlock() + f.events = append(f.events, event) +} + +func (f *fakeEventBroker) SendBroadcastMessage(_ context.Context, event events.Event) { + f.mu.Lock() + defer f.mu.Unlock() + f.events = append(f.events, event) +} + +func (f *fakeEventBroker) getEvents() []events.Event { + f.mu.Lock() + defer f.mu.Unlock() + return slices.Clone(f.events) +} + +var _ events.Broker = (*fakeEventBroker)(nil) + +func findQueued(q *tests.MockArtworkQueueRepo, kind, id string) *model.ArtworkQueueItem { + for _, it := range q.Data { + if it.ItemKind == kind && it.ItemID == id { + return &it + } + } + return nil +} + +var _ = Describe("Worker", func() { + var ( + ctx context.Context + ds *tests.MockDataStore + folderRepo *fakeFolderRepo + libRepo *tests.MockLibraryRepo + ffm *tests.MockFFmpeg + ag *agents.Agents + store *ImageStore + artRepo *tests.MockArtworkRepo + queueRepo *tests.MockArtworkQueueRepo + broker *fakeEventBroker + imgCache *recordingCache + repoRoot string + w *Worker + ) + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + ctx = context.Background() + var err error + repoRoot, err = os.Getwd() + Expect(err).ToNot(HaveOccurred()) + conf.Server.CacheFolder = conf.NewDir(GinkgoT().TempDir()) + + folderRepo = &fakeFolderRepo{} + libRepo = &tests.MockLibraryRepo{} + libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) + ffm = tests.NewMockFFmpeg("") + ag = agents.GetAgents(&tests.MockDataStore{}, nil) + artRepo = tests.CreateMockArtworkRepo() + queueRepo = tests.CreateMockArtworkQueueRepo() + ds = &tests.MockDataStore{ + MockedFolder: folderRepo, + MockedLibrary: libRepo, + MockedArtwork: artRepo, + MockedArtworkQueue: queueRepo, + } + ds.MockedAlbum = tests.CreateMockAlbumRepo() + store = NewImageStore(GinkgoT().TempDir()) + conf.Server.CoverArtPriority = "cover.jpg, embedded" + conf.Server.DevArtworkExternalMaxRPS = 1000 // keep the limiter out of the way of behavior tests + broker = &fakeEventBroker{} + imgCache = &recordingCache{FileCache: cache.NewFileCache("WorkerTest", "100MB", "images", 0, + func(ctx context.Context, arg cache.Item) (io.Reader, error) { + return arg.(artworkReader).Reader(ctx) + })} + Eventually(func() bool { return imgCache.Available(ctx) }).Should(BeTrue()) + w = NewWorker(ds, store, ag, ffm, broker, imgCache) + }) + + Describe("drain", func() { + It("processes a seeded queue item and removes it from the queue", func() { + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"cover.jpg"}, + }} + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "al1", Name: "Album", FolderIDs: []string{"f1"}}, + }) + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ + ItemKind: "al", ItemID: "al1", Priority: model.ArtworkPriorityScan, + })).To(Succeed()) + + n, err := w.drain(ctx, 2) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(1)) + + ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Source).To(Equal("folder")) + + count, err := queueRepo.Count() + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(BeZero(), "a found item must be deleted from the queue") + }) + + It("processes an mf queue item, writing state and storing embedded bytes", func() { + conf.Server.EnableMediaFileCoverArt = true + ds.MockedMediaFile = tests.CreateMockMediaFileRepo() + ds.MockedMediaFile.(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "mf1", LibraryID: 0, Path: "tests/fixtures/artist/an-album/test.mp3", HasCoverArt: true}, + }) + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ + ItemKind: "mf", ItemID: "mf1", Priority: model.ArtworkPriorityBump, + })).To(Succeed()) + + n, err := w.drain(ctx, 2) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(1)) + + ia, err := artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Source).To(Equal("embedded")) + Expect(ia.Hash).ToNot(BeEmpty()) + + art, err := artRepo.GetImage(ia.Hash) + Expect(err).ToNot(HaveOccurred()) + r, err := store.Open(ia.Hash, art.Mime) + Expect(err).ToNot(HaveOccurred()) + defer r.Close() + data, err := io.ReadAll(r) + Expect(err).ToNot(HaveOccurred()) + Expect(data).ToNot(BeEmpty(), "embedded bytes must be written to the store") + + count, err := queueRepo.Count() + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(BeZero()) + }) + + It("reschedules a failed item via MarkFailed with a backed-off retry_at", func() { + conf.Server.CoverArtPriority = "external" + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al4", Name: "Album"}}) + imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4"})).To(Succeed()) + + n, err := w.drain(ctx, 2) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(1)) + + it := findQueued(queueRepo, "al", "al4") + Expect(it).ToNot(BeNil()) + Expect(it.Attempts).To(Equal(1)) + Expect(it.RetryAt).To(BeTemporally(">", time.Now())) + + _, err = artRepo.GetItemArtwork(model.KindAlbumArtwork, "al4", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound), "a timeout must never settle on absent") + }) + + It("reschedules a found-stale item via MarkFailed while keeping its served state", func() { + conf.Server.CoverArtPriority = "external, cover.jpg" + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"cover.jpg"}, + }} + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "alstale", Name: "Album", FolderIDs: []string{"f1"}}, + }) + imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "alstale"})).To(Succeed()) + + n, err := w.drain(ctx, 2) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(1)) + + it := findQueued(queueRepo, "al", "alstale") + Expect(it).ToNot(BeNil(), "a found-stale row must survive for a higher-priority retry") + Expect(it.Attempts).To(Equal(1)) + Expect(it.RetryAt).To(BeTemporally(">", time.Now())) + + ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alstale", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Source).To(Equal("folder"), "the fallback art is served meanwhile") + + evts := broker.getEvents() + Expect(evts).To(HaveLen(1), "the served fallback art must live-refresh the UI") + Expect(evts[0].(*events.RefreshResource).Data(evts[0])).To(ContainSubstring("alstale")) + }) + + It("keeps a row re-enqueued between dequeue and delete", func() { + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"cover.jpg"}, + }} + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "al7", Name: "Album", FolderIDs: []string{"f1"}}, + }) + racing := &reenqueueOnDequeue{MockArtworkQueueRepo: queueRepo} + ds.MockedArtworkQueue = racing + w = NewWorker(ds, store, ag, ffm, broker, imgCache) + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ + ItemKind: "al", ItemID: "al7", Priority: model.ArtworkPriorityScan, + })).To(Succeed()) + + n, err := w.drain(ctx, 1) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(1)) + + // The concurrent re-enqueue changed retry_at, so the found-path delete was a no-op. + Expect(findQueued(queueRepo, "al", "al7")).ToNot(BeNil()) + ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al7", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Source).To(Equal("folder")) + }) + + It("keeps a fresh re-enqueue ahead of a stale failure backoff", func() { + conf.Server.CoverArtPriority = "external" + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al8", Name: "Album"}}) + imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) + racing := &reenqueueOnDequeue{MockArtworkQueueRepo: queueRepo} + ds.MockedArtworkQueue = racing + w = NewWorker(ds, store, ag, ffm, broker, imgCache) + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al8"})).To(Succeed()) + dequeued := findQueued(queueRepo, "al", "al8").RetryAt + + n, err := w.drain(ctx, 1) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(1)) + + // The re-enqueue reset retry_at; the failure path must not stomp it nor bump attempts. + it := findQueued(queueRepo, "al", "al8") + Expect(it).ToNot(BeNil()) + Expect(it.Attempts).To(BeZero()) + Expect(it.RetryAt).To(BeTemporally("==", dequeued.Add(time.Minute))) + }) + + It("gives up and settles absent once the retry budget is exhausted", func() { + conf.Server.CoverArtPriority = "external" + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al9", Name: "Album"}}) + imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) + w = NewWorker(ds, store, ag, ffm, broker, imgCache) + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al9"})).To(Succeed()) + // Age the row past the retry budget. + for k, v := range queueRepo.Data { + if v.ItemID == "al9" { + v.EnqueuedAt = time.Now().Add(-(giveUpAfter + time.Hour)) + queueRepo.Data[k] = v + } + } + + n, err := w.drain(ctx, 1) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(1)) + + Expect(findQueued(queueRepo, "al", "al9")).To(BeNil()) + ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al9", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Hash).To(BeEmpty()) + }) + + It("keeps already-served art when the retry budget is exhausted", func() { + conf.Server.CoverArtPriority = "external" + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al10", Name: "Album"}}) + Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ + ItemKind: "al", ItemID: "al10", ImageType: model.ImageTypePrimary, + Hash: "cafebabe", Source: "external:lastfm", + })).To(Succeed()) + imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) + w = NewWorker(ds, store, ag, ffm, broker, imgCache) + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al10"})).To(Succeed()) + for k, v := range queueRepo.Data { + if v.ItemID == "al10" { + v.EnqueuedAt = time.Now().Add(-(giveUpAfter + time.Hour)) + queueRepo.Data[k] = v + } + } + + n, err := w.drain(ctx, 1) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(1)) + + Expect(findQueued(queueRepo, "al", "al10")).To(BeNil()) + ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al10", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Hash).To(Equal("cafebabe"), "a persistent outage must not discard served art") + }) + + // Media files are excluded from recheckKinds, so an absent row here would never be + // revisited: a transient read error would look permanent. + It("does not settle absent on exhaustion for a kind with no recheck path", func() { + conf.Server.EnableMediaFileCoverArt = true + ds.MockedMediaFile = tests.CreateMockMediaFileRepo() + ds.MockedMediaFile.(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "mfX", LibraryID: 0, Path: "tests/fixtures/artist/an-album/gone.mp3", HasCoverArt: true}, + }) + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "mf", ItemID: "mfX"})).To(Succeed()) + for k, v := range queueRepo.Data { + if v.ItemID == "mfX" { + v.EnqueuedAt = time.Now().Add(-(giveUpAfter + time.Hour)) + queueRepo.Data[k] = v + } + } + + n, err := w.drain(ctx, 1) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(1)) + + Expect(findQueued(queueRepo, "mf", "mfX")).To(BeNil(), "the row must stop retrying") + _, err = artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mfX", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound), + "no row leaves the track unresolved, so a later view can still recover it") + }) + + It("resolves a private playlist under an admin context instead of failing forever", func() { + ds.MockedUser = adminUserRepo() + vds := &visibilityPlaylistDS{ + MockDataStore: ds, + private: model.Playlist{ID: "plPriv", OwnerID: "admin"}, + tracks: &tests.MockPlaylistTrackRepo{}, + } + w = NewWorker(vds, store, ag, ffm, broker, imgCache) + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plPriv"})).To(Succeed()) + + n, err := w.drain(ctx, 1) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(1)) + + Expect(findQueued(queueRepo, "pl", "plPriv")).To(BeNil()) + ia, err := artRepo.GetItemArtwork(model.KindPlaylistArtwork, "plPriv", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Hash).To(BeEmpty()) + }) + + It("returns zero when the queue is empty", func() { + n, err := w.drain(ctx, 2) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(BeZero()) + }) + + It("broadcasts a single refresh event for the found items in a batch", func() { + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"cover.jpg"}, + }} + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "al1", Name: "Album 1", FolderIDs: []string{"f1"}}, + {ID: "al2", Name: "Album 2", FolderIDs: []string{"f1"}}, + }) + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1", Priority: model.ArtworkPriorityScan})).To(Succeed()) + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2", Priority: model.ArtworkPriorityScan})).To(Succeed()) + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar1", Priority: model.ArtworkPriorityScan})).To(Succeed()) + + n, err := w.drain(ctx, 3) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(3)) + + evts := broker.getEvents() + Expect(evts).To(HaveLen(1), "exactly one coalesced event per drain batch") + rr, ok := evts[0].(*events.RefreshResource) + Expect(ok).To(BeTrue()) + data := rr.Data(rr) + Expect(data).To(ContainSubstring(`"album"`)) + Expect(data).To(ContainSubstring("al1")) + Expect(data).To(ContainSubstring("al2")) + Expect(data).ToNot(ContainSubstring("artist"), "a failed (unresolved) artist must not be refreshed") + Expect(data).ToNot(ContainSubstring("ar1")) + }) + + DescribeTable("only lists the kinds it actually resolved", + func(kinds []string, wantSong bool) { + items := slice.Map(kinds, func(k string) model.ArtworkQueueItem { + return model.ArtworkQueueItem{ItemKind: k, ItemID: k + "1"} + }) + w.broadcastRefresh(ctx, items) + + evts := broker.getEvents() + Expect(evts).To(HaveLen(1)) + data := evts[0].(*events.RefreshResource).Data(evts[0]) + if wantSong { + Expect(data).To(ContainSubstring(`"song"`)) + } else { + Expect(data).ToNot(ContainSubstring(`"song"`)) + } + }, + // An album's tracks inherit its art, but the dependent ids are unbounded: the client + // fans an album refresh out to the tracks it has loaded. + Entry("album alone does not name songs", []string{"al"}, false), + Entry("artist alone does not", []string{"ar"}, false), + Entry("playlist alone does not", []string{"pl"}, false), + Entry("album mixed with others still does not", []string{"ar", "al"}, false), + Entry("songs resolving on their own are listed by id", []string{"mf"}, true), + ) + + It("broadcasts a refresh when an item resolves to absent (removed cover)", func() { + conf.Server.CoverArtPriority = "cover.*" // local-only; no folder image → absent + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al3", Name: "Artless"}}) + folderRepo.result = nil + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3", Priority: model.ArtworkPriorityScan})).To(Succeed()) + + n, err := w.drain(ctx, 2) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(1)) + + evts := broker.getEvents() + Expect(evts).To(HaveLen(1), "a removed cover must live-refresh clients so they drop it") + Expect(evts[0].(*events.RefreshResource).Data(evts[0])).To(ContainSubstring("al3")) + + ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al3", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Hash).To(BeEmpty(), "the outcome was absent, not found") + }) + + It("does not broadcast when no item is found", func() { + conf.Server.CoverArtPriority = "external" + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alx", Name: "Album"}}) + imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "alx"})).To(Succeed()) + + n, err := w.drain(ctx, 2) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(1)) + + Expect(broker.getEvents()).To(BeEmpty(), "a drain with no found items sends no event") + }) + }) + + Describe("gate/breaker", func() { + It("opens after 5 consecutive external errors and short-circuits the step", func() { + var calls int + failing := func() (io.ReadCloser, string, error) { + calls++ + return nil, "", errors.New("boom") + } + for range 5 { + _, _, err := w.gate("A", failing) + Expect(err).To(HaveOccurred()) + } + Expect(calls).To(Equal(5)) + + _, _, err := w.gate("A", failing) + Expect(err).To(MatchError(errBreakerOpen)) + Expect(calls).To(Equal(5), "an open breaker must not call the external step") + }) + + It("resets the failure count on a successful call", func() { + failing := func() (io.ReadCloser, string, error) { return nil, "", errors.New("boom") } + ok := func() (io.ReadCloser, string, error) { return io.NopCloser(nil), "p", nil } + for range 4 { + _, _, _ = w.gate("A", failing) + } + _, _, err := w.gate("A", ok) + Expect(err).ToNot(HaveOccurred()) + + var calls int + counting := func() (io.ReadCloser, string, error) { + calls++ + return nil, "", errors.New("boom") + } + for range 5 { + _, _, _ = w.gate("A", counting) + } + Expect(calls).To(Equal(5), "the breaker should have re-closed after the success") + }) + + It("does not open the breaker on a run of agent not-found misses", func() { + // agents.ErrNotFound is a definitive miss, not a fault: artless items must not + // trip the breaker, or they would loop in retry instead of settling absent. + notFound := func() (io.ReadCloser, string, error) { return nil, "", agents.ErrNotFound } + for range breakerThreshold + 3 { + _, _, err := w.gate("A", notFound) + Expect(err).To(MatchError(agents.ErrNotFound), "a miss passes through, never errBreakerOpen") + } + + var calls int + counting := func() (io.ReadCloser, string, error) { + calls++ + return nil, "", errors.New("boom") + } + _, _, _ = w.gate("A", counting) + Expect(calls).To(Equal(1), "the breaker stayed closed, so the step still runs") + }) + + It("isolates each agent's breaker: one open gate does not block another", func() { + failing := func() (io.ReadCloser, string, error) { return nil, "", errors.New("boom") } + for range breakerThreshold { + _, _, _ = w.gate("A", failing) + } + _, _, err := w.gate("A", failing) + Expect(err).To(MatchError(errBreakerOpen), "agent A's breaker is open") + + var bCalls int + bStep := func() (io.ReadCloser, string, error) { + bCalls++ + return io.NopCloser(nil), "p", nil + } + for range breakerThreshold + 2 { + _, _, err := w.gate("B", bStep) + Expect(err).ToNot(HaveOccurred(), "agent B keeps being called while A is open") + } + Expect(bCalls).To(Equal(breakerThreshold + 2)) + }) + }) + + Describe("precache", func() { + BeforeEach(func() { + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"cover.jpg"}, + }} + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "alpc", Name: "Album", FolderIDs: []string{"f1"}}, + }) + conf.Server.UICoverArtSize = 300 + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ + ItemKind: "al", ItemID: "alpc", Priority: model.ArtworkPriorityScan, + })).To(Succeed()) + }) + + It("warms the resize cache at the UI cover size after a found acquisition", func() { + conf.Server.EnableArtworkPrecache = true + + n, err := w.drain(ctx, 1) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(1)) + + // The list surfaces request square covers, so warming any other key is wasted work. + Expect(imgCache.getKeys()).To(ContainElement(ContainSubstring(".300.true."))) + }) + + It("skips warming when precache is disabled", func() { + conf.Server.EnableArtworkPrecache = false + + n, err := w.drain(ctx, 1) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(1)) + + Expect(imgCache.getKeys()).To(BeEmpty()) + }) + + // It warms from the bytes acquisition already held, so no state row or store file is needed. + It("warms from the acquired bytes without reading them back", func() { + conf.Server.EnableArtworkPrecache = true + ia := &model.ItemArtwork{ + ItemKind: "al", ItemID: "unpersisted", ImageType: model.ImageTypePrimary, + Hash: "abcdef0123456789", UpdatedAt: time.Now(), + } + + data, err := os.ReadFile("tests/fixtures/artist/an-album/cover.jpg") + Expect(err).ToNot(HaveOccurred()) + + w.precache(ctx, &acquired{ia: ia, mime: "image/jpeg", data: data}) + + // Nothing backs that hash on disk, so a hit can only come from the bytes handed in; + // the probe refuses to open, proving nothing is re-read. + probe := &resizedItem{ + hash: ia.Hash, size: 300, square: true, ffmpeg: ffm, + open: func() (io.ReadCloser, error) { return nil, errors.New("precache must not re-read the source") }, + } + stream, err := imgCache.Get(ctx, probe) + Expect(err).ToNot(HaveOccurred()) + defer stream.Close() + Expect(io.ReadAll(stream)).ToNot(BeEmpty()) + }) + }) + + Describe("drain pools", func() { + // A kind in neither pool is never dequeued, with nothing to catch it at compile time. + It("covers every kind the worker can process, exactly once", func() { + var pooled []string + for _, p := range newDrainPools() { + pooled = append(pooled, p.kinds...) + } + for kind := range artworkKindToResource { + Expect(pooled).To(ContainElement(kind.Prefix()), "kind %q belongs to no drain pool", kind.Prefix()) + } + Expect(pooled).To(HaveLen(len(artworkKindToResource)), "a kind is claimed by more than one pool") + }) + + // Artists resolve through a rate-limited agent that holds its slot while waiting, so one + // shared pool would park every album behind them. + It("resolves albums while artists are stuck on a slow agent", func() { + conf.Server.CoverArtPriority = "cover.jpg" + conf.Server.ArtistArtPriority = "external" + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"cover.jpg"}, + }} + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alx", Name: "Album", FolderIDs: []string{"f1"}}}) + ds.MockedArtist = tests.CreateMockArtistRepo() + + // Every artist lookup blocks until released, standing in for the rate limiter. + block := make(chan struct{}) + artists := model.Artists{} + for i := range 8 { + artists = append(artists, model.Artist{ID: fmt.Sprintf("arx%d", i), Name: "A"}) + } + ds.MockedArtist.(*tests.MockArtistRepo).SetData(artists) + imageAgents(&fakeImageAgent{name: "slowAgent", block: block}) + + // Artists first, exactly as Backfill orders them. + for _, a := range artists { + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ + ItemKind: "ar", ItemID: a.ID, Priority: model.ArtworkPriorityBackfill, + })).To(Succeed()) + } + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ + ItemKind: "al", ItemID: "alx", Priority: model.ArtworkPriorityBackfill, + })).To(Succeed()) + + runCtx, cancel := context.WithCancel(ctx) + done := make(chan struct{}) + go func() { defer close(done); _ = w.Run(runCtx) }() + // Join Run: a leaked pool goroutine would race the config snapshot Ginkgo restores. + DeferCleanup(func() { + cancel() + close(block) // unpark the blocked lookups so the pools can unwind + <-done + }) + + Eventually(func() bool { + ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alx", model.ImageTypePrimary) + return err == nil && ia.Hash != "" + }, 5*time.Second, 50*time.Millisecond).Should(BeTrue(), + "a blocked external pool must not hold up local artwork") + + _, err := artRepo.GetItemArtwork(model.KindArtistArtwork, "arx0", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound), "artists are still blocked, as intended") + }) + }) + + Describe("batching", func() { + It("leaves undispatched items queued when cancelled mid-batch", func() { + for i := range 8 { + id := fmt.Sprintf("alc%d", i) + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: id, Name: "Album"}}) + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ + ItemKind: "al", ItemID: id, Priority: model.ArtworkPriorityScan, + })).To(Succeed()) + } + cancelledCtx, cancel := context.WithCancel(ctx) + cancel() + + _, err := w.drain(cancelledCtx, 1) + Expect(err).ToNot(HaveOccurred()) + + for i := range 8 { + id := fmt.Sprintf("alc%d", i) + Expect(findQueued(queueRepo, "al", id)).ToNot(BeNil(), "row "+id+" must survive a cancelled drain") + } + }) + + It("dequeues past the worker pool so one drain covers many items", func() { + for i := range 16 { + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: fmt.Sprintf("alb%d", i), Name: "Album"}}) + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ + ItemKind: "al", ItemID: fmt.Sprintf("alb%d", i), Priority: model.ArtworkPriorityScan, + })).To(Succeed()) + } + + n, err := w.drain(ctx, 2) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(16), "a batch sized to the pool would have stopped at 4") + }) + }) + + Describe("RunPrune", func() { + It("runs a prune under the worker mutex", func() { + Expect(w.RunPrune(ctx)).To(Succeed()) + }) + }) + + Describe("Run", func() { + It("exits cleanly when the context is cancelled", func() { + runCtx, cancel := context.WithCancel(ctx) + done := make(chan error, 1) + go func() { done <- w.Run(runCtx) }() + + cancel() + Eventually(done, time.Second).Should(Receive(BeNil())) + }) + + It("does not leak goroutines after Run exits", func() { + DeferCleanup(configtest.SetupConfig()) + + ignore := goleak.IgnoreCurrent() + DeferCleanup(func() { goleak.VerifyNone(GinkgoT(), ignore) }) + + localDS := &tests.MockDataStore{MockedArtworkQueue: tests.CreateMockArtworkQueueRepo()} + lw := NewWorker(localDS, NewImageStore(GinkgoT().TempDir()), agents.GetAgents(localDS, nil), tests.NewMockFFmpeg(""), &fakeEventBroker{}, imgCache) + + runCtx, cancel := context.WithCancel(ctx) + done := make(chan error, 1) + go func() { done <- lw.Run(runCtx) }() + + time.Sleep(20 * time.Millisecond) // let the loop settle on the idle select + cancel() + Eventually(done, 2*time.Second).Should(Receive(BeNil())) + }) + }) +}) + +var _ = Describe("backoff", func() { + It("returns the expected schedule with no jitter", func() { + for _, c := range []struct { + attempts int + want time.Duration + }{ + {0, 5 * time.Second}, + {1, 20 * time.Second}, + {2, 80 * time.Second}, + {3, 320 * time.Second}, + {4, 1280 * time.Second}, + {5, 5120 * time.Second}, + {6, 20480 * time.Second}, + {7, 12 * time.Hour}, + {8, 12 * time.Hour}, + } { + Expect(backoffFor(c.attempts, 0)).To(Equal(c.want), "attempt %d", c.attempts) + } + }) + + It("applies jitter proportionally", func() { + base := backoffFor(2, 0) + Expect(backoffFor(2, 0.2)).To(Equal(time.Duration(float64(base) * 1.2))) + Expect(backoffFor(2, -0.2)).To(Equal(time.Duration(float64(base) * 0.8))) + }) + + It("keeps random jitter within +/-40%", func() { + lo := time.Duration(float64(320*time.Second) * 0.6) + hi := time.Duration(float64(320*time.Second) * 1.4) + for range 200 { + d := backoff(3) + Expect(d).To(BeNumerically(">=", lo)) + Expect(d).To(BeNumerically("<=", hi)) + } + }) +}) diff --git a/core/artwork/worker_timing_test.go b/core/artwork/worker_timing_test.go new file mode 100644 index 000000000..f7315b20a --- /dev/null +++ b/core/artwork/worker_timing_test.go @@ -0,0 +1,76 @@ +package artwork + +import ( + "errors" + "io" + "testing" + "testing/synctest" + "time" + + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/gomega" +) + +// Drives the real breaker state machine with the fake clock. Plain test: testing/synctest +// needs a *testing.T, which Ginkgo doesn't give. +func TestArtworkBreakerHalfOpen(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + g := NewWithT(t) + b := newBreaker() + + for range breakerThreshold { + b.record("agentA", errors.New("boom")) + } + g.Expect(b.allow()).To(BeFalse(), "breaker opens after consecutive errors") + + time.Sleep(breakerProbeAfter - time.Nanosecond) + g.Expect(b.allow()).To(BeFalse(), "still open before the probe interval") + + time.Sleep(time.Nanosecond) + g.Expect(b.allow()).To(BeTrue(), "half-open: one probe is granted") + g.Expect(b.allow()).To(BeFalse(), "only a single probe per interval") + + b.record("agentA", errors.New("boom")) // probe fails -> stay open + time.Sleep(breakerProbeAfter) + g.Expect(b.allow()).To(BeTrue(), "another probe after the next interval") + + b.record("agentA", nil) // probe succeeds -> close + g.Expect(b.allow()).To(BeTrue(), "closed breaker admits freely") + g.Expect(b.allow()).To(BeTrue()) + }) +} + +func TestArtworkGatePerAgentBreakerIsolation(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + g := NewWithT(t) + w := NewWorker(&tests.MockDataStore{}, NewImageStore(t.TempDir()), + agents.GetAgents(&tests.MockDataStore{}, nil), tests.NewMockFFmpeg(""), &fakeEventBroker{}, nil) + + fail := func() (io.ReadCloser, string, error) { return nil, "", errors.New("boom") } + for range breakerThreshold { + _, _, _ = w.gate("A", fail) + } + _, _, err := w.gate("A", fail) + g.Expect(err).To(MatchError(errBreakerOpen), "A opens after consecutive errors") + + // B has its own breaker, untouched by A being open. + var bCalls int + bStep := func() (io.ReadCloser, string, error) { bCalls++; return nil, "", errors.New("boom") } + for range breakerThreshold - 1 { + _, _, err := w.gate("B", bStep) + g.Expect(err).To(MatchError("boom")) + } + g.Expect(bCalls).To(Equal(breakerThreshold-1), "B keeps being called while A is open") + + // After the probe window, A admits exactly one probe again. + time.Sleep(breakerProbeAfter) + var aCalls int + aFail := func() (io.ReadCloser, string, error) { aCalls++; return nil, "", errors.New("boom") } + _, _, _ = w.gate("A", aFail) + g.Expect(aCalls).To(Equal(1), "A grants a single probe after the interval") + _, _, err = w.gate("A", aFail) + g.Expect(err).To(MatchError(errBreakerOpen), "the probe failed, so A stays open") + g.Expect(aCalls).To(Equal(1)) + }) +} diff --git a/core/external/provider.go b/core/external/provider.go index 869841e06..efd061c0c 100644 --- a/core/external/provider.go +++ b/core/external/provider.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "net/url" "sort" "strings" "time" @@ -35,8 +34,6 @@ type Provider interface { UpdateArtistInfo(ctx context.Context, id string, count int, includeNotPresent bool) (*model.Artist, error) SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error) TopSongs(ctx context.Context, artist, artistId string, count int) (model.MediaFiles, error) - ArtistImage(ctx context.Context, id string) (*url.URL, error) - AlbumImage(ctx context.Context, id string) (*url.URL, error) } type provider struct { @@ -258,7 +255,7 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au // Call all registered agents and collect information g := errgroup.Group{} g.SetLimit(2) - g.Go(func() error { e.callGetImage(ctx, e.ag, &artist); return nil }) + g.Go(func() error { _ = e.callGetImage(ctx, e.ag, &artist); return nil }) g.Go(func() error { e.callGetBiography(ctx, e.ag, &artist); return nil }) g.Go(func() error { e.callGetURL(ctx, e.ag, &artist); return nil }) g.Go(func() error { e.callGetSimilarArtists(ctx, e.ag, &artist, maxSimilarArtists, true); return nil }) @@ -370,76 +367,6 @@ func (e *provider) similarSongsFallback(ctx context.Context, id string, count in return similarSongs, nil } -func (e *provider) ArtistImage(ctx context.Context, id string) (*url.URL, error) { - artist, err := e.getArtist(ctx, id) - if err != nil { - return nil, err - } - - imageUrl := artist.ArtistImageUrl() - if imageUrl == "" { - // No cached URL — must fetch from external source synchronously - e.callGetImage(ctx, e.ag, &artist) - if utils.IsCtxDone(ctx) { - log.Warn(ctx, "ArtistImage call canceled", ctx.Err()) - return nil, ctx.Err() - } - imageUrl = artist.ArtistImageUrl() - } else { - // If cached info is expired, enqueue a background refresh so that config changes - // (e.g. disabling an agent) take effect without waiting for a full artist info refresh. - updatedAt := V(artist.ExternalInfoUpdatedAt) - if !updatedAt.IsZero() && time.Since(updatedAt) > conf.Server.DevArtistInfoTimeToLive { - log.Debug(ctx, "Artist image info expired, enqueuing background refresh", "artist", artist.Name(), "updatedAt", updatedAt) - e.artistQueue.enqueue(&artist) - } - } - - if imageUrl == "" { - return nil, model.ErrNotFound - } - return url.Parse(imageUrl) -} - -func (e *provider) AlbumImage(ctx context.Context, id string) (*url.URL, error) { - album, err := e.getAlbum(ctx, id) - if err != nil { - return nil, err - } - - albumName := album.Name() - images, err := e.ag.GetAlbumImages(ctx, albumName, album.AlbumArtist, album.MbzAlbumID) - if err != nil { - switch { - case errors.Is(err, agents.ErrNotFound): - log.Trace(ctx, "Album not found in agent", "albumID", id, "name", albumName, "artist", album.AlbumArtist) - return nil, model.ErrNotFound - case errors.Is(err, context.Canceled): - log.Debug(ctx, "GetAlbumImages call canceled", err) - default: - log.Warn(ctx, "Error getting album images from agent", "albumID", id, "name", albumName, "artist", album.AlbumArtist, err) - } - return nil, err - } - - if len(images) == 0 { - log.Warn(ctx, "Agent returned no images without error", "albumID", id, "name", albumName, "artist", album.AlbumArtist) - return nil, model.ErrNotFound - } - - // Return the biggest image - var img agents.ExternalImage - for _, i := range images { - if img.Size <= i.Size { - img = i - } - } - if img.URL == "" { - return nil, model.ErrNotFound - } - return url.Parse(img.URL) -} - func (e *provider) TopSongs(ctx context.Context, artistName, id string, count int) (model.MediaFiles, error) { artist, err := e.findArtist(ctx, artistName, id) if err != nil { @@ -524,10 +451,15 @@ func (e *provider) callGetBiography(ctx context.Context, agent agents.ArtistBiog artist.Biography = strings.ReplaceAll(bio, " images[j].Size }) @@ -540,6 +472,7 @@ func (e *provider) callGetImage(ctx context.Context, agent agents.ArtistImageRet if len(images) >= 3 { artist.SmallImageUrl = images[2].URL } + return nil } func (e *provider) callGetSimilarArtists(ctx context.Context, agent agents.ArtistSimilarRetriever, artist *auxArtist, diff --git a/core/external/provider_albumimage_test.go b/core/external/provider_albumimage_test.go deleted file mode 100644 index e801b7cce..000000000 --- a/core/external/provider_albumimage_test.go +++ /dev/null @@ -1,365 +0,0 @@ -package external_test - -import ( - "context" - "errors" - "net/url" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core/agents" - . "github.com/navidrome/navidrome/core/external" - "github.com/navidrome/navidrome/core/matcher" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "github.com/stretchr/testify/mock" -) - -var _ = Describe("Provider - AlbumImage", func() { - var ds *tests.MockDataStore - var provider Provider - var mockArtistRepo *mockArtistRepo - var mockAlbumRepo *mockAlbumRepo - var mockMediaFileRepo *mockMediaFileRepo - var mockAlbumAgent *mockAlbumInfoAgent - var ctx context.Context - - BeforeEach(func() { - ctx = GinkgoT().Context() - DeferCleanup(configtest.SetupConfig()) - conf.Server.Agents = "mockAlbum" // Configure mock agent - - mockArtistRepo = newMockArtistRepo() - mockAlbumRepo = newMockAlbumRepo() - mockMediaFileRepo = newMockMediaFileRepo() - - ds = &tests.MockDataStore{ - MockedArtist: mockArtistRepo, - MockedAlbum: mockAlbumRepo, - MockedMediaFile: mockMediaFileRepo, - } - - mockAlbumAgent = newMockAlbumInfoAgent() - - agentsCombined := &mockAgents{albumInfoAgent: mockAlbumAgent} - provider = NewProvider(ds, agentsCombined, matcher.New(ds)) - - // Default mocks - // Mocks for GetEntityByID sequence (initial failed lookups) - mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() - mockArtistRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once() - mockAlbumRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once() - - // Default mock for non-existent entities - Use Maybe() for flexibility - mockArtistRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe() - mockAlbumRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe() - mockMediaFileRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe() - }) - - It("returns the largest image URL when successful", func() { - // Arrange - mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence - mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once() - // Explicitly mock agent call for this test - mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", ""). - Return([]agents.ExternalImage{ - {URL: "http://example.com/large.jpg", Size: 1000}, - {URL: "http://example.com/medium.jpg", Size: 500}, - {URL: "http://example.com/small.jpg", Size: 200}, - }, nil).Once() - - expectedURL, _ := url.Parse("http://example.com/large.jpg") - imgURL, err := provider.AlbumImage(ctx, "album-1") - - Expect(err).ToNot(HaveOccurred()) - Expect(imgURL).To(Equal(expectedURL)) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") // From GetEntityByID - mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1") - mockArtistRepo.AssertNotCalled(GinkgoT(), "Get", "artist-1") // Artist lookup no longer happens in getAlbum - mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") // Expect empty artist name - }) - - It("returns ErrNotFound if the album is not found in the DB", func() { - // Arrange: Explicitly expect the full GetEntityByID sequence for "not-found" - mockArtistRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once() - mockAlbumRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once() - mockMediaFileRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once() - - imgURL, err := provider.AlbumImage(ctx, "not-found") - - Expect(err).To(MatchError("data not found")) - Expect(imgURL).To(BeNil()) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "not-found") - mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "not-found") - mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "not-found") - mockAlbumAgent.AssertNotCalled(GinkgoT(), "GetAlbumImages", mock.Anything, mock.Anything, mock.Anything) - }) - - It("returns the agent error if the agent fails", func() { - // Arrange - mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence - mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once() - - agentErr := errors.New("agent failure") - // Explicitly mock agent call for this test - mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").Return(nil, agentErr).Once() // Expect empty artist - - imgURL, err := provider.AlbumImage(ctx, "album-1") - - Expect(err).To(MatchError("agent failure")) - Expect(imgURL).To(BeNil()) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") - mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1") - mockArtistRepo.AssertNotCalled(GinkgoT(), "Get", "artist-1") - mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") // Expect empty artist - }) - - It("returns ErrNotFound if the agent returns ErrNotFound", func() { - // Arrange - mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence - mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once() - - // Explicitly mock agent call for this test - mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").Return(nil, agents.ErrNotFound).Once() // Expect empty artist - - imgURL, err := provider.AlbumImage(ctx, "album-1") - - Expect(err).To(MatchError("data not found")) - Expect(imgURL).To(BeNil()) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") - mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1") - mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") // Expect empty artist - }) - - It("returns ErrNotFound if the agent returns no images", func() { - // Arrange - mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence - mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once() - - // Explicitly mock agent call for this test - mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", ""). - Return([]agents.ExternalImage{}, nil).Once() // Expect empty artist - - imgURL, err := provider.AlbumImage(ctx, "album-1") - - Expect(err).To(MatchError("data not found")) - Expect(imgURL).To(BeNil()) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") - mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1") - mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") // Expect empty artist - }) - - It("returns context error if context is canceled", func() { - // Arrange - cctx, cancelCtx := context.WithCancel(ctx) - // Mock the necessary DB calls *before* canceling the context - mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() - mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once() - // Expect the agent call even if context is cancelled, returning the context error - mockAlbumAgent.On("GetAlbumImages", cctx, "Album One", "", "").Return(nil, context.Canceled).Once() - // Cancel the context *before* calling the function under test - cancelCtx() - - imgURL, err := provider.AlbumImage(cctx, "album-1") - - Expect(err).To(MatchError("context canceled")) - Expect(imgURL).To(BeNil()) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") - mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1") - // Agent should now be called, verify this expectation - mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", cctx, "Album One", "", "") - }) - - It("derives album ID from MediaFile ID", func() { - // Arrange: Mock full GetEntityByID for "mf-1" and recursive "album-1" - mockArtistRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once() - mockAlbumRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once() - mockMediaFileRepo.On("Get", "mf-1").Return(&model.MediaFile{ID: "mf-1", Title: "Track One", ArtistID: "artist-1", AlbumID: "album-1"}, nil).Once() - mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() - mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once() - - // Explicitly mock agent call for this test - mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", ""). - Return([]agents.ExternalImage{ - {URL: "http://example.com/large.jpg", Size: 1000}, - {URL: "http://example.com/medium.jpg", Size: 500}, - {URL: "http://example.com/small.jpg", Size: 200}, - }, nil).Once() - - expectedURL, _ := url.Parse("http://example.com/large.jpg") - imgURL, err := provider.AlbumImage(ctx, "mf-1") - - Expect(err).ToNot(HaveOccurred()) - Expect(imgURL).To(Equal(expectedURL)) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "mf-1") - mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "mf-1") - mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "mf-1") - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") - mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1") - mockArtistRepo.AssertNotCalled(GinkgoT(), "Get", "artist-1") - mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") - }) - - It("handles different image orders from agent", func() { - // Arrange - mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence - mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once() - // Explicitly mock agent call for this test - mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", ""). - Return([]agents.ExternalImage{ - {URL: "http://example.com/small.jpg", Size: 200}, - {URL: "http://example.com/large.jpg", Size: 1000}, - {URL: "http://example.com/medium.jpg", Size: 500}, - }, nil).Once() - - expectedURL, _ := url.Parse("http://example.com/large.jpg") - imgURL, err := provider.AlbumImage(ctx, "album-1") - - Expect(err).ToNot(HaveOccurred()) - Expect(imgURL).To(Equal(expectedURL)) // Should still pick the largest - mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") - }) - - It("handles agent returning only one image", func() { - // Arrange - mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence - mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once() - // Explicitly mock agent call for this test - mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", ""). - Return([]agents.ExternalImage{ - {URL: "http://example.com/single.jpg", Size: 700}, - }, nil).Once() - - expectedURL, _ := url.Parse("http://example.com/single.jpg") - imgURL, err := provider.AlbumImage(ctx, "album-1") - - Expect(err).ToNot(HaveOccurred()) - Expect(imgURL).To(Equal(expectedURL)) - mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") - }) - - It("returns ErrNotFound if deriving album ID fails", func() { - // Arrange: Mock full GetEntityByID for "mf-no-album" and recursive "not-found" - mockArtistRepo.On("Get", "mf-no-album").Return(nil, model.ErrNotFound).Once() - mockAlbumRepo.On("Get", "mf-no-album").Return(nil, model.ErrNotFound).Once() - mockMediaFileRepo.On("Get", "mf-no-album").Return(&model.MediaFile{ID: "mf-no-album", Title: "Track No Album", ArtistID: "artist-1", AlbumID: "not-found"}, nil).Once() - mockArtistRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once() - mockAlbumRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once() - mockMediaFileRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once() - - imgURL, err := provider.AlbumImage(ctx, "mf-no-album") - - Expect(err).To(MatchError("data not found")) - Expect(imgURL).To(BeNil()) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "mf-no-album") - mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "mf-no-album") - mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "mf-no-album") - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "not-found") - mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "not-found") - mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "not-found") - mockAlbumAgent.AssertNotCalled(GinkgoT(), "GetAlbumImages", mock.Anything, mock.Anything, mock.Anything) - }) - - Context("Unicode handling in album names", func() { - var albumWithEnDash *model.Album - var expectedURL *url.URL - - const ( - originalAlbumName = "Raising Hell–Deluxe" // Album name with en dash - normalizedAlbumName = "Raising Hell-Deluxe" // Normalized version with hyphen - ) - - BeforeEach(func() { - // Test with en dash (–) in album name - albumWithEnDash = &model.Album{ID: "album-endash", Name: originalAlbumName, AlbumArtistID: "artist-1"} - mockArtistRepo.Mock = mock.Mock{} // Reset default expectations - mockAlbumRepo.Mock = mock.Mock{} // Reset default expectations - mockArtistRepo.On("Get", "album-endash").Return(nil, model.ErrNotFound).Once() - mockAlbumRepo.On("Get", "album-endash").Return(albumWithEnDash, nil).Once() - - expectedURL, _ = url.Parse("http://example.com/album.jpg") - - // Mock the album agent to return an image for the album - mockAlbumAgent.On("GetAlbumImages", ctx, mock.AnythingOfType("string"), "", ""). - Return([]agents.ExternalImage{ - {URL: "http://example.com/album.jpg", Size: 1000}, - }, nil).Once() - }) - - When("DevPreserveUnicodeInExternalCalls is true", func() { - BeforeEach(func() { - conf.Server.DevPreserveUnicodeInExternalCalls = true - }) - - It("preserves Unicode characters in album names", func() { - // Act - imgURL, err := provider.AlbumImage(ctx, "album-endash") - - // Assert - Expect(err).ToNot(HaveOccurred()) - Expect(imgURL).To(Equal(expectedURL)) - mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-endash") - // This is the key assertion: ensure the original Unicode name is used - mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, originalAlbumName, "", "") - }) - }) - - When("DevPreserveUnicodeInExternalCalls is false", func() { - BeforeEach(func() { - conf.Server.DevPreserveUnicodeInExternalCalls = false - }) - - It("normalizes Unicode characters", func() { - // Act - imgURL, err := provider.AlbumImage(ctx, "album-endash") - - // Assert - Expect(err).ToNot(HaveOccurred()) - Expect(imgURL).To(Equal(expectedURL)) - mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-endash") - // This assertion ensures the normalized name is used (en dash → hyphen) - mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, normalizedAlbumName, "", "") - }) - }) - }) -}) - -// mockAlbumInfoAgent implementation -type mockAlbumInfoAgent struct { - mock.Mock - agents.AlbumInfoRetriever - agents.AlbumImageRetriever -} - -func newMockAlbumInfoAgent() *mockAlbumInfoAgent { - m := new(mockAlbumInfoAgent) - m.On("AgentName").Return("mockAlbum").Maybe() - return m -} - -func (m *mockAlbumInfoAgent) AgentName() string { - args := m.Called() - return args.String(0) -} - -func (m *mockAlbumInfoAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*agents.AlbumInfo, error) { - args := m.Called(ctx, name, artist, mbid) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).(*agents.AlbumInfo), args.Error(1) -} - -func (m *mockAlbumInfoAgent) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) { - args := m.Called(ctx, name, artist, mbid) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).([]agents.ExternalImage), args.Error(1) -} - -// Ensure mockAgent implements the interfaces -var _ agents.AlbumInfoRetriever = (*mockAlbumInfoAgent)(nil) -var _ agents.AlbumImageRetriever = (*mockAlbumInfoAgent)(nil) diff --git a/core/external/provider_artistimage_test.go b/core/external/provider_artistimage_test.go deleted file mode 100644 index 79612d651..000000000 --- a/core/external/provider_artistimage_test.go +++ /dev/null @@ -1,426 +0,0 @@ -package external_test - -import ( - "bytes" - "context" - "errors" - "net/url" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core/agents" - . "github.com/navidrome/navidrome/core/external" - "github.com/navidrome/navidrome/core/matcher" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "github.com/stretchr/testify/mock" -) - -var _ = Describe("Provider - ArtistImage", func() { - var ds *tests.MockDataStore - var provider Provider - var mockArtistRepo *mockArtistRepo - var mockAlbumRepo *mockAlbumRepo - var mockMediaFileRepo *mockMediaFileRepo - var mockImageAgent *mockArtistImageAgent - var agentsCombined *mockAgents - var ctx context.Context - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.Agents = "mockImage" // Configure only the mock agent - ctx = GinkgoT().Context() - - mockArtistRepo = newMockArtistRepo() - mockAlbumRepo = newMockAlbumRepo() - mockMediaFileRepo = newMockMediaFileRepo() - - ds = &tests.MockDataStore{ - MockedArtist: mockArtistRepo, - MockedAlbum: mockAlbumRepo, - MockedMediaFile: mockMediaFileRepo, - } - - mockImageAgent = newMockArtistImageAgent() - - // Use the mockAgents from helper, setting the specific agent - agentsCombined = &mockAgents{ - imageAgent: mockImageAgent, - } - - provider = NewProvider(ds, agentsCombined, matcher.New(ds)) - - // Default mocks for successful Get calls - mockArtistRepo.On("Get", "artist-1").Return(&model.Artist{ID: "artist-1", Name: "Artist One"}, nil).Maybe() - mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Maybe() - mockMediaFileRepo.On("Get", "mf-1").Return(&model.MediaFile{ID: "mf-1", Title: "Track One", ArtistID: "artist-1"}, nil).Maybe() - // Default mock for non-existent entities - mockArtistRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe() - mockAlbumRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe() - mockMediaFileRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe() - - // Default successful image agent response - mockImageAgent.On("GetArtistImages", mock.Anything, "artist-1", "Artist One", ""). - Return([]agents.ExternalImage{ - {URL: "http://example.com/large.jpg", Size: 1000}, - {URL: "http://example.com/medium.jpg", Size: 500}, - {URL: "http://example.com/small.jpg", Size: 200}, - }, nil).Maybe() - }) - - AfterEach(func() { - mockArtistRepo.AssertExpectations(GinkgoT()) - mockAlbumRepo.AssertExpectations(GinkgoT()) - mockMediaFileRepo.AssertExpectations(GinkgoT()) - mockImageAgent.AssertExpectations(GinkgoT()) - }) - - It("returns the largest image URL when successful", func() { - // Arrange - expectedURL, _ := url.Parse("http://example.com/large.jpg") - - // Act - imgURL, err := provider.ArtistImage(ctx, "artist-1") - - // Assert - Expect(err).ToNot(HaveOccurred()) - Expect(imgURL).To(Equal(expectedURL)) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") - mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "") - }) - - It("returns ErrNotFound if the artist is not found in the DB", func() { - // Arrange - - // Act - imgURL, err := provider.ArtistImage(ctx, "not-found") - - // Assert - Expect(err).To(MatchError(model.ErrNotFound)) - Expect(imgURL).To(BeNil()) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "not-found") - mockImageAgent.AssertNotCalled(GinkgoT(), "GetArtistImages", mock.Anything, mock.Anything, mock.Anything, mock.Anything) - }) - - It("returns the agent error if the agent fails", func() { - // Arrange - agentErr := errors.New("agent failure") - mockImageAgent.Mock = mock.Mock{} // Reset default expectation - mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", "").Return(nil, agentErr).Once() - - // Act - imgURL, err := provider.ArtistImage(ctx, "artist-1") - - // Assert - Expect(err).To(MatchError(model.ErrNotFound)) // Corrected Expectation: The provider maps agent errors (other than canceled) to ErrNotFound if no image was found/populated - Expect(imgURL).To(BeNil()) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") - mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "") - }) - - It("returns ErrNotFound if the agent returns ErrNotFound", func() { - // Arrange - mockImageAgent.Mock = mock.Mock{} // Reset default expectation - mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", "").Return(nil, agents.ErrNotFound).Once() - - // Act - imgURL, err := provider.ArtistImage(ctx, "artist-1") - - // Assert - Expect(err).To(MatchError(model.ErrNotFound)) - Expect(imgURL).To(BeNil()) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") - mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "") - }) - - It("returns ErrNotFound if the agent returns no images", func() { - // Arrange - mockImageAgent.Mock = mock.Mock{} // Reset default expectation - mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", "").Return([]agents.ExternalImage{}, nil).Once() - - // Act - imgURL, err := provider.ArtistImage(ctx, "artist-1") - - // Assert - Expect(err).To(MatchError(model.ErrNotFound)) // Implementation maps empty result to ErrNotFound - Expect(imgURL).To(BeNil()) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") - mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "") - }) - - It("returns context error if context is canceled before agent call", func() { - // Arrange - cctx, cancelCtx := context.WithCancel(context.Background()) - mockArtistRepo.Mock = mock.Mock{} // Reset default expectation for artist repo as well - mockArtistRepo.On("Get", "artist-1").Return(&model.Artist{ID: "artist-1", Name: "Artist One"}, nil).Run(func(args mock.Arguments) { - cancelCtx() // Cancel context *during* the DB call simulation - }).Once() - - // Act - imgURL, err := provider.ArtistImage(cctx, "artist-1") - - // Assert - Expect(err).To(MatchError(context.Canceled)) - Expect(imgURL).To(BeNil()) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") - }) - - It("derives artist ID from MediaFile ID", func() { - // Arrange: Add mocks for the initial GetEntityByID lookups - mockArtistRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once() - mockAlbumRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once() - // Default mocks for MediaFileRepo.Get("mf-1") and ArtistRepo.Get("artist-1") handle the rest - expectedURL, _ := url.Parse("http://example.com/large.jpg") - - // Act - imgURL, err := provider.ArtistImage(ctx, "mf-1") - - // Assert - Expect(err).ToNot(HaveOccurred()) - Expect(imgURL).To(Equal(expectedURL)) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "mf-1") // GetEntityByID sequence - mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "mf-1") // GetEntityByID sequence - mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "mf-1") - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") // Should be called after getting MF - mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "") - }) - - It("derives artist ID from Album ID", func() { - // Arrange: Add mock for the initial GetEntityByID lookup - mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() - // Default mocks for AlbumRepo.Get("album-1") and ArtistRepo.Get("artist-1") handle the rest - expectedURL, _ := url.Parse("http://example.com/large.jpg") - - // Act - imgURL, err := provider.ArtistImage(ctx, "album-1") - - // Assert - Expect(err).ToNot(HaveOccurred()) - Expect(imgURL).To(Equal(expectedURL)) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") // GetEntityByID sequence - mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1") - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") // Should be called after getting Album - mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "") - }) - - It("returns ErrNotFound if derived artist is not found", func() { - // Arrange - // Add mocks for the initial GetEntityByID lookups - mockArtistRepo.On("Get", "mf-bad-artist").Return(nil, model.ErrNotFound).Once() - mockAlbumRepo.On("Get", "mf-bad-artist").Return(nil, model.ErrNotFound).Once() - mockMediaFileRepo.On("Get", "mf-bad-artist").Return(&model.MediaFile{ID: "mf-bad-artist", ArtistID: "not-found"}, nil).Once() - // Add expectation for the recursive GetEntityByID call for the MediaFileRepo - mockMediaFileRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe() - // The default mocks for ArtistRepo/AlbumRepo handle the final "not-found" lookups - - // Act - imgURL, err := provider.ArtistImage(ctx, "mf-bad-artist") - - // Assert - Expect(err).To(MatchError(model.ErrNotFound)) - Expect(imgURL).To(BeNil()) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "mf-bad-artist") // GetEntityByID sequence - mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "mf-bad-artist") // GetEntityByID sequence - mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "mf-bad-artist") - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "not-found") - mockImageAgent.AssertNotCalled(GinkgoT(), "GetArtistImages", mock.Anything, mock.Anything, mock.Anything, mock.Anything) - }) - - It("handles different image orders from agent", func() { - // Arrange - mockImageAgent.Mock = mock.Mock{} // Reset default expectation - mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", ""). - Return([]agents.ExternalImage{ - {URL: "http://example.com/small.jpg", Size: 200}, - {URL: "http://example.com/large.jpg", Size: 1000}, - {URL: "http://example.com/medium.jpg", Size: 500}, - }, nil).Once() - expectedURL, _ := url.Parse("http://example.com/large.jpg") - - // Act - imgURL, err := provider.ArtistImage(ctx, "artist-1") - - // Assert - Expect(err).ToNot(HaveOccurred()) - Expect(imgURL).To(Equal(expectedURL)) // Still picks the largest - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") - mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "") - }) - - It("handles agent returning only one image", func() { - // Arrange - mockImageAgent.Mock = mock.Mock{} // Reset default expectation - mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", ""). - Return([]agents.ExternalImage{ - {URL: "http://example.com/medium.jpg", Size: 500}, - }, nil).Once() - expectedURL, _ := url.Parse("http://example.com/medium.jpg") - - // Act - imgURL, err := provider.ArtistImage(ctx, "artist-1") - - // Assert - Expect(err).ToNot(HaveOccurred()) - Expect(imgURL).To(Equal(expectedURL)) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") - mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "") - }) - - It("returns cached URL and does not call agent when info is not expired", func() { - // Arrange: artist has a cached image URL with recent ExternalInfoUpdatedAt - cachedArtist := &model.Artist{ - ID: "artist-cached", - Name: "Cached Artist", - LargeImageUrl: "http://example.com/cached-large.jpg", - ExternalInfoUpdatedAt: new(time.Now().Add(-1 * time.Minute)), - } - mockArtistRepo.On("Get", "artist-cached").Return(cachedArtist, nil).Maybe() - expectedURL, _ := url.Parse("http://example.com/cached-large.jpg") - - // Capture log output - var logBuf bytes.Buffer - log.SetOutput(&logBuf) - defer log.SetOutput(GinkgoWriter) - log.SetLevel(log.LevelDebug) - - // Act - imgURL, err := provider.ArtistImage(ctx, "artist-cached") - - // Assert - Expect(err).ToNot(HaveOccurred()) - Expect(imgURL).To(Equal(expectedURL)) - mockImageAgent.AssertNotCalled(GinkgoT(), "GetArtistImages", mock.Anything, "artist-cached", mock.Anything, mock.Anything) - - // Assert: background refresh was NOT enqueued - Expect(logBuf.String()).ToNot(ContainSubstring("Artist image info expired, enqueuing background refresh")) - - }) - - It("returns stale URL and enqueues refresh when info is expired", func() { - // Arrange - conf.Server.DevArtistInfoTimeToLive = 1 * time.Nanosecond - staleArtist := &model.Artist{ - ID: "artist-expired", - Name: "Expired Artist", - LargeImageUrl: "http://example.com/expired-large.jpg", - ExternalInfoUpdatedAt: new(time.Now().Add(-1 * time.Hour)), - } - mockArtistRepo.On("Get", "artist-expired").Return(staleArtist, nil).Maybe() - expectedURL, _ := url.Parse("http://example.com/expired-large.jpg") - - // Capture log output - var logBuf bytes.Buffer - log.SetOutput(&logBuf) - defer log.SetOutput(GinkgoWriter) - log.SetLevel(log.LevelDebug) - - // Act - imgURL, err := provider.ArtistImage(ctx, "artist-expired") - - // Assert: returns stale URL immediately, no agent call - Expect(err).ToNot(HaveOccurred()) - Expect(imgURL).To(Equal(expectedURL)) - mockImageAgent.AssertNotCalled(GinkgoT(), "GetArtistImages", mock.Anything, "artist-expired", mock.Anything, mock.Anything) - - // Assert: background refresh was enqueued - Expect(logBuf.String()).To(ContainSubstring("Artist image info expired, enqueuing background refresh")) - }) - - Context("Unicode handling in artist names", func() { - var artistWithEnDash *model.Artist - var expectedURL *url.URL - - const ( - originalArtistName = "Run–D.M.C." // Artist name with en dash - normalizedArtistName = "Run-D.M.C." // Normalized version with hyphen - ) - - BeforeEach(func() { - // Test with en dash (–) in artist name like "Run–D.M.C." - artistWithEnDash = &model.Artist{ID: "artist-endash", Name: originalArtistName} - mockArtistRepo.Mock = mock.Mock{} // Reset default expectations - mockArtistRepo.On("Get", "artist-endash").Return(artistWithEnDash, nil).Once() - - expectedURL, _ = url.Parse("http://example.com/rundmc.jpg") - - // Mock the image agent to return an image for the artist - mockImageAgent.On("GetArtistImages", ctx, "artist-endash", mock.AnythingOfType("string"), ""). - Return([]agents.ExternalImage{ - {URL: "http://example.com/rundmc.jpg", Size: 1000}, - }, nil).Once() - - }) - - When("DevPreserveUnicodeInExternalCalls is true", func() { - BeforeEach(func() { - conf.Server.DevPreserveUnicodeInExternalCalls = true - }) - It("preserves Unicode characters in artist names", func() { - // Act - imgURL, err := provider.ArtistImage(ctx, "artist-endash") - - // Assert - Expect(err).ToNot(HaveOccurred()) - Expect(imgURL).To(Equal(expectedURL)) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-endash") - // This is the key assertion: ensure the original Unicode name is used - mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-endash", originalArtistName, "") - }) - }) - - When("DevPreserveUnicodeInExternalCalls is false", func() { - BeforeEach(func() { - conf.Server.DevPreserveUnicodeInExternalCalls = false - }) - - It("normalizes Unicode characters", func() { - // Act - imgURL, err := provider.ArtistImage(ctx, "artist-endash") - - // Assert - Expect(err).ToNot(HaveOccurred()) - Expect(imgURL).To(Equal(expectedURL)) - mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-endash") - // This assertion ensures the normalized name is used (en dash → hyphen) - mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-endash", normalizedArtistName, "") - }) - }) - }) -}) - -// mockArtistImageAgent implementation using testify/mock -// This remains local as it's specific to testing the ArtistImage functionality -type mockArtistImageAgent struct { - mock.Mock - agents.ArtistImageRetriever // Embed interface -} - -// Constructor for the mock agent -func newMockArtistImageAgent() *mockArtistImageAgent { - mock := new(mockArtistImageAgent) - // Set default AgentName if needed, although usually called via mockAgents - mock.On("AgentName").Return("mockImage").Maybe() - return mock -} - -func (m *mockArtistImageAgent) AgentName() string { - args := m.Called() - return args.String(0) -} - -func (m *mockArtistImageAgent) GetArtistImages(ctx context.Context, id, artistName, mbid string) ([]agents.ExternalImage, error) { - args := m.Called(ctx, id, artistName, mbid) - // Need careful type assertion for potentially nil slice - var res []agents.ExternalImage - if args.Get(0) != nil { - res = args.Get(0).([]agents.ExternalImage) - } - return res, args.Error(1) -} - -// Ensure mockAgent implements the interface -var _ agents.ArtistImageRetriever = (*mockArtistImageAgent)(nil) diff --git a/core/playlists/import_test.go b/core/playlists/import_test.go index f2866fb60..2a84d56b2 100644 --- a/core/playlists/import_test.go +++ b/core/playlists/import_test.go @@ -11,7 +11,7 @@ import ( "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/playlists" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/criteria" @@ -43,7 +43,7 @@ var _ = Describe("Playlists - Import", func() { var folder *model.Folder BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) ds.MockedMediaFile = &mockedMediaFileRepo{} libPath, _ := os.Getwd() // Set up library with the actual library path that matches the folder @@ -118,7 +118,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3", "test.ogg"}} - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") @@ -136,7 +136,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") @@ -155,7 +155,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") @@ -174,7 +174,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") @@ -192,7 +192,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") @@ -209,7 +209,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") @@ -226,7 +226,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") @@ -244,7 +244,7 @@ var _ = Describe("Playlists - Import", func() { mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") @@ -258,7 +258,7 @@ var _ = Describe("Playlists - Import", func() { tmpDir := GinkgoT().TempDir() mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) m3u := "#EXTALBUMARTURL:https://example.com/new-cover.jpg\ntest.mp3\n" plsFile := filepath.Join(tmpDir, "test.m3u") @@ -285,7 +285,7 @@ var _ = Describe("Playlists - Import", func() { tmpDir := GinkgoT().TempDir() mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) plsFile := filepath.Join(tmpDir, "test.m3u") Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed()) @@ -311,7 +311,7 @@ var _ = Describe("Playlists - Import", func() { tmpDir := GinkgoT().TempDir() mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) m3u := "test.mp3\n" plsFile := filepath.Join(tmpDir, "test.m3u") @@ -388,7 +388,7 @@ var _ = Describe("Playlists - Import", func() { tmpDir := GinkgoT().TempDir() mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{}} - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) // Create the playlist file on disk with the filesystem's normalization form plsFile := tmpDir + "/" + filesystemName + ".m3u" @@ -448,7 +448,7 @@ var _ = Describe("Playlists - Import", func() { "def.mp3", // This is playlists/def.mp3 relative to plsDir }, } - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) }) It("handles relative paths that reference files in other libraries", func() { @@ -604,7 +604,7 @@ var _ = Describe("Playlists - Import", func() { }, } // Recreate playlists service to pick up new mock - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) // Create playlist in music library that references both tracks plsContent := "#PLAYLIST:Same Path Test\nalbum/track.mp3\n../classical/album/track.mp3" @@ -662,7 +662,7 @@ var _ = Describe("Playlists - Import", func() { }, } ds.MockedFolder = mockFolderRepo - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) plsContent := "#PLAYLIST:My Playlist\ntest.mp3\ntest.ogg\n" plsFile := filepath.Join(tmpDir, "my-playlist.m3u") @@ -681,7 +681,7 @@ var _ = Describe("Playlists - Import", func() { libDir := filepath.Join(tmpDir, "music") Expect(os.Mkdir(libDir, 0755)).To(Succeed()) mockLibRepo.SetData([]model.Library{{ID: 1, Path: libDir}}) - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) plsContent := "#PLAYLIST:External Playlist\n" + libDir + "/test.mp3\n" plsFile := filepath.Join(tmpDir, "external.m3u") @@ -704,7 +704,7 @@ var _ = Describe("Playlists - Import", func() { }, } ds.MockedFolder = mockFolderRepo - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) plsFile := filepath.Join(tmpDir, "test.m3u") Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed()) @@ -724,7 +724,7 @@ var _ = Describe("Playlists - Import", func() { }, } ds.MockedFolder = mockFolderRepo - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) plsFile := filepath.Join(tmpDir, "test.m3u") Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed()) @@ -744,7 +744,7 @@ var _ = Describe("Playlists - Import", func() { }, } ds.MockedFolder = mockFolderRepo - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) plsFile := filepath.Join(tmpDir, "test.m3u") Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed()) @@ -767,7 +767,7 @@ var _ = Describe("Playlists - Import", func() { BeforeEach(func() { repo = &mockedMediaFileFromListRepo{} ds.MockedMediaFile = repo - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) mockLibRepo.SetData([]model.Library{{ID: 1, Path: "/music"}, {ID: 2, Path: "/new"}}) ctx = request.WithUser(ctx, model.User{ID: "123"}) }) diff --git a/core/playlists/playlists.go b/core/playlists/playlists.go index 1ef083bbb..656bde05e 100644 --- a/core/playlists/playlists.go +++ b/core/playlists/playlists.go @@ -52,11 +52,12 @@ type Playlists interface { TracksRepository(ctx context.Context, playlistId string, refreshSmartPlaylist bool) rest.Repository } -// ImageUploadService is a local interface satisfied by core.ImageUploadService. -// Defined here to avoid an import cycle between core and core/playlists. +// ImageUploadService is a local interface satisfied by artwork.Uploader. +// Defined here to avoid an import cycle between core/artwork and core/playlists. type ImageUploadService interface { SetImage(ctx context.Context, entityType string, entityID string, name string, oldPath string, reader io.Reader, ext string) (filename string, err error) RemoveImage(ctx context.Context, path string) error + EnqueueArtwork(ctx context.Context, entityType, entityID string) } type playlists struct { @@ -320,7 +321,11 @@ func (s *playlists) SetImage(ctx context.Context, playlistID string, reader io.R } pls.UploadedImage = filename - return s.ds.Playlist(ctx).Put(pls) + if err := s.ds.Playlist(ctx).Put(pls); err != nil { + return err + } + s.imgUpload.EnqueueArtwork(ctx, consts.EntityPlaylist, pls.ID) + return nil } func (s *playlists) RemoveImage(ctx context.Context, playlistID string) error { @@ -334,5 +339,9 @@ func (s *playlists) RemoveImage(ctx context.Context, playlistID string) error { } pls.UploadedImage = "" - return s.ds.Playlist(ctx).Put(pls) + if err := s.ds.Playlist(ctx).Put(pls); err != nil { + return err + } + s.imgUpload.EnqueueArtwork(ctx, consts.EntityPlaylist, pls.ID) + return nil } diff --git a/core/playlists/playlists_test.go b/core/playlists/playlists_test.go index 0c9674bed..e8134b2ef 100644 --- a/core/playlists/playlists_test.go +++ b/core/playlists/playlists_test.go @@ -8,7 +8,7 @@ import ( "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/playlists" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/criteria" @@ -42,7 +42,7 @@ var _ = Describe("Playlists", func() { "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, } mockPlsRepo.TracksRepo = mockTracks - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) }) It("allows owner to delete their playlist", func() { @@ -82,7 +82,7 @@ var _ = Describe("Playlists", func() { "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, } mockPlsRepo.TracksRepo = mockTracks - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) }) It("returns the playlist's track repository", func() { @@ -103,7 +103,7 @@ var _ = Describe("Playlists", func() { "pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1", Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, } - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) }) It("creates a new playlist with owner set from context", func() { @@ -161,7 +161,7 @@ var _ = Describe("Playlists", func() { Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, } mockPlsRepo.TracksRepo = mockTracks - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) }) It("allows owner to update their playlist", func() { @@ -219,7 +219,7 @@ var _ = Describe("Playlists", func() { "pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"}, } mockPlsRepo.TracksRepo = mockTracks - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) }) It("allows owner to add tracks", func() { @@ -267,7 +267,7 @@ var _ = Describe("Playlists", func() { Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, } mockPlsRepo.TracksRepo = mockTracks - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) }) It("allows owner to remove tracks", func() { @@ -301,7 +301,7 @@ var _ = Describe("Playlists", func() { Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, } mockPlsRepo.TracksRepo = mockTracks - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) }) It("allows owner to reorder", func() { @@ -330,7 +330,7 @@ var _ = Describe("Playlists", func() { "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, "pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"}, } - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) }) It("saves image file and updates UploadedImage", func() { @@ -400,7 +400,7 @@ var _ = Describe("Playlists", func() { "pls-empty": {ID: "pls-empty", Name: "No Cover", OwnerID: "user-1"}, "pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"}, } - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) }) It("removes file and clears UploadedImage", func() { @@ -420,6 +420,24 @@ var _ = Describe("Playlists", func() { Expect(mockPlsRepo.Last.UploadedImage).To(BeEmpty()) }) + It("clears the resolved artwork state and re-queues after removing an upload", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + Expect(ds.Artwork(ctx).PutItemArtwork(&model.ItemArtwork{ + ItemKind: "pl", ItemID: "pls-1", Hash: "oldhash", Source: "upload", + })).To(Succeed()) + + Expect(ps.RemoveImage(ctx, "pls-1")).To(Succeed()) + + _, err := ds.Artwork(ctx).GetItemArtwork(model.KindPlaylistArtwork, "pls-1", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + queued, _ := ds.ArtworkQueue(ctx).DequeueBatch(100) + Expect(queued).To(ContainElement(SatisfyAll( + HaveField("ItemKind", "pl"), + HaveField("ItemID", "pls-1"), + HaveField("Priority", model.ArtworkPriorityBump), + ))) + }) + It("denies non-owner", func() { ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false}) err := ps.RemoveImage(ctx, "pls-1") diff --git a/core/playlists/rest_adapter_test.go b/core/playlists/rest_adapter_test.go index 58a327bde..7ae376b07 100644 --- a/core/playlists/rest_adapter_test.go +++ b/core/playlists/rest_adapter_test.go @@ -5,7 +5,7 @@ import ( "time" "github.com/deluan/rest" - "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/criteria" @@ -37,7 +37,7 @@ var _ = Describe("REST Adapter", func() { mockPlsRepo.Data = map[string]*model.Playlist{ "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, } - ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) }) Describe("Save", func() { diff --git a/core/wire_providers.go b/core/wire_providers.go index a2fffa34f..a09fcc108 100644 --- a/core/wire_providers.go +++ b/core/wire_providers.go @@ -24,8 +24,6 @@ var Set = wire.NewSet( NewLibrary, NewUser, NewMaintenance, - NewImageUploadService, - wire.Bind(new(playlists.ImageUploadService), new(ImageUploadService)), stream.NewTranscodeDecider, agents.GetAgents, external.NewProvider, diff --git a/db/db.go b/db/db.go index 4ca996fe5..11a05b456 100644 --- a/db/db.go +++ b/db/db.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "embed" + "errors" "fmt" "time" @@ -106,6 +107,17 @@ func Init(ctx context.Context) func() { } } +// ErrorCodes reports the SQLite result code and extended result code carried by err. +// The extended code is what distinguishes errors that share a message: "database is locked" +// is both SQLITE_BUSY, which busy_timeout retries, and SQLITE_BUSY_SNAPSHOT, which it never can. +func ErrorCodes(err error) (code, extended int, ok bool) { + var se sqlite3.Error + if !errors.As(err, &se) { + return 0, 0, false + } + return int(se.Code), int(se.ExtendedCode), true +} + type statusLogger struct{ numPending int } func (*statusLogger) Fatalf(format string, v ...any) { log.Fatal(fmt.Sprintf(format, v...)) } diff --git a/db/migrations/20260722023032_add_artwork_tables.sql b/db/migrations/20260722023032_add_artwork_tables.sql new file mode 100644 index 000000000..e307d13e8 --- /dev/null +++ b/db/migrations/20260722023032_add_artwork_tables.sql @@ -0,0 +1,46 @@ +-- +goose Up +CREATE TABLE artwork ( + hash TEXT PRIMARY KEY, + mime TEXT NOT NULL, + width INTEGER NOT NULL DEFAULT 0, + height INTEGER NOT NULL DEFAULT 0, + size_bytes INTEGER NOT NULL DEFAULT 0, + blur_hash TEXT NOT NULL DEFAULT '', + thumb_hash TEXT NOT NULL DEFAULT '', + dominant_color TEXT NOT NULL DEFAULT '', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE item_artwork ( + item_kind TEXT NOT NULL, + item_id TEXT NOT NULL, + image_type TEXT NOT NULL DEFAULT 'primary', + hash TEXT NOT NULL DEFAULT '', + source TEXT NOT NULL DEFAULT '', + source_path TEXT NOT NULL DEFAULT '', + ref_mtime INTEGER NOT NULL DEFAULT 0, + attempted_at TIMESTAMP, + updated_at TIMESTAMP, + PRIMARY KEY (item_kind, item_id, image_type) +) WITHOUT ROWID; +CREATE INDEX ix_item_artwork_hash ON item_artwork(hash); + +CREATE TABLE artwork_queue ( + item_kind TEXT NOT NULL, + item_id TEXT NOT NULL, + image_type TEXT NOT NULL DEFAULT 'primary', + priority INTEGER NOT NULL DEFAULT 0, + attempts INTEGER NOT NULL DEFAULT 0, + retry_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + enqueued_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (item_kind, item_id, image_type) +) WITHOUT ROWID; +-- Ordered to match DequeueBatch (priority DESC, enqueued_at) so drains stop after n rows; retry_at makes it covering. +-- item_kind leads: each drain pool dequeues only its own kinds, so it must seek straight to +-- them rather than scan past another pool's backlog. +CREATE INDEX ix_artwork_queue_drain ON artwork_queue(item_kind, priority DESC, enqueued_at, retry_at); + +-- +goose Down +DROP TABLE artwork_queue; +DROP TABLE item_artwork; +DROP TABLE artwork; diff --git a/go.mod b/go.mod index 5488b41e4..b72f4035c 100644 --- a/go.mod +++ b/go.mod @@ -57,6 +57,7 @@ require ( github.com/tetratelabs/wazero v1.12.0 github.com/unrolled/secure v1.17.0 github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 + github.com/zeebo/xxh3 v1.1.0 go.senan.xyz/taglib v0.11.1 go.uber.org/goleak v1.3.0 golang.org/x/image v0.44.0 @@ -128,7 +129,6 @@ require ( github.com/subosito/gotenv v1.6.0 // indirect github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect github.com/valyala/fastjson v1.6.10 // indirect - github.com/zeebo/xxh3 v1.1.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect diff --git a/log/formatters.go b/log/formatters.go index 0b27f3a43..5d864dae4 100644 --- a/log/formatters.go +++ b/log/formatters.go @@ -26,8 +26,15 @@ func ShortDur(d time.Duration) string { default: s = d.String() } - s = strings.TrimSuffix(s, "0s") - return strings.TrimSuffix(s, "0m") + // Drop whole zero-valued trailing components ("4h0m0s" -> "4h"). The suffix has to include + // the preceding unit, or a value that merely ends in a zero digit loses it: "10s" -> "1". + if strings.HasSuffix(s, "m0s") { + s = strings.TrimSuffix(s, "0s") + } + if strings.HasSuffix(s, "h0m") { + s = strings.TrimSuffix(s, "0m") + } + return s } func StringerValue(s fmt.Stringer) string { diff --git a/log/formatters_test.go b/log/formatters_test.go index 6ed43a094..64d72bdbd 100644 --- a/log/formatters_test.go +++ b/log/formatters_test.go @@ -25,6 +25,12 @@ var _ = DescribeTable("ShortDur", Entry("4m3s", 4*time.Minute+3*time.Second, "4m3s"), Entry("4h", 4*time.Hour, "4h"), Entry("4h", 4*time.Hour+2*time.Second, "4h"), + // A trailing zero digit is significant: only a whole zero-valued component may be dropped. + Entry("zero", time.Duration(0), "0s"), + Entry("10s", 10*time.Second, "10s"), + Entry("20s", 20*time.Second, "20s"), + Entry("1m30s", time.Minute+30*time.Second, "1m30s"), + Entry("2h30m", 2*time.Hour+30*time.Minute, "2h30m"), Entry("4h2m", 4*time.Hour+2*time.Minute+5*time.Second+200*time.Millisecond, "4h2m"), ) diff --git a/model/album.go b/model/album.go index 55df8c63c..f27ca12c0 100644 --- a/model/album.go +++ b/model/album.go @@ -13,6 +13,7 @@ import ( type Album struct { Annotations `structs:"-" hash:"ignore"` + ItemImage `structs:"-" hash:"ignore"` ID string `structs:"id" json:"id"` LibraryID int `structs:"library_id" json:"libraryId"` @@ -142,6 +143,7 @@ type AlbumRepository interface { UpdateExternalInfo(*Album) error Get(id string) (*Album, error) GetAll(...QueryOptions) (Albums, error) + GetAllIDs(...QueryOptions) ([]string, error) GetCursor(...QueryOptions) (AlbumCursor, error) GetYears(libraryIDs ...int) ([]int, error) diff --git a/model/artist.go b/model/artist.go index f9c4bffd5..f3704b669 100644 --- a/model/artist.go +++ b/model/artist.go @@ -11,6 +11,7 @@ import ( type Artist struct { Annotations `structs:"-"` + ItemImage `structs:"-"` ID string `structs:"id" json:"id"` @@ -89,6 +90,7 @@ type ArtistRepository interface { UpdateExternalInfo(a *Artist) error Get(id string) (*Artist, error) GetAll(options ...QueryOptions) (Artists, error) + GetAllIDs(options ...QueryOptions) ([]string, error) GetCursor(options ...QueryOptions) (ArtistCursor, error) GetIndex(includeMissing bool, libraryIds []int, roles ...Role) (ArtistIndexes, error) diff --git a/model/artwork.go b/model/artwork.go new file mode 100644 index 000000000..87b424f33 --- /dev/null +++ b/model/artwork.go @@ -0,0 +1,144 @@ +package model + +import "time" + +// Artwork is one unique image, identified by the XXH3-64 hash of its bytes. +type Artwork struct { + Hash string `structs:"hash"` + Mime string `structs:"mime"` + Width int `structs:"width"` + Height int `structs:"height"` + SizeBytes int64 `structs:"size_bytes"` + BlurHash string `structs:"blur_hash"` + ThumbHash string `structs:"thumb_hash"` + // DominantColor is "#rrggbb": a flat placeholder clients can paint before any decode. + DominantColor string `structs:"dominant_color"` + CreatedAt time.Time `structs:"created_at"` +} + +const ImageTypePrimary = "primary" + +// ItemImage is per-entity artwork state hydrated at query time; never persisted. +type ItemImage struct { + ImageHash string `structs:"-" json:"imageHash,omitempty"` + ImageAbsent bool `structs:"-" json:"imageAbsent,omitempty"` + // BlurHash is Jellyfin's; its mappers read this field directly, so it stays off native JSON. + BlurHash string `structs:"-" json:"-"` + ThumbHash string `structs:"-" json:"thumbHash,omitempty"` + // DominantColor is the only placeholder needing no decode, so it can paint on the first frame. + DominantColor string `structs:"-" json:"dominantColor,omitempty"` + // A thumbhash's own aspect is quantised, so clients need these to shape the placeholder exactly. + ImageWidth int `structs:"-" json:"imageWidth,omitempty"` + ImageHeight int `structs:"-" json:"imageHeight,omitempty"` +} + +// AspectRatio is the image's width/height, or nil when the image or its dimensions are unknown. +func (i ItemImage) AspectRatio() *float64 { + if i.ImageAbsent || i.ImageWidth <= 0 || i.ImageHeight <= 0 { + return nil + } + return new(float64(i.ImageWidth) / float64(i.ImageHeight)) +} + +// ItemArtwork is an entity's resolved artwork state. Hash=="" means known absent. +type ItemArtwork struct { + ItemKind string `structs:"item_kind"` + ItemID string `structs:"item_id"` + ImageType string `structs:"image_type"` + Hash string `structs:"hash"` + Source string `structs:"source"` + // SourcePath is the backing file (folder/upload: the image; embedded: the audio file); "" otherwise. + SourcePath string `structs:"source_path"` + // RefMtime is SourcePath's mtime (unix-nanoseconds) at resolution; 0 when there is no SourcePath. + RefMtime int64 `structs:"ref_mtime"` + // Nullable in the schema, but every insert must set them: these non-pointer fields cannot scan NULL. + AttemptedAt time.Time `structs:"attempted_at"` + UpdatedAt time.Time `structs:"updated_at"` +} + +// ItemArtworkInfo is the list-hydration projection (item_artwork joined with artwork). +type ItemArtworkInfo struct { + ItemID string + Hash string + BlurHash string + ThumbHash string + DominantColor string + Width int + Height int +} + +// Absent reports a known-absent artwork state (resolved, no image). +func (i ItemArtworkInfo) Absent() bool { return i.Hash == "" } + +// Image projects the hydration entry onto the entity-facing struct. +func (i ItemArtworkInfo) Image() ItemImage { + return ItemImage{ + ImageHash: i.Hash, + ImageAbsent: i.Absent(), + BlurHash: i.BlurHash, + ThumbHash: i.ThumbHash, + DominantColor: i.DominantColor, + ImageWidth: i.Width, + ImageHeight: i.Height, + } +} + +type ArtworkQueueItem struct { + ItemKind string `structs:"item_kind"` + ItemID string `structs:"item_id"` + ImageType string `structs:"image_type"` + Priority int `structs:"priority"` + Attempts int `structs:"attempts"` + RetryAt time.Time `structs:"retry_at"` + EnqueuedAt time.Time `structs:"enqueued_at"` +} + +// Queue priorities: higher drains first. +const ( + ArtworkPriorityRecheck = 0 + ArtworkPriorityBackfill = 10 + ArtworkPriorityScan = 50 + ArtworkPriorityBump = 100 +) + +// Delete* takes the rows to remove; Purge* finds them itself and reports how many went. +type ArtworkRepository interface { + GetImage(hash string) (*Artwork, error) + PutImage(a *Artwork) error + // PurgeOrphans deletes rows referenced by no item_artwork row and older than cutoff. + PurgeOrphans(createdBefore time.Time) (int64, error) + GetItemArtwork(kind Kind, id, imageType string) (*ItemArtwork, error) + PutItemArtwork(ia *ItemArtwork) error + DeleteForItems(kind Kind, ids []string) error + // GetInfoForItems hydrates a page in one batched query. + GetInfoForItems(kind Kind, ids []string) (map[string]ItemArtworkInfo, error) + // GetMimeByHash returns hash -> current mime for every stored artwork. + GetMimeByHash() (map[string]string, error) + // PurgeDanglingItems removes state rows whose entity no longer exists. + PurgeDanglingItems() (int64, error) +} + +type ArtworkQueueRepository interface { + // Enqueue upserts; an existing row keeps the higher priority and has its retry_at reset. + Enqueue(items ...ArtworkQueueItem) error + // EnqueuePreservingBackoff upserts like Enqueue but preserves an existing row's retry_at, so a + // request-triggered read-through never resets a failed resolution's backoff. + EnqueuePreservingBackoff(items ...ArtworkQueueItem) error + // EnqueueStaleAbsent inserts queue rows (priority Recheck) for absent states older than cutoff. + EnqueueStaleAbsent(kind Kind, attemptedBefore time.Time) (int64, error) + // EnqueueAllMissing inserts queue rows for all entities with no item_artwork row, at the given priority. + EnqueueAllMissing(kind Kind, priority int) (int64, error) + // EnqueueIfMissing inserts only for items with no item_artwork row yet. + EnqueueIfMissing(items ...ArtworkQueueItem) error + // DequeueBatch returns up to n items with retry_at <= now, priority desc, enqueued_at asc. + // Restricted to the given kinds when any are passed, so one kind cannot block another's drain. + DequeueBatch(n int, kinds ...string) ([]ArtworkQueueItem, error) + // MarkFailedIfUnchanged applies the failure backoff only while retry_at still matches + // seenRetryAt, so a concurrent re-enqueue keeps its fresh eligibility. + MarkFailedIfUnchanged(kind, id, imageType string, seenRetryAt, retryAt time.Time) error + // DeleteIfUnchanged deletes only while retry_at still matches, sparing a concurrent re-enqueue. + DeleteIfUnchanged(kind, id, imageType string, retryAt time.Time) error + Count() (int64, error) + // PurgeDangling removes queue rows whose entity no longer exists. + PurgeDangling() (int64, error) +} diff --git a/model/artwork_id.go b/model/artwork_id.go index 1bd146c1f..634a6442f 100644 --- a/model/artwork_id.go +++ b/model/artwork_id.go @@ -17,6 +17,11 @@ func (k Kind) String() string { return k.name } +// Prefix is the short token used in artwork ids and the item_artwork.item_kind column. +func (k Kind) Prefix() string { + return k.prefix +} + var ( KindMediaFileArtwork = Kind{"mf", "media_file"} KindArtistArtwork = Kind{"ar", "artist"} @@ -35,10 +40,18 @@ var artworkKindMap = map[string]Kind{ KindRadioArtwork.prefix: KindRadioArtwork, } +// ParseKind resolves an item_kind prefix (e.g. "al") to its Kind, reporting whether it was known. +// Use it at string boundaries — URL params, the item_kind column — to enter the typed Kind domain. +func ParseKind(prefix string) (Kind, bool) { + k, ok := artworkKindMap[prefix] + return k, ok +} + type ArtworkID struct { Kind Kind ID string - LastUpdate time.Time + Hash string // content-hash suffix; "" = unknown/none + LastUpdate time.Time // legacy: populated only when parsing old _ tokens } func (id ArtworkID) String() string { @@ -46,14 +59,14 @@ func (id ArtworkID) String() string { return "" } s := fmt.Sprintf("%s-%s", id.Kind.prefix, id.ID) - if lu := id.LastUpdate.Unix(); lu > 0 { - return fmt.Sprintf("%s_%x", s, lu) + if id.Hash != "" { + return s + "_" + id.Hash } - return s + "_0" + return s } func NewArtworkID(kind Kind, id string, lastUpdate *time.Time) ArtworkID { - artID := ArtworkID{kind, id, time.Time{}} + artID := ArtworkID{Kind: kind, ID: id} if lastUpdate != nil { artID.LastUpdate = *lastUpdate } @@ -75,18 +88,34 @@ func ParseArtworkID(id string) (ArtworkID, error) { } parts = strings.SplitN(parts[1], "_", 2) if len(parts) == 2 { - if parts[1] != "0" { - lastUpdate, err := strconv.ParseInt(parts[1], 16, 64) - if err != nil { - return ArtworkID{}, err - } - parsedID.LastUpdate = time.Unix(lastUpdate, 0) - } parsedID.ID = parts[0] + suffix := parts[1] + switch { + // Hash detection must come first: a 16-hex value with the high bit set overflows int64. + case isImageHash(suffix): + parsedID.Hash = suffix + case suffix != "0": + if lastUpdate, err := strconv.ParseInt(suffix, 16, 64); err == nil { + parsedID.LastUpdate = time.Unix(lastUpdate, 0) + } + } } return parsedID, nil } +// isImageHash reports whether s is a 16-char lowercase-hex XXH3-64 content hash. +func isImageHash(s string) bool { + if len(s) != 16 { + return false + } + for _, c := range s { + if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') { + return false + } + } + return true +} + func MustParseArtworkID(id string) ArtworkID { artID, err := ParseArtworkID(id) if err != nil { @@ -112,40 +141,21 @@ func ParseDiscArtworkID(id string) (albumID string, discNumber int, err error) { } func artworkIDFromAlbum(al Album) ArtworkID { - return ArtworkID{ - Kind: KindAlbumArtwork, - ID: al.ID, - LastUpdate: al.UpdatedAt, - } + return ArtworkID{Kind: KindAlbumArtwork, ID: al.ID, Hash: al.ImageHash} } func artworkIDFromMediaFile(mf MediaFile) ArtworkID { - return ArtworkID{ - Kind: KindMediaFileArtwork, - ID: mf.ID, - LastUpdate: mf.UpdatedAt, - } + return ArtworkID{Kind: KindMediaFileArtwork, ID: mf.ID, Hash: mf.ImageHash} } func artworkIDFromPlaylist(pls Playlist) ArtworkID { - return ArtworkID{ - Kind: KindPlaylistArtwork, - ID: pls.ID, - LastUpdate: pls.UpdatedAt, - } + return ArtworkID{Kind: KindPlaylistArtwork, ID: pls.ID, Hash: pls.ImageHash} } func artworkIDFromArtist(ar Artist) ArtworkID { - return ArtworkID{ - Kind: KindArtistArtwork, - ID: ar.ID, - } + return ArtworkID{Kind: KindArtistArtwork, ID: ar.ID, Hash: ar.ImageHash} } func artworkIDFromRadio(r Radio) ArtworkID { - return ArtworkID{ - Kind: KindRadioArtwork, - ID: r.ID, - LastUpdate: r.UpdatedAt, - } + return ArtworkID{Kind: KindRadioArtwork, ID: r.ID, Hash: r.ImageHash} } diff --git a/model/artwork_id_test.go b/model/artwork_id_test.go index ad66f7bb5..af6a12ffb 100644 --- a/model/artwork_id_test.go +++ b/model/artwork_id_test.go @@ -9,14 +9,31 @@ import ( ) var _ = Describe("ArtworkID", func() { + Describe("String()", func() { + It("returns a bare id when there is no hash", func() { + id := model.ArtworkID{Kind: model.KindAlbumArtwork, ID: "1234"} + Expect(id.String()).To(Equal("al-1234")) + }) + It("appends the hash suffix when set", func() { + id := model.ArtworkID{Kind: model.KindAlbumArtwork, ID: "1234", Hash: "abcdef0123456789"} + Expect(id.String()).To(Equal("al-1234_abcdef0123456789")) + }) + It("never emits a legacy timestamp/_0 suffix", func() { + id := model.NewArtworkID(model.KindAlbumArtwork, "1234", new(time.Now())) + Expect(id.String()).To(Equal("al-1234")) + }) + It("returns empty string for an empty id", func() { + Expect(model.ArtworkID{Kind: model.KindAlbumArtwork}.String()).To(BeEmpty()) + }) + }) + Describe("NewArtworkID()", func() { - It("creates a valid parseable ArtworkID", func() { + It("round-trips Kind and ID through String()", func() { id := model.NewArtworkID(model.KindAlbumArtwork, "1234", new(time.Now())) parsedId, err := model.ParseArtworkID(id.String()) Expect(err).ToNot(HaveOccurred()) Expect(parsedId.Kind).To(Equal(id.Kind)) Expect(parsedId.ID).To(Equal(id.ID)) - Expect(parsedId.LastUpdate.Unix()).To(Equal(id.LastUpdate.Unix())) }) It("creates a valid ArtworkID without lastUpdate info", func() { id := model.NewArtworkID(model.KindPlaylistArtwork, "1234", nil) @@ -24,18 +41,16 @@ var _ = Describe("ArtworkID", func() { Expect(err).ToNot(HaveOccurred()) Expect(parsedId.Kind).To(Equal(id.Kind)) Expect(parsedId.ID).To(Equal(id.ID)) - Expect(parsedId.LastUpdate.Unix()).To(Equal(id.LastUpdate.Unix())) }) }) + Describe("ParseArtworkID - disc kind", func() { It("parses a disc artwork ID with dc prefix", func() { - now := time.Now() - id := model.NewArtworkID(model.KindDiscArtwork, "albumid123:2", &now) + id := model.NewArtworkID(model.KindDiscArtwork, "albumid123:2", nil) parsedId, err := model.ParseArtworkID(id.String()) Expect(err).ToNot(HaveOccurred()) Expect(parsedId.Kind).To(Equal(model.KindDiscArtwork)) Expect(parsedId.ID).To(Equal("albumid123:2")) - Expect(parsedId.LastUpdate.Unix()).To(Equal(now.Unix())) }) }) @@ -67,6 +82,7 @@ var _ = Describe("ArtworkID", func() { Expect(err).ToNot(HaveOccurred()) Expect(id.Kind).To(Equal(model.KindAlbumArtwork)) Expect(id.ID).To(Equal("1234")) + Expect(id.Hash).To(BeEmpty()) }) It("parses media file artwork ids", func() { id, err := model.ParseArtworkID("mf-a6f8d2b1") @@ -74,12 +90,45 @@ var _ = Describe("ArtworkID", func() { Expect(id.Kind).To(Equal(model.KindMediaFileArtwork)) Expect(id.ID).To(Equal("a6f8d2b1")) }) - It("parses playlists artwork ids", func() { + It("parses playlist artwork ids with dashed UUID", func() { id, err := model.ParseArtworkID("pl-18690de0-151b-4d86-81cb-f418a907315a") Expect(err).ToNot(HaveOccurred()) Expect(id.Kind).To(Equal(model.KindPlaylistArtwork)) Expect(id.ID).To(Equal("18690de0-151b-4d86-81cb-f418a907315a")) }) + It("captures a 16-hex suffix as Hash", func() { + id, err := model.ParseArtworkID("al-1234_abcdef0123456789") + Expect(err).ToNot(HaveOccurred()) + Expect(id.ID).To(Equal("1234")) + Expect(id.Hash).To(Equal("abcdef0123456789")) + Expect(id.LastUpdate.IsZero()).To(BeTrue()) + }) + It("captures a high-bit 16-hex suffix as Hash without error", func() { + id, err := model.ParseArtworkID("al-1234_ffffffffffffffff") + Expect(err).ToNot(HaveOccurred()) + Expect(id.ID).To(Equal("1234")) + Expect(id.Hash).To(Equal("ffffffffffffffff")) + }) + It("parses a legacy hex-timestamp suffix as LastUpdate", func() { + id, err := model.ParseArtworkID("al-123_688a1b2c") + Expect(err).ToNot(HaveOccurred()) + Expect(id.ID).To(Equal("123")) + Expect(id.Hash).To(BeEmpty()) + Expect(id.LastUpdate.Unix()).To(Equal(int64(0x688a1b2c))) + }) + It("parses a legacy _0 suffix", func() { + id, err := model.ParseArtworkID("al-123_0") + Expect(err).ToNot(HaveOccurred()) + Expect(id.ID).To(Equal("123")) + Expect(id.Hash).To(BeEmpty()) + Expect(id.LastUpdate.IsZero()).To(BeTrue()) + }) + It("silently drops a garbage suffix", func() { + id, err := model.ParseArtworkID("al-123_zz") + Expect(err).ToNot(HaveOccurred()) + Expect(id.ID).To(Equal("123")) + Expect(id.Hash).To(BeEmpty()) + }) It("fails to parse malformed ids", func() { _, err := model.ParseArtworkID("a6f8d2b1") Expect(err).To(MatchError("invalid artwork id")) diff --git a/model/artwork_test.go b/model/artwork_test.go new file mode 100644 index 000000000..baf741e68 --- /dev/null +++ b/model/artwork_test.go @@ -0,0 +1,73 @@ +package model_test + +import ( + "encoding/json" + + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func jsonOf(v any) map[string]any { + GinkgoHelper() + data, err := json.Marshal(v) + Expect(err).ToNot(HaveOccurred()) + var out map[string]any + Expect(json.Unmarshal(data, &out)).To(Succeed()) + return out +} + +var _ = Describe("ItemImage JSON", func() { + It("exposes the artwork state a client needs to render a placeholder", func() { + al := model.Album{ID: "al-1", Name: "Album"} + al.ImageHash = "0123456789abcdef" + al.ThumbHash = "1QcSHQRn" + al.ImageWidth, al.ImageHeight = 1200, 800 + + Expect(jsonOf(al)).To(SatisfyAll( + HaveKeyWithValue("imageHash", "0123456789abcdef"), + HaveKeyWithValue("thumbHash", "1QcSHQRn"), + HaveKeyWithValue("imageWidth", BeNumerically("==", 1200)), + HaveKeyWithValue("imageHeight", BeNumerically("==", 800)), + )) + }) + + It("keeps the blurhash off the native API, where nothing consumes it", func() { + al := model.Album{ID: "al-1", Name: "Album"} + al.BlurHash = "LEHV6nWB2yk8" + Expect(jsonOf(al)).ToNot(HaveKey("blurHash")) + }) + + It("omits every artwork field when the entity has none", func() { + out := jsonOf(model.Album{ID: "al-2", Name: "Album"}) + for _, key := range []string{ + "imageHash", "blurHash", "thumbHash", "imageAbsent", "imageWidth", "imageHeight", + } { + Expect(out).ToNot(HaveKey(key)) + } + }) + + It("exposes known-absent artwork so clients can skip the request", func() { + ar := model.Artist{ID: "ar-1", Name: "Artist"} + ar.ImageAbsent = true + Expect(jsonOf(ar)).To(HaveKeyWithValue("imageAbsent", true)) + }) + + Describe("AspectRatio", func() { + It("returns width/height", func() { + img := model.ItemImage{ImageHash: "abc", ImageWidth: 1200, ImageHeight: 800} + Expect(*img.AspectRatio()).To(BeNumerically("~", 1.5, 0.0001)) + }) + + It("returns nil when a dimension is missing, so callers never guess a ratio", func() { + Expect(model.ItemImage{ImageHash: "abc"}.AspectRatio()).To(BeNil()) + Expect(model.ItemImage{ImageHash: "abc", ImageWidth: 1200}.AspectRatio()).To(BeNil()) + Expect(model.ItemImage{ImageHash: "abc", ImageHeight: 800}.AspectRatio()).To(BeNil()) + }) + + It("returns nil for a known-absent image, whatever the dimensions say", func() { + img := model.ItemImage{ImageAbsent: true, ImageWidth: 1200, ImageHeight: 800} + Expect(img.AspectRatio()).To(BeNil()) + }) + }) +}) diff --git a/model/datastore.go b/model/datastore.go index 94c3c3622..273ca714b 100644 --- a/model/datastore.go +++ b/model/datastore.go @@ -40,6 +40,8 @@ type DataStore interface { ScrobbleBuffer(ctx context.Context) ScrobbleBufferRepository Scrobble(ctx context.Context) ScrobbleRepository Plugin(ctx context.Context) PluginRepository + Artwork(ctx context.Context) ArtworkRepository + ArtworkQueue(ctx context.Context) ArtworkQueueRepository Resource(ctx context.Context, model any) ResourceRepository diff --git a/model/mediafile.go b/model/mediafile.go index 22ab7fbbe..ac1fad67b 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -24,6 +24,11 @@ import ( type MediaFile struct { Annotations `structs:"-" hash:"ignore"` Bookmarkable `structs:"-" hash:"ignore"` + ItemImage `structs:"-" hash:"ignore"` + + // AlbumImage is the parent album's artwork state, hydrated alongside the track's own so a + // song's Jellyfin album-art tag can be pixel-versioned without a second query. + AlbumImage ItemImage `structs:"-" json:"-" hash:"ignore"` ID string `structs:"id" json:"id" hash:"ignore"` PID string `structs:"pid" json:"-" hash:"ignore"` @@ -139,13 +144,15 @@ func (mf MediaFile) CoverArtID() ArtworkID { // otherwise it returns the album artwork ID. func (mf MediaFile) DiscCoverArtID() ArtworkID { if mf.DiscNumber > 0 { - return NewArtworkID(KindDiscArtwork, DiscArtworkID(mf.AlbumID, mf.DiscNumber), nil) + return ArtworkID{Kind: KindDiscArtwork, ID: DiscArtworkID(mf.AlbumID, mf.DiscNumber), Hash: mf.ImageHash} } return mf.AlbumCoverArtID() } +// AlbumCoverArtID uses AlbumImage, not the track's own ItemImage: an album id must carry the +// album's content hash even when the track resolved art of its own. func (mf MediaFile) AlbumCoverArtID() ArtworkID { - return artworkIDFromAlbum(Album{ID: mf.AlbumID}) + return artworkIDFromAlbum(Album{ID: mf.AlbumID, ItemImage: mf.AlbumImage}) } func (mf MediaFile) StructuredLyrics() (LyricList, error) { @@ -542,6 +549,11 @@ type MediaFileRepository interface { GetRandom(options ...QueryOptions) (MediaFiles, error) GetAllByTags(tag TagName, values []string, options ...QueryOptions) (MediaFiles, error) GetCursor(options ...QueryOptions) (MediaFileCursor, error) + // GetAllIDs returns just the media_file IDs for the same row set as GetAll. + GetAllIDs(options ...QueryOptions) ([]string, error) + // GetCursorWithArtwork streams like GetCursor, hydrated, so callers that render images don't + // pay the scanner's per-row cost; it uses the same id pre-pass as the other cursors. + GetCursorWithArtwork(options ...QueryOptions) (MediaFileCursor, error) Delete(id string) error DeleteMissing(ids []string) error DeleteAllMissing() (int64, error) diff --git a/model/playlist.go b/model/playlist.go index 185f6f942..9aa54bf13 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -13,6 +13,7 @@ import ( type Playlist struct { Annotations `structs:"-"` + ItemImage `structs:"-"` ID string `structs:"id" json:"id"` Name string `structs:"name" json:"name"` @@ -143,6 +144,7 @@ type PlaylistRepository interface { Get(id string) (*Playlist, error) GetWithTracks(id string, refreshSmartPlaylist, includeMissing bool) (*Playlist, error) GetAll(options ...QueryOptions) (Playlists, error) + GetAllIDs(options ...QueryOptions) ([]string, error) GetCursor(options ...QueryOptions) (PlaylistCursor, error) FindByPath(path string) (*Playlist, error) Delete(id string) error diff --git a/model/radio.go b/model/radio.go index 86f27c24c..466ff48b0 100644 --- a/model/radio.go +++ b/model/radio.go @@ -7,6 +7,8 @@ import ( ) type Radio struct { + ItemImage `structs:"-"` + ID string `structs:"id" json:"id"` StreamUrl string `structs:"stream_url" json:"streamUrl"` Name string `structs:"name" json:"name"` @@ -30,7 +32,9 @@ type RadioRepository interface { ResourceRepository CountAll(options ...QueryOptions) (int64, error) Delete(id string) error + Exists(id string) (bool, error) Get(id string) (*Radio, error) GetAll(options ...QueryOptions) (Radios, error) + GetAllIDs(options ...QueryOptions) ([]string, error) Put(u *Radio, colsToUpdate ...string) error } diff --git a/model/radio_test.go b/model/radio_test.go index 860331f17..6ae74c3f4 100644 --- a/model/radio_test.go +++ b/model/radio_test.go @@ -14,12 +14,11 @@ import ( var _ = Describe("Radio", func() { Describe("CoverArtID", func() { It("returns a radio artwork ID", func() { - now := time.Now() - r := model.Radio{ID: "rd-1", UpdatedAt: now} + r := model.Radio{ID: "rd-1", UpdatedAt: time.Now()} artID := r.CoverArtID() Expect(artID.Kind).To(Equal(model.KindRadioArtwork)) Expect(artID.ID).To(Equal("rd-1")) - Expect(artID.LastUpdate).To(Equal(now)) + Expect(artID.LastUpdate.IsZero()).To(BeTrue()) }) }) diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 2bb541003..1ebb09751 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -202,7 +202,9 @@ func (r *albumRepository) CountAll(options ...model.QueryOptions) (int64, error) } func (r *albumRepository) Exists(id string) (bool, error) { - return r.exists(Eq{"album.id": id}) + // The exists() helper applies no library filter, so it would report rows the caller cannot see. + c, err := r.count(r.applyLibraryFilter(r.newSelect().Where(Eq{"album.id": id}))) + return c > 0, err } func (r *albumRepository) Put(al *model.Album) error { @@ -251,16 +253,36 @@ func (r *albumRepository) GetAll(options ...model.QueryOptions) (model.Albums, e if err != nil { return nil, err } - return res.toModels(), nil + albums := res.toModels() + r.hydrateArtwork(albums) + return albums, nil +} + +func (r *albumRepository) hydrateArtwork(albums model.Albums) { + hydrateItems(r.ctx, r.db, model.KindAlbumArtwork, albums, + func(a *model.Album) (string, *model.ItemImage) { return a.ID, &a.ItemImage }) +} + +// GetAllIDs returns the IDs of GetAll's row set, skipping its column projection and JSON decoding. +func (r *albumRepository) GetAllIDs(options ...model.QueryOptions) ([]string, error) { + sq := r.applyLibraryFilter(r.newSelect(options...).Columns("album.id")) + if filtersNeedAnnotation(sq) { + sq = r.withAnnotation(sq, "album.id") + } + ids := []string{} + err := r.queryAllSlice(sq, &ids) + return ids, err } func (r *albumRepository) GetCursor(options ...model.QueryOptions) (model.AlbumCursor, error) { - sq := r.selectAlbum(options...) - cursor, err := queryWithStableResults[dbAlbum](r.sqlRepository, sq) + ids, err := r.GetAllIDs(options...) if err != nil { return nil, err } - return wrapAlbumCursor(cursor), nil + opts := chunkOptions(options, "album.id") + return model.AlbumCursor(streamByIDs(ids, func(chunk []string) (model.Albums, error) { + return r.GetAll(opts(chunk)) + })), nil } func (r *albumRepository) GetYears(libraryIDs ...int) ([]int, error) { @@ -405,7 +427,9 @@ func (r *albumRepository) Search(q string, options ...model.QueryOptions) (model if err != nil { return nil, fmt.Errorf("searching album %q: %w", q, err) } - return res.toModels(), nil + albums := res.toModels() + r.hydrateArtwork(albums) + return albums, nil } func (r *albumRepository) Count(options ...rest.QueryOptions) (int64, error) { diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index 465617b10..1ead89529 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -13,6 +13,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/utils/slice" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -84,6 +85,17 @@ var _ = Describe("AlbumRepository", func() { }) }) + Describe("GetAllIDs", func() { + It("returns the same id set as GetAll", func() { + want, err := albumRepo.GetAll() + Expect(err).ToNot(HaveOccurred()) + Expect(want).ToNot(BeEmpty()) + ids, err := albumRepo.GetAllIDs() + Expect(err).ToNot(HaveOccurred()) + Expect(ids).To(ConsistOf(slice.Map(want, func(a model.Album) string { return a.ID }))) + }) + }) + Describe("GetAll", func() { var GetAll = func(opts ...model.QueryOptions) (model.Albums, error) { albums, err := albumRepo.GetAll(opts...) @@ -977,6 +989,22 @@ var _ = Describe("AlbumRepository", func() { Expect(got.RGAlbumPeak).To(BeNil()) }) }) + + // Exists must apply the same library filter as Get/GetAll/CountAll. + Describe("Exists library visibility", func() { + It("hides an album the user has no library access to", func() { + Expect(albumRepo.Put(&model.Album{ID: "vis-album", Name: "Vis", LibraryID: 1})).To(Succeed()) + DeferCleanup(func() { + _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": "vis-album"})) + }) + + Expect(albumRepo.Exists("vis-album")).To(BeTrue(), "admin sees it") + + restricted := model.User{ID: "restricted_album_user", UserName: "ra", Name: "RA", Email: "ra@t.com"} + rctx := request.WithUser(GinkgoT().Context(), restricted) + Expect(NewAlbumRepository(rctx, GetDBXBuilder()).Exists("vis-album")).To(BeFalse()) + }) + }) }) func _p(id, name string, sortName ...string) model.Participant { diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go index b542dedb4..67d0df448 100644 --- a/persistence/artist_repository.go +++ b/persistence/artist_repository.go @@ -6,7 +6,6 @@ import ( "encoding/json" "errors" "fmt" - "iter" "os" "slices" "strings" @@ -250,6 +249,7 @@ func (r *artistRepository) Get(id string) (*model.Artist, error) { return nil, model.ErrNotFound } res := dba.toModels() + r.hydrateArtwork(res) return &res[0], nil } @@ -261,20 +261,37 @@ func (r *artistRepository) GetAll(options ...model.QueryOptions) (model.Artists, return nil, err } res := dba.toModels() + r.hydrateArtwork(res) return res, err } +// GetAllIDs returns just the artist IDs for the same row set as GetAll, skipping the +// heavy stats columns and JSON post-processing. +func (r *artistRepository) GetAllIDs(options ...model.QueryOptions) ([]string, error) { + sq := r.applyLibraryFilterToArtistQuery(r.newSelect(options...).Columns("artist.id")).GroupBy("artist.id") + if filtersNeedAnnotation(sq) { + sq = r.withAnnotation(sq, "artist.id") + } + ids := []string{} + err := r.queryAllSlice(sq, &ids) + return ids, err +} + +// hydrateArtwork fills each artist's ImageHash/ImageAbsent from one batched item_artwork lookup. +func (r *artistRepository) hydrateArtwork(artists model.Artists) { + hydrateItems(r.ctx, r.db, model.KindArtistArtwork, artists, + func(a *model.Artist) (string, *model.ItemImage) { return a.ID, &a.ItemImage }) +} + func (r *artistRepository) GetCursor(options ...model.QueryOptions) (model.ArtistCursor, error) { - sel := r.selectArtist(options...) - cursor, err := queryWithStableResults[dbArtist](r.sqlRepository, sel) + ids, err := r.GetAllIDs(options...) 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 })) + opts := chunkOptions(options, "artist.id") + return model.ArtistCursor(streamByIDs(ids, func(chunk []string) (model.Artists, error) { + return r.GetAll(opts(chunk)) + })), nil } func (r *artistRepository) getIndexKey(a model.Artist) string { @@ -635,7 +652,9 @@ func (r *artistRepository) Search(q string, options ...model.QueryOptions) (mode if err != nil { return nil, fmt.Errorf("searching artist %q: %w", q, err) } - return res.toModels(), nil + artists := res.toModels() + r.hydrateArtwork(artists) + return artists, nil } // searchScope returns the library IDs the search must be restricted to, or nil to skip the filter diff --git a/persistence/artist_repository_test.go b/persistence/artist_repository_test.go index dc11ede36..25472ffe1 100644 --- a/persistence/artist_repository_test.go +++ b/persistence/artist_repository_test.go @@ -15,6 +15,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/utils" + "github.com/navidrome/navidrome/utils/slice" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -284,6 +285,17 @@ var _ = Describe("ArtistRepository", func() { }) }) + Describe("GetAllIDs", func() { + It("returns the same id set as GetAll", func() { + want, err := repo.GetAll() + Expect(err).ToNot(HaveOccurred()) + Expect(want).ToNot(BeEmpty()) + ids, err := repo.GetAllIDs() + Expect(err).ToNot(HaveOccurred()) + Expect(ids).To(ConsistOf(slice.Map(want, func(a model.Artist) string { return a.ID }))) + }) + }) + Describe("Basic Operations", func() { Describe("Count", func() { It("returns the number of artists in the DB", func() { diff --git a/persistence/artwork_hydration.go b/persistence/artwork_hydration.go new file mode 100644 index 000000000..fa9920f27 --- /dev/null +++ b/persistence/artwork_hydration.go @@ -0,0 +1,181 @@ +package persistence + +import ( + "context" + "iter" + "slices" + + . "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/pocketbase/dbx" +) + +// Keeps the id IN-list under SQLite's bound-parameter limit; a whole multiple of artworkBatchSize +// so a page re-chunks into even hydration batches. +const artworkChunkSize = artworkBatchSize * 3 + +// streamByIDs yields rows in id chunks through the caller's hydrating fetch. Resolving ids first +// keeps OFFSET out of the joined query. +func streamByIDs[S ~[]T, T any](ids []string, fetch func(chunk []string) (S, error)) iter.Seq2[T, error] { + return func(yield func(T, error) bool) { + for chunk := range slices.Chunk(ids, artworkChunkSize) { + rows, err := fetch(chunk) + if err != nil { + var zero T + yield(zero, err) + return + } + for _, row := range rows { + if !yield(row, nil) { + return + } + } + } + } +} + +// chunkOptions narrows options to a chunk of ids, dropping Max/Offset (the id pre-pass already +// consumed them) and reusing Sort, which may be a seeded random expression by now. +func chunkOptions(options []model.QueryOptions, idField string) func([]string) model.QueryOptions { + var base model.QueryOptions + if len(options) > 0 { + base = model.QueryOptions{Sort: options[0].Sort, Order: options[0].Order, Filters: options[0].Filters} + } + return func(chunk []string) model.QueryOptions { + opts := base + opts.Filters = Eq{idField: chunk} + if base.Filters != nil { + opts.Filters = And{base.Filters, opts.Filters} + } + return opts + } +} + +// hydrateItemImages returns per-item artwork info in one batched query per kind. On error it logs +// and returns an empty map, so the page still renders. +func hydrateItemImages(ctx context.Context, db dbx.Builder, kind model.Kind, ids []string) map[string]model.ItemArtworkInfo { + if len(ids) == 0 { + return map[string]model.ItemArtworkInfo{} + } + infos, err := NewArtworkRepository(ctx, db).GetInfoForItems(kind, ids) + if err != nil { + log.Error(ctx, "Failed to hydrate artwork info onto page", "kind", kind, err) + return map[string]model.ItemArtworkInfo{} + } + return infos +} + +// applyItemImage copies a hydration entry onto img; a missing entry leaves it zero (unresolved). +func applyItemImage(infos map[string]model.ItemArtworkInfo, id string, img *model.ItemImage) { + if info, ok := infos[id]; ok { + *img = info.Image() + } +} + +// hydrateItems fills every item's embedded ItemImage from one batched lookup. ref yields an +// item's id and the ItemImage to fill, which is all that differs between the entity kinds. +func hydrateItems[T any](ctx context.Context, db dbx.Builder, kind model.Kind, items []T, + ref func(*T) (string, *model.ItemImage)) { + ids := make([]string, len(items)) + for i := range items { + ids[i], _ = ref(&items[i]) + } + infos := hydrateItemImages(ctx, db, kind, ids) + for i := range items { + id, img := ref(&items[i]) + applyItemImage(infos, id, img) + } +} + +// hydrateMediaFileArtwork mirrors MediaFile.CoverArtID: an embedded-eligible file with resolved own +// art uses it, else it falls back to the album's. +func hydrateMediaFileArtwork(ctx context.Context, db dbx.Builder, mfs model.MediaFiles) { + if len(mfs) == 0 { + return + } + albumIDs := make([]string, len(mfs)) + var eligibleIDs []string + for i := range mfs { + albumIDs[i] = mfs[i].AlbumID + if mfs[i].HasCoverArt && conf.Server.EnableMediaFileCoverArt { + eligibleIDs = append(eligibleIDs, mfs[i].ID) + } + } + albumInfos := hydrateItemImages(ctx, db, model.KindAlbumArtwork, albumIDs) + mfInfos := hydrateItemImages(ctx, db, model.KindMediaFileArtwork, eligibleIDs) + for i := range mfs { + mf := &mfs[i] + applyItemImage(albumInfos, mf.AlbumID, &mf.AlbumImage) + eligible := mf.HasCoverArt && conf.Server.EnableMediaFileCoverArt + ownInfo, ownResolved := mfInfos[mf.ID] + if eligible && ownResolved && !ownInfo.Absent() { + mf.ItemImage = ownInfo.Image() + continue + } + ownWontResolve := !eligible || (ownResolved && ownInfo.Absent()) + // Inherit the album hash only when serving returns those exact bytes: a multi-disc track is + // served disc art, and an eligible-but-unresolved one still extracts its own embedded image. + if album, ok := albumInfos[mf.AlbumID]; ok && !album.Absent() { + if mf.DiscNumber == 0 && ownWontResolve { + mf.ItemImage = album.Image() + } + continue + } + // Mark absent only when serving would definitively yield a placeholder; disc art and + // still-extractable embedded art both keep a track requestable. + if mf.DiscNumber > 0 { + continue + } + if album, ok := albumInfos[mf.AlbumID]; ok && album.Absent() && ownWontResolve { + mf.ImageAbsent = true + } + } +} + +// hydrateCursor hydrates a streamed cursor in batches, avoiding a per-row query. +func hydrateCursor[T any](cursor iter.Seq2[T, error], hydrate func([]T)) iter.Seq2[T, error] { + return func(yield func(T, error) bool) { + buf := make([]T, 0, artworkBatchSize) + flush := func() bool { + hydrate(buf) + for i := range buf { + if !yield(buf[i], nil) { + return false + } + } + buf = buf[:0] + return true + } + for row, err := range cursor { + if err != nil { + var zero T + yield(zero, err) + return + } + buf = append(buf, row) + if len(buf) == artworkBatchSize && !flush() { + return + } + } + if len(buf) > 0 { + flush() + } + } +} + +// hydratePlaylistTrackArtwork hydrates the MediaFile embedded in each playlist track. +func hydratePlaylistTrackArtwork(ctx context.Context, db dbx.Builder, tracks model.PlaylistTracks) { + if len(tracks) == 0 { + return + } + mfs := make(model.MediaFiles, len(tracks)) + for i := range tracks { + mfs[i] = tracks[i].MediaFile + } + hydrateMediaFileArtwork(ctx, db, mfs) + for i := range tracks { + tracks[i].MediaFile = mfs[i] + } +} diff --git a/persistence/artwork_hydration_test.go b/persistence/artwork_hydration_test.go new file mode 100644 index 000000000..2b52d7d82 --- /dev/null +++ b/persistence/artwork_hydration_test.go @@ -0,0 +1,773 @@ +package persistence + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/utils/slice" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/pocketbase/dbx" +) + +// seedAnnotations gives each id distinct annotation values, writing them directly so that +// SetRating's average_rating update does not outlive the cleanup. +func seedAnnotations(itemType string, ids ...string) { + GinkgoHelper() + when := time.Date(2024, 3, 1, 12, 0, 0, 0, time.UTC) + for i, id := range ids { + _, err := GetDBXBuilder().NewQuery(`insert or replace into annotation + (user_id, item_id, item_type, play_count, play_date, rating, rated_at, starred, starred_at) + values ({:u}, {:id}, {:t}, {:pc}, {:d}, {:r}, {:d}, 1, {:d})`). + Bind(dbx.Params{"u": adminUser.ID, "id": id, "t": itemType, + "pc": i + 1, "r": 4 + i, "d": when.Add(time.Duration(i) * time.Hour)}).Execute() + Expect(err).ToNot(HaveOccurred()) + } + DeferCleanup(func() { + for _, id := range ids { + _, err := GetDBXBuilder().NewQuery("delete from annotation where user_id={:u} and item_type={:t} and item_id={:id}"). + Bind(dbx.Params{"u": adminUser.ID, "t": itemType, "id": id}).Execute() + Expect(err).ToNot(HaveOccurred()) + } + }) +} + +var _ = Describe("Artwork hydration", func() { + var ctx context.Context + var aw model.ArtworkRepository + + putInfo := func(kind, id, hash string) { + Expect(aw.PutItemArtwork(&model.ItemArtwork{ + ItemKind: kind, ItemID: id, ImageType: model.ImageTypePrimary, Hash: hash, + })).To(Succeed()) + } + + BeforeEach(func() { + clearArtworkTables() + DeferCleanup(clearArtworkTables) + ctx = request.WithUser(log.NewContext(context.Background()), adminUser) + aw = NewArtworkRepository(ctx, GetDBXBuilder()) + }) + + Describe("albums", func() { + var repo model.AlbumRepository + BeforeEach(func() { repo = NewAlbumRepository(ctx, GetDBXBuilder()) }) + + It("hydrates the found / known-absent / unresolved states", func() { + putInfo("al", albumSgtPeppers.ID, "althash11111111") + putInfo("al", albumAbbeyRoad.ID, "") + // albumRadioactivity: no row -> unresolved + + all, err := repo.GetAll() + Expect(err).ToNot(HaveOccurred()) + byID := slice.ToMap(all, func(a model.Album) (string, model.Album) { return a.ID, a }) + + Expect(byID[albumSgtPeppers.ID].ImageHash).To(Equal("althash11111111")) + Expect(byID[albumSgtPeppers.ID].ImageAbsent).To(BeFalse()) + Expect(byID[albumAbbeyRoad.ID].ImageHash).To(BeEmpty()) + Expect(byID[albumAbbeyRoad.ID].ImageAbsent).To(BeTrue()) + Expect(byID[albumRadioactivity.ID].ImageHash).To(BeEmpty()) + Expect(byID[albumRadioactivity.ID].ImageAbsent).To(BeFalse()) + }) + + It("hydrates Get", func() { + putInfo("al", albumSgtPeppers.ID, "gethash22222222") + got, err := repo.Get(albumSgtPeppers.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.ImageHash).To(Equal("gethash22222222")) + }) + + It("hydrates Search", func() { + putInfo("al", albumSgtPeppers.ID, "srchash33333333") + res, err := repo.Search("Peppers", model.QueryOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(res).ToNot(BeEmpty()) + Expect(res[0].ImageHash).To(Equal("srchash33333333")) + }) + + It("does not persist ImageHash/ImageAbsent on Put", func() { + al := albumSgtPeppers + al.ImageHash = "shouldnotpersist" + al.ImageAbsent = true + Expect(repo.(*albumRepository).Put(&al)).To(Succeed()) + + got, err := repo.Get(al.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.ImageHash).To(BeEmpty()) + Expect(got.ImageAbsent).To(BeFalse()) + }) + }) + + Describe("artists", func() { + var repo model.ArtistRepository + BeforeEach(func() { repo = NewArtistRepository(ctx, GetDBXBuilder()) }) + + It("hydrates the found / known-absent / unresolved states", func() { + putInfo("ar", artistBeatles.ID, "arhash444444444") + putInfo("ar", artistKraftwerk.ID, "") + // artistCJK: no row -> unresolved + + all, err := repo.GetAll() + Expect(err).ToNot(HaveOccurred()) + byID := slice.ToMap(all, func(a model.Artist) (string, model.Artist) { return a.ID, a }) + + Expect(byID[artistBeatles.ID].ImageHash).To(Equal("arhash444444444")) + Expect(byID[artistBeatles.ID].ImageAbsent).To(BeFalse()) + Expect(byID[artistKraftwerk.ID].ImageHash).To(BeEmpty()) + Expect(byID[artistKraftwerk.ID].ImageAbsent).To(BeTrue()) + Expect(byID[artistCJK.ID].ImageHash).To(BeEmpty()) + Expect(byID[artistCJK.ID].ImageAbsent).To(BeFalse()) + }) + + It("hydrates Get", func() { + putInfo("ar", artistBeatles.ID, "arget5555555555") + got, err := repo.Get(artistBeatles.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.ImageHash).To(Equal("arget5555555555")) + }) + + It("hydrates Search", func() { + putInfo("ar", artistBeatles.ID, "arsrch666666666") + res, err := repo.Search("Beatles", model.QueryOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(res).ToNot(BeEmpty()) + Expect(res[0].ImageHash).To(Equal("arsrch666666666")) + }) + }) + + Describe("playlists", func() { + var repo model.PlaylistRepository + BeforeEach(func() { repo = NewPlaylistRepository(ctx, GetDBXBuilder()) }) + + It("hydrates the found / known-absent states", func() { + putInfo("pl", plsBest.ID, "plhash777777777") + putInfo("pl", plsCool.ID, "") + + all, err := repo.GetAll() + Expect(err).ToNot(HaveOccurred()) + byID := slice.ToMap(all, func(p model.Playlist) (string, model.Playlist) { return p.ID, p }) + + Expect(byID[plsBest.ID].ImageHash).To(Equal("plhash777777777")) + Expect(byID[plsBest.ID].ImageAbsent).To(BeFalse()) + Expect(byID[plsCool.ID].ImageHash).To(BeEmpty()) + Expect(byID[plsCool.ID].ImageAbsent).To(BeTrue()) + }) + + It("hydrates Get", func() { + putInfo("pl", plsBest.ID, "plget8888888888") + got, err := repo.Get(plsBest.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.ImageHash).To(Equal("plget8888888888")) + }) + + It("hydrates the tracks reached through a playlist", func() { + Expect(aw.PutImage(&model.Artwork{Hash: "pltrackhash1234", Mime: "image/jpeg", BlurHash: "LPLBLURhash"})).To(Succeed()) + putInfo("al", songDayInALife.AlbumID, "pltrackhash1234") + + pls, err := repo.GetWithTracks(plsBest.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + tracks := pls.Tracks + Expect(tracks).ToNot(BeEmpty()) + byID := slice.ToMap(tracks, func(t model.PlaylistTrack) (string, model.PlaylistTrack) { return t.MediaFile.ID, t }) + Expect(byID).To(HaveKey(songDayInALife.ID)) + Expect(byID[songDayInALife.ID].AlbumImage.ImageHash).To(Equal("pltrackhash1234")) + Expect(byID[songDayInALife.ID].BlurHash).To(Equal("LPLBLURhash")) + + cursor, err := repo.Tracks(plsBest.ID, true).GetCursor() + Expect(err).ToNot(HaveOccurred()) + var streamed *model.PlaylistTrack + for t, err := range cursor { + Expect(err).ToNot(HaveOccurred()) + if t.MediaFile.ID == songDayInALife.ID { + streamed = &t + } + } + Expect(streamed).ToNot(BeNil()) + Expect(streamed.AlbumImage.ImageHash).To(Equal("pltrackhash1234"), + "the streamed cursor Jellyfin uses must hydrate too") + }) + }) + + Describe("radios", func() { + var repo model.RadioRepository + BeforeEach(func() { repo = NewRadioRepository(ctx, GetDBXBuilder()) }) + + It("hydrates the found / known-absent states", func() { + putInfo("ra", radioWithHomePage.ID, "rahash999999999") + putInfo("ra", radioWithoutHomePage.ID, "") + + all, err := repo.GetAll() + Expect(err).ToNot(HaveOccurred()) + byID := slice.ToMap(all, func(rd model.Radio) (string, model.Radio) { return rd.ID, rd }) + + Expect(byID[radioWithHomePage.ID].ImageHash).To(Equal("rahash999999999")) + Expect(byID[radioWithHomePage.ID].ImageAbsent).To(BeFalse()) + Expect(byID[radioWithoutHomePage.ID].ImageHash).To(BeEmpty()) + Expect(byID[radioWithoutHomePage.ID].ImageAbsent).To(BeTrue()) + }) + + It("hydrates Get", func() { + putInfo("ra", radioWithHomePage.ID, "ragetaaaaaaaaaa") + got, err := repo.Get(radioWithHomePage.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.ImageHash).To(Equal("ragetaaaaaaaaaa")) + }) + }) + + Describe("mediafiles", func() { + var repo model.MediaFileRepository + + setCover := func(id string, v bool) { + _, err := GetDBXBuilder().NewQuery("UPDATE media_file SET has_cover_art={:v} WHERE id={:id}"). + Bind(dbx.Params{"v": v, "id": id}).Execute() + Expect(err).ToNot(HaveOccurred()) + } + + getByID := func() map[string]model.MediaFile { + all, err := repo.GetAll() + Expect(err).ToNot(HaveOccurred()) + return slice.ToMap(all, func(mf model.MediaFile) (string, model.MediaFile) { return mf.ID, mf }) + } + + BeforeEach(func() { + repo = NewMediaFileRepository(ctx, GetDBXBuilder()) + DeferCleanup(configtest.SetupConfig()) + conf.Server.EnableMediaFileCoverArt = true + }) + + It("resolves the embedded-eligible fallback matrix", func() { + setCover("1001", true) // eligible, own hash + setCover("1002", true) // eligible, but embedded art absent -> album + DeferCleanup(func() { setCover("1001", false); setCover("1002", false) }) + + putInfo("al", "101", "alh101xxxxxxxxxx") // song 1001's album (found) + putInfo("al", "102", "alh102xxxxxxxxxx") // song 1002's album (found) + putInfo("al", "103", "") // songs 1003/1004 album known-absent + putInfo("mf", "1001", "mfh1001xxxxxxxx") + putInfo("mf", "1002", "") // embedded resolved absent + + byID := getByID() + + Expect(byID["1001"].ImageHash).To(Equal("mfh1001xxxxxxxx")) + Expect(byID["1001"].ImageAbsent).To(BeFalse()) + Expect(byID["1002"].ImageHash).To(Equal("alh102xxxxxxxxxx")) + Expect(byID["1002"].ImageAbsent).To(BeFalse()) + Expect(byID["1003"].ImageHash).To(BeEmpty()) + Expect(byID["1003"].ImageAbsent).To(BeTrue()) + // 2002: not eligible, and its album has no row at all -> unresolved + Expect(byID["2002"].ImageHash).To(BeEmpty()) + Expect(byID["2002"].ImageAbsent).To(BeFalse()) + }) + + It("populates AlbumImage from hydrateArtwork regardless of which continue branch a track takes", func() { + setCover("1001", true) // eligible, resolves its own art -> own-art-wins continue + DeferCleanup(func() { setCover("1001", false) }) + + putInfo("al", "101", "alh101albimgxxxx") // 1001's album: own-art-wins branch + putInfo("al", "102", "alh102albimgxxxx") // 1002's album: single-disc inheritance branch + putInfo("al", "104", "") // 2002's album: known-absent, multi-disc branch + putInfo("mf", "1001", "mfh1001albimgxxx") + + byID := getByID() + + Expect(byID["1001"].ImageHash).To(Equal("mfh1001albimgxxx")) + Expect(byID["1001"].AlbumImage.ImageHash).To(Equal("alh101albimgxxxx")) + + Expect(byID["1002"].ImageHash).To(Equal("alh102albimgxxxx")) + Expect(byID["1002"].AlbumImage.ImageHash).To(Equal("alh102albimgxxxx")) + + Expect(byID["2002"].ImageHash).To(BeEmpty()) + Expect(byID["2002"].ImageAbsent).To(BeFalse()) + Expect(byID["2002"].AlbumImage.ImageAbsent).To(BeTrue()) + }) + + It("carries both hashes and the dimensions alongside the hash in the own-art and inherited branches", func() { + setCover("1001", true) // eligible, resolves its own art -> own-art-wins branch + DeferCleanup(func() { setCover("1001", false) }) + + Expect(aw.PutImage(&model.Artwork{Hash: "mfh1001blurxxxxx", Mime: "image/jpeg", BlurHash: "LTRACKblur", ThumbHash: "THtrack", Width: 640, Height: 480})).To(Succeed()) + Expect(aw.PutImage(&model.Artwork{Hash: "alh102blurxxxxxx", Mime: "image/jpeg", BlurHash: "LALBUMblur", ThumbHash: "THalbum", Width: 1200, Height: 800})).To(Succeed()) + putInfo("mf", "1001", "mfh1001blurxxxxx") + putInfo("al", "102", "alh102blurxxxxxx") // 1002's album: single-disc inheritance branch + + byID := getByID() + + Expect(byID["1001"].ImageHash).To(Equal("mfh1001blurxxxxx")) + Expect(byID["1001"].BlurHash).To(Equal("LTRACKblur")) + Expect(byID["1001"].ThumbHash).To(Equal("THtrack")) + Expect(byID["1001"].ImageWidth).To(Equal(640)) + Expect(byID["1001"].ImageHeight).To(Equal(480)) + + Expect(byID["1002"].ImageHash).To(Equal("alh102blurxxxxxx")) + Expect(byID["1002"].BlurHash).To(Equal("LALBUMblur")) + Expect(byID["1002"].ThumbHash).To(Equal("THalbum")) + Expect(byID["1002"].ImageWidth).To(Equal(1200)) + Expect(byID["1002"].ImageHeight).To(Equal(800)) + }) + + It("keeps an eligible file optimistic when its own art is unresolved, even if the album is absent", func() { + setCover("1004", true) + DeferCleanup(func() { setCover("1004", false) }) + putInfo("al", "103", "") // album known-absent + + byID := getByID() + + // 1004's own embedded art is still unresolved, so coverArt must stay requestable. + Expect(byID["1004"].ImageAbsent).To(BeFalse()) + Expect(byID["1004"].ImageHash).To(BeEmpty()) + // 1003 is not eligible, so it still inherits the album's absence. + Expect(byID["1003"].ImageAbsent).To(BeTrue()) + }) + + It("does not stamp a found album hash onto a multi-disc track (its dc- id is disc-served)", func() { + putInfo("al", "104", "alh104foundxxxxx") // songs 2002/2004 album is found + + byID := getByID() + + Expect(byID["2002"].ImageHash).To(BeEmpty()) + Expect(byID["2002"].ImageAbsent).To(BeFalse()) + }) + + It("keeps a multi-disc track requestable when its album is absent (disc art may resolve)", func() { + putInfo("al", "104", "") // songs 2002/2004 album known-absent + + byID := getByID() + + Expect(byID["2002"].ImageAbsent).To(BeFalse()) + Expect(byID["2002"].ImageHash).To(BeEmpty()) + }) + + // serveMediaFile serves this track's own embedded art, so the album's hash would + // advertise a content-version for bytes nobody will serve. + It("leaves the hash bare for an eligible file whose own art is unresolved", func() { + setCover("1004", true) + DeferCleanup(func() { setCover("1004", false) }) + putInfo("al", "103", "alh103found11111") + + byID := getByID() + Expect(byID["1004"].ImageAbsent).To(BeFalse()) + Expect(byID["1004"].ImageHash).To(BeEmpty()) + Expect(byID["1004"].AlbumImage.ImageHash).To(Equal("alh103found11111"), + "the album's own hash still hydrates, for AlbumCoverArtID") + }) + + It("uses album info for an eligible file when EnableMediaFileCoverArt is off", func() { + conf.Server.EnableMediaFileCoverArt = false + setCover("1001", true) + DeferCleanup(func() { setCover("1001", false) }) + + putInfo("al", "101", "alh101offxxxxxxx") + putInfo("mf", "1001", "mfh1001offxxxxx") + + byID := getByID() + Expect(byID["1001"].ImageHash).To(Equal("alh101offxxxxxxx")) + Expect(byID["1001"].ImageAbsent).To(BeFalse()) + }) + + It("hydrates Search", func() { + putInfo("al", "101", "alsrchhhhhhhhhhh") + res, err := repo.Search("A Day In A Life", model.QueryOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(res).ToNot(BeEmpty()) + Expect(res[0].ImageHash).To(Equal("alsrchhhhhhhhhhh")) + }) + }) + + Describe("GetCursor", func() { + var albumRepo model.AlbumRepository + var artistRepo model.ArtistRepository + var playlistRepo model.PlaylistRepository + var onlyAlbums, onlyArtists, onlyPlaylists squirrel.Eq + + scoped := func(opts model.QueryOptions, only squirrel.Eq) model.QueryOptions { + if opts.Filters == nil { + opts.Filters = only + } else { + opts.Filters = squirrel.And{only, opts.Filters} + } + return opts + } + + BeforeEach(func() { + albumRepo = NewAlbumRepository(ctx, GetDBXBuilder()) + artistRepo = NewArtistRepository(ctx, GetDBXBuilder()) + playlistRepo = NewPlaylistRepository(ctx, GetDBXBuilder()) + // Other specs leave rows behind, so scope every cursor spec to the fixtures. + onlyAlbums = squirrel.Eq{"album.id": []string{albumSgtPeppers.ID, albumAbbeyRoad.ID, + albumRadioactivity.ID, albumMultiDisc.ID, albumCJK.ID, albumPunctuation.ID}} + onlyArtists = squirrel.Eq{"artist.id": []string{artistKraftwerk.ID, artistBeatles.ID, + artistCJK.ID, artistPunctuation.ID}} + // Both fixture playlists share an owner, leaving the owner_name sort a single value to + // order by; this one is also private, which the non-admin visibility spec needs. + foreign := model.Playlist{Name: "Foreign", OwnerID: thirdUser.ID, OwnerName: thirdUser.UserName} + Expect(playlistRepo.Put(&foreign)).To(Succeed()) + DeferCleanup(func() { Expect(playlistRepo.Delete(foreign.ID)).To(Succeed()) }) + onlyPlaylists = squirrel.Eq{"playlist.id": []string{plsBest.ID, plsCool.ID, foreign.ID}} + + // The suite annotates a single album and artist, leaving the annotation-backed sorts + // nothing to order. + seedAnnotations("album", albumSgtPeppers.ID, albumAbbeyRoad.ID) + seedAnnotations("artist", artistKraftwerk.ID, artistCJK.ID) + + Expect(aw.PutImage(&model.Artwork{ + Hash: "curhash11111111", Mime: "image/jpeg", BlurHash: "LEHV6nWB2yk8", + })).To(Succeed()) + putInfo("al", albumSgtPeppers.ID, "curhash11111111") + putInfo("al", albumAbbeyRoad.ID, "") + putInfo("ar", artistBeatles.ID, "curhash11111111") + putInfo("pl", plsBest.ID, "curhash11111111") + }) + + It("hydrates every streamed album, like GetAll", func() { + opts := model.QueryOptions{Sort: "name", Filters: onlyAlbums} + want, err := albumRepo.GetAll(opts) + Expect(err).ToNot(HaveOccurred()) + + got := collectCursor(albumRepo.GetCursor(opts)) + + Expect(got).To(ConsistOf(want)) + byID := map[string]model.Album{} + for _, al := range got { + byID[al.ID] = al + } + Expect(byID[albumSgtPeppers.ID].ImageHash).To(Equal("curhash11111111")) + Expect(byID[albumSgtPeppers.ID].BlurHash).To(Equal("LEHV6nWB2yk8")) + Expect(byID[albumAbbeyRoad.ID].ImageAbsent).To(BeTrue()) + Expect(byID[albumRadioactivity.ID].ImageHash).To(BeEmpty()) + Expect(byID[albumRadioactivity.ID].ImageAbsent).To(BeFalse()) + }) + + It("hydrates every streamed artist, like GetAll", func() { + opts := model.QueryOptions{Sort: "name", Filters: onlyArtists} + want, err := artistRepo.GetAll(opts) + Expect(err).ToNot(HaveOccurred()) + + got := collectCursor(artistRepo.GetCursor(opts)) + + Expect(got).To(ConsistOf(want)) + Expect(slice.Map(got, func(a model.Artist) string { return a.ImageHash })). + To(ContainElement("curhash11111111")) + }) + + It("hydrates every streamed playlist, like GetAll", func() { + opts := model.QueryOptions{Sort: "name", Filters: onlyPlaylists} + want, err := playlistRepo.GetAll(opts) + Expect(err).ToNot(HaveOccurred()) + + got := collectCursor(playlistRepo.GetCursor(opts)) + + Expect(got).To(ConsistOf(want)) + Expect(slice.Map(got, func(p model.Playlist) string { return p.ImageHash })). + To(ContainElement("curhash11111111")) + }) + + It("honors Max and Offset exactly once", func() { + opts := model.QueryOptions{Sort: "name", Filters: onlyAlbums, Max: 2, Offset: 1} + want, err := albumRepo.GetAll(opts) + Expect(err).ToNot(HaveOccurred()) + Expect(want).To(HaveLen(2)) + + got := collectCursor(albumRepo.GetCursor(opts)) + + Expect(slice.Map(got, func(a model.Album) string { return a.ID })). + To(Equal(slice.Map(want, func(a model.Album) string { return a.ID }))) + }) + + // The sorts the Jellyfin list endpoints issue; comparing sort keys, not ids, keeps ties out. + DescribeTable("orders albums like GetAll", + func(opts model.QueryOptions, key func(model.Album) string) { + opts = scoped(opts, onlyAlbums) + want, err := albumRepo.GetAll(opts) + Expect(err).ToNot(HaveOccurred()) + Expect(want).ToNot(BeEmpty()) + + got := collectCursor(albumRepo.GetCursor(opts)) + + Expect(slice.Map(got, key)).To(Equal(slice.Map(want, key))) + Expect(slice.Map(got, func(a model.Album) string { return a.ID })). + To(ConsistOf(slice.Map(want, func(a model.Album) string { return a.ID }))) + }, + Entry("by name", model.QueryOptions{Sort: "name"}, + func(a model.Album) string { return a.OrderAlbumName }), + Entry("by artist", model.QueryOptions{Sort: "artist"}, + func(a model.Album) string { return a.OrderAlbumArtistName }), + Entry("by recently added", model.QueryOptions{Sort: "recently_added", Order: "desc"}, + func(a model.Album) string { return fmt.Sprint(a.CreatedAt) }), + Entry("by year", model.QueryOptions{Sort: "max_year"}, + func(a model.Album) string { return fmt.Sprint(a.MaxYear) }), + Entry("by play count", model.QueryOptions{Sort: "play_count", Order: "desc"}, + func(a model.Album) string { return fmt.Sprint(a.PlayCount) }), + Entry("by last played", model.QueryOptions{Sort: "play_date", Order: "desc"}, + func(a model.Album) string { return fmt.Sprint(a.PlayDate) }), + Entry("by rating", model.QueryOptions{Sort: "rating", Order: "desc"}, + func(a model.Album) string { return fmt.Sprint(a.Rating) }), + Entry("starred only", model.QueryOptions{Sort: "starred_at", Order: "desc", + Filters: squirrel.Eq{"starred": true}}, + func(a model.Album) string { return fmt.Sprint(a.StarredAt) }), + ) + + DescribeTable("orders artists like GetAll", + func(opts model.QueryOptions, key func(model.Artist) string) { + opts = scoped(opts, onlyArtists) + want, err := artistRepo.GetAll(opts) + Expect(err).ToNot(HaveOccurred()) + Expect(want).ToNot(BeEmpty()) + + got := collectCursor(artistRepo.GetCursor(opts)) + + Expect(slice.Map(got, key)).To(Equal(slice.Map(want, key))) + Expect(slice.Map(got, func(a model.Artist) string { return a.ID })). + To(ConsistOf(slice.Map(want, func(a model.Artist) string { return a.ID }))) + }, + Entry("by name", model.QueryOptions{Sort: "name"}, + func(a model.Artist) string { return a.OrderArtistName }), + Entry("by album count", model.QueryOptions{Sort: "album_count", Order: "desc"}, + func(a model.Artist) string { return fmt.Sprint(a.AlbumCount) }), + Entry("by song count", model.QueryOptions{Sort: "song_count", Order: "desc"}, + func(a model.Artist) string { return fmt.Sprint(a.SongCount) }), + Entry("by play count", model.QueryOptions{Sort: "play_count", Order: "desc"}, + func(a model.Artist) string { return fmt.Sprint(a.PlayCount) }), + Entry("starred only", model.QueryOptions{Sort: "starred_at", Order: "desc", + Filters: squirrel.Eq{"starred": true}}, + func(a model.Artist) string { return fmt.Sprint(a.StarredAt) }), + ) + + DescribeTable("orders playlists like GetAll", + func(opts model.QueryOptions, key func(model.Playlist) string) { + opts = scoped(opts, onlyPlaylists) + want, err := playlistRepo.GetAll(opts) + Expect(err).ToNot(HaveOccurred()) + Expect(want).ToNot(BeEmpty()) + + got := collectCursor(playlistRepo.GetCursor(opts)) + + Expect(slice.Map(got, key)).To(Equal(slice.Map(want, key))) + Expect(slice.Map(got, func(p model.Playlist) string { return p.ID })). + To(ConsistOf(slice.Map(want, func(p model.Playlist) string { return p.ID }))) + }, + Entry("by name", model.QueryOptions{Sort: "name"}, + func(p model.Playlist) string { return p.Name }), + Entry("by creation date", model.QueryOptions{Sort: "created_at", Order: "desc"}, + func(p model.Playlist) string { return fmt.Sprint(p.CreatedAt) }), + Entry("by owner", model.QueryOptions{Sort: "owner_name"}, + func(p model.Playlist) string { return p.OwnerName }), + ) + + It("streams every album exactly once when sorted randomly", func() { + opts := model.QueryOptions{Sort: "random", Filters: onlyAlbums} + want, err := albumRepo.GetAll(opts) + Expect(err).ToNot(HaveOccurred()) + + got := collectCursor(albumRepo.GetCursor(opts)) + + Expect(slice.Map(got, func(a model.Album) string { return a.ID })). + To(ConsistOf(slice.Map(want, func(a model.Album) string { return a.ID }))) + }) + + It("keeps a non-admin from streaming another user's private playlists", func() { + otherCtx := request.WithUser(log.NewContext(context.Background()), regularUser) + repo := NewPlaylistRepository(otherCtx, GetDBXBuilder()) + opts := model.QueryOptions{Sort: "name", Filters: onlyPlaylists} + + // Both phases must filter on their own: the id pre-pass and the chunk fetch. + Expect(repo.GetAllIDs(opts)).To(ConsistOf(plsBest.ID)) + all, err := repo.GetAll(model.QueryOptions{Filters: onlyPlaylists}) + Expect(err).ToNot(HaveOccurred()) + Expect(slice.Map(all, func(p model.Playlist) string { return p.ID })).To(ConsistOf(plsBest.ID)) + + got := collectCursor(repo.GetCursor(opts)) + + Expect(slice.Map(got, func(p model.Playlist) string { return p.Name })). + To(ConsistOf(plsBest.Name)) + }) + }) + + Describe("GetCursorWithArtwork", func() { + var mfRepo model.MediaFileRepository + var onlySongs squirrel.Eq + + BeforeEach(func() { + mfRepo = NewMediaFileRepository(ctx, GetDBXBuilder()) + putInfo("al", albumSgtPeppers.ID, "curhash11111111") + // Distinct titles only: other fixture songs share titles (e.g. "Antenna" x3), which + // would make the positional comparisons against GetAll pass by tie-order coincidence. + onlySongs = squirrel.Eq{"media_file.id": []string{songDayInALife.ID, songComeTogether.ID, + songRadioactivity.ID, songAntenna.ID, songDisc1Track01.ID, songCJK.ID, songPunctuation.ID}} + }) + + It("hydrates artwork onto every streamed track, unlike GetCursor", func() { + opts := model.QueryOptions{Sort: "title", Filters: onlySongs} + want, err := mfRepo.GetAll(opts) + Expect(err).ToNot(HaveOccurred()) + Expect(want).ToNot(BeEmpty()) + + cursor, err := mfRepo.GetCursorWithArtwork(opts) + Expect(err).ToNot(HaveOccurred()) + var got model.MediaFiles + cursor(func(mf model.MediaFile, err error) bool { + Expect(err).ToNot(HaveOccurred()) + got = append(got, mf) + return true + }) + + Expect(got).To(HaveLen(len(want))) + for i := range want { + Expect(got[i].ID).To(Equal(want[i].ID)) + Expect(got[i].ImageHash).To(Equal(want[i].ImageHash)) + Expect(got[i].ImageAbsent).To(Equal(want[i].ImageAbsent)) + Expect(got[i].AlbumImage.ImageHash).To(Equal(want[i].AlbumImage.ImageHash)) + Expect(got[i].AlbumImage.BlurHash).To(Equal(want[i].AlbumImage.BlurHash)) + } + Expect(want).To(ContainElement(HaveField("AlbumImage.ImageHash", Not(BeEmpty()))), + "fixture must include at least one track with album artwork, or this proves nothing") + }) + + It("leaves the scanner's GetCursor unhydrated", func() { + cursor, err := mfRepo.GetCursor(model.QueryOptions{Sort: "title"}) + Expect(err).ToNot(HaveOccurred()) + var seen int + cursor(func(mf model.MediaFile, err error) bool { + Expect(err).ToNot(HaveOccurred()) + Expect(mf.AlbumImage.ImageHash).To(BeEmpty(), "GetCursor must stay unhydrated for the scanner") + seen++ + return true + }) + Expect(seen).To(BeNumerically(">", 0)) + }) + + It("streams the same ids in the same order as GetAll", func() { + opts := model.QueryOptions{Sort: "title", Filters: onlySongs} + want, err := mfRepo.GetAll(opts) + Expect(err).ToNot(HaveOccurred()) + Expect(want).ToNot(BeEmpty()) + + got := collectCursor(mfRepo.GetCursorWithArtwork(opts)) + + Expect(slice.Map(got, func(mf model.MediaFile) string { return mf.ID })). + To(Equal(slice.Map(want, func(mf model.MediaFile) string { return mf.ID }))) + }) + + It("honors Max and Offset exactly once", func() { + opts := model.QueryOptions{Sort: "title", Filters: onlySongs, Max: 2, Offset: 1} + want, err := mfRepo.GetAll(opts) + Expect(err).ToNot(HaveOccurred()) + Expect(want).To(HaveLen(2)) + + got := collectCursor(mfRepo.GetCursorWithArtwork(opts)) + + Expect(slice.Map(got, func(mf model.MediaFile) string { return mf.ID })). + To(Equal(slice.Map(want, func(mf model.MediaFile) string { return mf.ID }))) + }) + }) + + Describe("chunkOptions", func() { + It("carries Sort, Order and the caller's filters, but never Max/Offset", func() { + base := model.QueryOptions{Sort: "name", Order: "desc", Max: 10, Offset: 20, + Filters: squirrel.Eq{"missing": false}} + + got := chunkOptions([]model.QueryOptions{base}, "album.id")([]string{"al-1", "al-2"}) + + Expect(got.Max).To(BeZero()) + Expect(got.Offset).To(BeZero()) + Expect(got.Sort).To(Equal("name")) + Expect(got.Order).To(Equal("desc")) + Expect(got.Filters).To(Equal(squirrel.And{ + base.Filters, squirrel.Eq{"album.id": []string{"al-1", "al-2"}}, + })) + }) + + It("filters by ids alone when the caller passed no filters", func() { + got := chunkOptions(nil, "artist.id")([]string{"ar-1"}) + Expect(got.Filters).To(Equal(squirrel.Eq{"artist.id": []string{"ar-1"}})) + }) + }) + + Describe("streamByIDs", func() { + It("fetches in chunks and yields every row in order", func() { + ids := make([]string, artworkChunkSize+3) + for i := range ids { + ids[i] = fmt.Sprintf("id-%d", i) + } + var chunks [][]string + got := collectCursor(streamByIDs(ids, func(chunk []string) ([]string, error) { + chunks = append(chunks, chunk) + return chunk, nil + }), nil) + + Expect(chunks).To(HaveLen(2)) + Expect(chunks[0]).To(HaveLen(artworkChunkSize)) + Expect(chunks[1]).To(HaveLen(3)) + Expect(got).To(Equal(ids)) + }) + + It("yields the fetch error and stops", func() { + ids := make([]string, artworkChunkSize+1) + calls := 0 + var errs []error + for _, err := range streamByIDs(ids, func(chunk []string) ([]string, error) { + calls++ + return nil, errors.New("boom") + }) { + errs = append(errs, err) + } + + Expect(calls).To(Equal(1)) + Expect(errs).To(HaveLen(1)) + Expect(errs[0]).To(MatchError("boom")) + }) + + It("stops fetching when the consumer breaks", func() { + ids := make([]string, artworkChunkSize+1) + calls := 0 + for range streamByIDs(ids, func(chunk []string) ([]string, error) { + calls++ + return chunk, nil + }) { + break + } + + Expect(calls).To(Equal(1)) + }) + }) + + Describe("applyItemImage", func() { + It("copies hash, absence, blurhash and dimensions onto the item", func() { + infos := map[string]model.ItemArtworkInfo{ + "al-1": {ItemID: "al-1", Hash: "0123456789abcdef", BlurHash: "LEHV6nWB2yk8", ThumbHash: "1QcSHQRn", Width: 1200, Height: 800}, + } + var img model.ItemImage + applyItemImage(infos, "al-1", &img) + Expect(img.ImageHash).To(Equal("0123456789abcdef")) + Expect(img.ImageAbsent).To(BeFalse()) + Expect(img.BlurHash).To(Equal("LEHV6nWB2yk8")) + Expect(img.ThumbHash).To(Equal("1QcSHQRn")) + Expect(img.ImageWidth).To(Equal(1200)) + Expect(img.ImageHeight).To(Equal(800)) + }) + + It("marks a hashless entry absent and carries no blurhash", func() { + infos := map[string]model.ItemArtworkInfo{"al-2": {ItemID: "al-2"}} + var img model.ItemImage + applyItemImage(infos, "al-2", &img) + Expect(img.ImageAbsent).To(BeTrue()) + Expect(img.BlurHash).To(BeEmpty()) + Expect(img.ThumbHash).To(BeEmpty()) + }) + + It("leaves an unresolved item zero-valued", func() { + var img model.ItemImage + applyItemImage(map[string]model.ItemArtworkInfo{}, "al-3", &img) + Expect(img.ImageHash).To(BeEmpty()) + Expect(img.ImageAbsent).To(BeFalse()) + Expect(img.BlurHash).To(BeEmpty()) + }) + }) +}) diff --git a/persistence/artwork_queue_repository.go b/persistence/artwork_queue_repository.go new file mode 100644 index 000000000..e5469fbea --- /dev/null +++ b/persistence/artwork_queue_repository.go @@ -0,0 +1,149 @@ +package persistence + +import ( + "cmp" + "context" + "fmt" + "slices" + "strings" + "time" + + . "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/model" + "github.com/pocketbase/dbx" +) + +// Keeps each multi-row insert under SQLite's bind-variable limit (at most 7 vars per row). +const enqueueChunkSize = 100 + +// Every insert writes these, in this order; the INSERT..SELECT forms must project them to match. +var enqueueColumns = []string{"item_kind", "item_id", "image_type", "priority", "attempts", "retry_at", "enqueued_at"} + +type artworkQueueRepository struct { + sqlRepository +} + +func NewArtworkQueueRepository(ctx context.Context, db dbx.Builder) model.ArtworkQueueRepository { + r := &artworkQueueRepository{} + r.ctx = ctx + r.db = db + r.tableName = "artwork_queue" + return r +} + +// Enqueue also resets enqueued_at, so a fresh request does not inherit an old row's spent retry budget. +func (r *artworkQueueRepository) Enqueue(items ...model.ArtworkQueueItem) error { + return r.enqueue(`ON CONFLICT (item_kind, item_id, image_type) DO UPDATE SET + priority = MAX(priority, excluded.priority), retry_at = excluded.retry_at, + attempts = 0, enqueued_at = excluded.enqueued_at`, items) +} + +func (r *artworkQueueRepository) EnqueuePreservingBackoff(items ...model.ArtworkQueueItem) error { + return r.enqueue(`ON CONFLICT (item_kind, item_id, image_type) DO UPDATE SET + priority = MAX(priority, excluded.priority)`, items) +} + +func (r *artworkQueueRepository) EnqueueStaleAbsent(kind model.Kind, attemptedBefore time.Time) (int64, error) { + now := time.Now() + return r.insertIfNotQueued("", `SELECT item_kind, item_id, image_type, ?, 0, ?, ? + FROM `+itemArtworkTable+` WHERE item_kind = ? AND hash = '' AND attempted_at < ?`, + model.ArtworkPriorityRecheck, now, now, kind.Prefix(), attemptedBefore) +} + +func (r *artworkQueueRepository) EnqueueAllMissing(kind model.Kind, priority int) (int64, error) { + entityTable, ok := artworkOwnerTables[kind] + if !ok { + return 0, fmt.Errorf("artwork queue: no entity table for kind %q", kind.Prefix()) + } + now := time.Now() + return r.insertIfNotQueued("", `SELECT ?, id, ?, ?, 0, ?, ? + FROM `+entityTable+` + WHERE id NOT IN (SELECT item_id FROM `+itemArtworkTable+` WHERE item_kind = ?)`, + kind.Prefix(), model.ImageTypePrimary, priority, now, now, kind.Prefix()) +} + +func (r *artworkQueueRepository) EnqueueIfMissing(items ...model.ArtworkQueueItem) error { + now := time.Now() + for chunk := range slices.Chunk(items, enqueueChunkSize) { + rows := make([]string, 0, len(chunk)) + args := make([]any, 0, len(chunk)*4+2) + for _, it := range chunk { + rows = append(rows, "(?,?,?,?)") + args = append(args, it.ItemKind, it.ItemID, cmp.Or(it.ImageType, model.ImageTypePrimary), it.Priority) + } + args = append(args, now, now) + _, err := r.insertIfNotQueued( + `WITH new_items(item_kind, item_id, image_type, priority) AS (VALUES `+strings.Join(rows, ",")+`) `, + `SELECT n.item_kind, n.item_id, n.image_type, n.priority, 0, ?, ? + FROM new_items n + WHERE NOT EXISTS ( + SELECT 1 FROM `+itemArtworkTable+` ia + WHERE ia.item_kind = n.item_kind AND ia.item_id = n.item_id AND ia.image_type = n.image_type)`, + args...) + if err != nil { + return err + } + } + return nil +} + +// insertIfNotQueued inserts the rows selected by the given SQL, optionally prefixed by a CTE. DO NOTHING is +// deliberate: a recheck must not bump the priority or retry_at of an already-queued item. +func (r *artworkQueueRepository) insertIfNotQueued(with, sql string, args ...any) (int64, error) { + return r.executeSQL(Expr(with+`INSERT INTO `+r.tableName+ + ` (`+strings.Join(enqueueColumns, ", ")+`) `+sql+ + ` ON CONFLICT (item_kind, item_id, image_type) DO NOTHING`, args...)) +} + +func (r *artworkQueueRepository) enqueue(conflict string, items []model.ArtworkQueueItem) error { + now := time.Now() + for chunk := range slices.Chunk(items, enqueueChunkSize) { + ins := Insert(r.tableName).Columns(enqueueColumns...) + for _, it := range chunk { + ins = ins.Values(it.ItemKind, it.ItemID, cmp.Or(it.ImageType, model.ImageTypePrimary), it.Priority, 0, now, now) + } + ins = ins.Suffix(conflict) + if _, err := r.executeSQL(ins); err != nil { + return err + } + } + return nil +} + +func (r *artworkQueueRepository) DequeueBatch(n int, kinds ...string) ([]model.ArtworkQueueItem, error) { + sel := Select("*").From(r.tableName). + Where(LtOrEq{"retry_at": time.Now()}). + OrderBy("priority DESC", "enqueued_at ASC"). + Limit(uint64(n)) + if len(kinds) > 0 { + sel = sel.Where(Eq{"item_kind": kinds}) + } + var res []model.ArtworkQueueItem + err := r.queryAll(sel, &res) + return res, err +} + +func (r *artworkQueueRepository) MarkFailedIfUnchanged(kind, id, imageType string, seenRetryAt, retryAt time.Time) error { + upd := Update(r.tableName). + Set("attempts", Expr("attempts + 1")). + Set("retry_at", retryAt). + Where(Eq{"item_kind": kind, "item_id": id, "image_type": imageType, "retry_at": seenRetryAt}) + _, err := r.executeSQL(upd) + return err +} + +func (r *artworkQueueRepository) DeleteIfUnchanged(kind, id, imageType string, retryAt time.Time) error { + return r.delete(Eq{"item_kind": kind, "item_id": id, "image_type": imageType, "retry_at": retryAt}) +} + +func (r *artworkQueueRepository) PurgeDangling() (int64, error) { + return purgeDangling(r.sqlRepository) +} + +func (r *artworkQueueRepository) Count() (int64, error) { + var res struct{ Count int64 } + err := r.queryOne(Select("count(*) as count").From(r.tableName), &res) + return res.Count, err +} + +var _ model.ArtworkQueueRepository = (*artworkQueueRepository)(nil) diff --git a/persistence/artwork_queue_repository_test.go b/persistence/artwork_queue_repository_test.go new file mode 100644 index 000000000..6638a204e --- /dev/null +++ b/persistence/artwork_queue_repository_test.go @@ -0,0 +1,284 @@ +package persistence + +import ( + "context" + "time" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/slice" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/pocketbase/dbx" +) + +var _ = Describe("ArtworkQueueRepository", func() { + var repo model.ArtworkQueueRepository + + item := func(kind, id string, prio int) model.ArtworkQueueItem { + return model.ArtworkQueueItem{ItemKind: kind, ItemID: id, + ImageType: model.ImageTypePrimary, Priority: prio} + } + + // Writes the post-failure state directly: the production path, MarkFailedIfUnchanged, needs + // a retry_at only a dequeue can hand it. + backOff := func(kind, id string, retryAt time.Time) { + GinkgoHelper() + r := repo.(*artworkQueueRepository) + _, err := r.executeSQL(squirrel.Update(r.tableName). + Set("attempts", squirrel.Expr("attempts + 1")). + Set("retry_at", retryAt). + Where(squirrel.Eq{"item_kind": kind, "item_id": id, "image_type": model.ImageTypePrimary})) + Expect(err).ToNot(HaveOccurred()) + } + + remove := func(kind, id string) { + GinkgoHelper() + r := repo.(*artworkQueueRepository) + Expect(r.delete(squirrel.Eq{"item_kind": kind, "item_id": id, "image_type": model.ImageTypePrimary})).To(Succeed()) + } + + BeforeEach(func() { + clearArtworkTables() + DeferCleanup(clearArtworkTables) + repo = NewArtworkQueueRepository(context.Background(), GetDBXBuilder()) + }) + + It("enqueues and dequeues by priority then FIFO", func() { + Expect(repo.Enqueue(item("al", "low", model.ArtworkPriorityBackfill))).To(Succeed()) + Expect(repo.Enqueue(item("ar", "high", model.ArtworkPriorityBump))).To(Succeed()) + + got, err := repo.DequeueBatch(10) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(HaveLen(2)) + Expect(got[0].ItemID).To(Equal("high")) + }) + + It("keeps the higher priority on duplicate enqueue", func() { + Expect(repo.Enqueue(item("al", "a1", model.ArtworkPriorityBump))).To(Succeed()) + Expect(repo.Enqueue(item("al", "a1", model.ArtworkPriorityBackfill))).To(Succeed()) + got, _ := repo.DequeueBatch(10) + Expect(got).To(HaveLen(1)) + Expect(got[0].Priority).To(Equal(model.ArtworkPriorityBump)) + }) + + It("EnqueuePreservingBackoff raises priority without resetting a backing-off row's retry_at", func() { + Expect(repo.Enqueue(item("al", "b1", model.ArtworkPriorityScan))).To(Succeed()) + backOff("al", "b1", time.Now().Add(time.Hour)) + Expect(repo.DequeueBatch(10)).To(BeEmpty()) + + Expect(repo.EnqueuePreservingBackoff(item("al", "b1", model.ArtworkPriorityBump))).To(Succeed()) + Expect(repo.DequeueBatch(10)).To(BeEmpty(), "bump must not reset retry_at") + + // Enqueue (scan/manual), by contrast, resets retry_at and makes it eligible now. + Expect(repo.Enqueue(item("al", "b1", model.ArtworkPriorityScan))).To(Succeed()) + got, _ := repo.DequeueBatch(10) + Expect(got).To(HaveLen(1)) + Expect(got[0].Priority).To(Equal(model.ArtworkPriorityBump), "bump's higher priority is preserved") + }) + + It("EnqueuePreservingBackoff inserts a brand-new row eligible immediately", func() { + Expect(repo.EnqueuePreservingBackoff(item("ar", "n1", model.ArtworkPriorityBump))).To(Succeed()) + got, _ := repo.DequeueBatch(10) + Expect(got).To(HaveLen(1)) + Expect(got[0].ItemID).To(Equal("n1")) + }) + + It("hides failed items until retry_at", func() { + Expect(repo.Enqueue(item("al", "f1", model.ArtworkPriorityScan))).To(Succeed()) + backOff("al", "f1", time.Now().Add(time.Hour)) + + got, err := repo.DequeueBatch(10) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(BeEmpty()) + + backOff("al", "f1", time.Now().Add(-time.Minute)) + got, _ = repo.DequeueBatch(10) + Expect(got).To(HaveLen(1)) + Expect(got[0].Attempts).To(Equal(2)) + }) + + It("MarkFailedIfUnchanged applies backoff only while retry_at is unchanged", func() { + Expect(repo.Enqueue(item("al", "m1", model.ArtworkPriorityScan))).To(Succeed()) + // Anchor retry_at in the past so it can never collide with the re-enqueue's now. + backOff("al", "m1", time.Now().Add(-time.Hour)) + got, err := repo.DequeueBatch(10) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(HaveLen(1)) + original := got[0].RetryAt + + // A concurrent scan re-enqueues, resetting retry_at to now. + Expect(repo.Enqueue(item("al", "m1", model.ArtworkPriorityScan))).To(Succeed()) + + future := time.Now().Add(48 * time.Hour) + Expect(repo.MarkFailedIfUnchanged("al", "m1", model.ImageTypePrimary, original, future)).To(Succeed()) + got, _ = repo.DequeueBatch(10) + Expect(got).To(HaveLen(1), "the fresh re-enqueue stays immediately eligible") + Expect(got[0].Attempts).To(BeZero(), "re-enqueue clears attempts, and the stale failure must not bump them") + current := got[0].RetryAt + + Expect(repo.MarkFailedIfUnchanged("al", "m1", model.ImageTypePrimary, current, future)).To(Succeed()) + got, _ = repo.DequeueBatch(10) + Expect(got).To(BeEmpty(), "backed-off row is hidden until the future retry_at") + all, _ := repo.Count() + Expect(all).To(Equal(int64(1))) + }) + + It("Enqueue restarts the retry budget an existing row had spent", func() { + Expect(repo.Enqueue(item("al", "e1", model.ArtworkPriorityScan))).To(Succeed()) + backOff("al", "e1", time.Now().Add(-time.Hour)) + stale := time.Now().Add(-48 * time.Hour) + _, err := GetDBXBuilder().NewQuery("UPDATE artwork_queue SET enqueued_at = {:t} WHERE item_id = 'e1'"). + Bind(dbx.Params{"t": stale}).Execute() + Expect(err).ToNot(HaveOccurred()) + + Expect(repo.Enqueue(item("al", "e1", model.ArtworkPriorityScan))).To(Succeed()) + + got, err := repo.DequeueBatch(10) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(HaveLen(1)) + Expect(got[0].EnqueuedAt).To(BeTemporally("~", time.Now(), time.Minute), + "a re-request must not inherit a spent 12h window and give up on its first attempt") + Expect(got[0].Attempts).To(BeZero()) + }) + + It("deletes on completion and counts", func() { + Expect(repo.Enqueue(item("al", "c1", 0))).To(Succeed()) + n, _ := repo.Count() + Expect(n).To(Equal(int64(1))) + remove("al", "c1") + n, _ = repo.Count() + Expect(n).To(BeZero()) + }) + + It("DeleteIfUnchanged deletes only while retry_at is unchanged", func() { + Expect(repo.Enqueue(item("al", "d1", model.ArtworkPriorityScan))).To(Succeed()) + // Anchor retry_at in the past so it can never collide with the re-enqueue's now. + backOff("al", "d1", time.Now().Add(-time.Hour)) + got, err := repo.DequeueBatch(10) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(HaveLen(1)) + original := got[0].RetryAt + + // A concurrent scan re-enqueues, resetting retry_at to now. + Expect(repo.Enqueue(item("al", "d1", model.ArtworkPriorityScan))).To(Succeed()) + + // Deleting with the stale retry_at is a no-op: the re-enqueued row survives. + Expect(repo.DeleteIfUnchanged("al", "d1", model.ImageTypePrimary, original)).To(Succeed()) + n, _ := repo.Count() + Expect(n).To(Equal(int64(1))) + + got, _ = repo.DequeueBatch(10) + Expect(got).To(HaveLen(1)) + Expect(repo.DeleteIfUnchanged("al", "d1", model.ImageTypePrimary, got[0].RetryAt)).To(Succeed()) + n, _ = repo.Count() + Expect(n).To(BeZero()) + }) + + It("purges queue rows whose entity no longer exists, per kind", func() { + Expect(repo.Enqueue( + item("al", albumSgtPeppers.ID, model.ArtworkPriorityScan), + item("al", "no-such-album", model.ArtworkPriorityScan), + item("ar", artistKraftwerk.ID, model.ArtworkPriorityScan), + item("ar", "no-such-artist", model.ArtworkPriorityScan), + item("pl", plsBest.ID, model.ArtworkPriorityScan), + item("pl", "no-such-playlist", model.ArtworkPriorityScan), + item("ra", radioWithHomePage.ID, model.ArtworkPriorityScan), + item("ra", "no-such-radio", model.ArtworkPriorityScan), + item("mf", songDayInALife.ID, model.ArtworkPriorityScan), + item("mf", "no-such-mediafile", model.ArtworkPriorityScan), + )).To(Succeed()) + + purged, err := repo.PurgeDangling() + Expect(err).ToNot(HaveOccurred()) + Expect(purged).To(Equal(int64(5))) + + got, _ := repo.DequeueBatch(100) + ids := slice.Map(got, func(it model.ArtworkQueueItem) string { return it.ItemID }) + Expect(ids).To(ConsistOf(albumSgtPeppers.ID, artistKraftwerk.ID, plsBest.ID, radioWithHomePage.ID, songDayInALife.ID)) + }) + + It("enqueues stale absent states for recheck", func() { + awRepo := NewArtworkRepository(context.Background(), GetDBXBuilder()) + old := time.Now().Add(-48 * time.Hour) + Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "stale1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old})).To(Succeed()) + Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "fresh1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: time.Now()})).To(Succeed()) + Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "found1", ImageType: model.ImageTypePrimary, Hash: "hX", AttemptedAt: old})).To(Succeed()) + + n, err := repo.EnqueueStaleAbsent(model.KindArtistArtwork, time.Now().Add(-24*time.Hour)) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(int64(1))) + + items, err := repo.DequeueBatch(10) + Expect(err).ToNot(HaveOccurred()) + Expect(items).To(HaveLen(1)) + Expect(items[0].ItemID).To(Equal("stale1")) + Expect(items[0].Priority).To(Equal(model.ArtworkPriorityRecheck)) + }) + + It("enqueues entities that have no item_artwork row at all", func() { + awRepo := NewArtworkRepository(context.Background(), GetDBXBuilder()) + Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: albumSgtPeppers.ID, ImageType: model.ImageTypePrimary, Hash: "hX", AttemptedAt: time.Now()})).To(Succeed()) + Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: albumAbbeyRoad.ID, ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: time.Now()})).To(Succeed()) + + n, err := repo.EnqueueAllMissing(model.KindAlbumArtwork, model.ArtworkPriorityRecheck) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(BeNumerically(">=", 1)) + + got, err := repo.DequeueBatch(1000) + Expect(err).ToNot(HaveOccurred()) + ids := make([]string, 0, len(got)) + for _, it := range got { + Expect(it.ItemKind).To(Equal("al")) + Expect(it.Priority).To(Equal(model.ArtworkPriorityRecheck)) + ids = append(ids, it.ItemID) + } + Expect(ids).To(ContainElement(albumRadioactivity.ID), "an album with no row must be enqueued") + Expect(ids).ToNot(ContainElement(albumSgtPeppers.ID), "a resolved album must not be re-enqueued") + Expect(ids).ToNot(ContainElement(albumAbbeyRoad.ID), "an absent-state album must not be enqueued as missing") + }) + + It("EnqueueIfMissing skips items that already have an item_artwork row", func() { + awRepo := NewArtworkRepository(context.Background(), GetDBXBuilder()) + Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "resolved", ImageType: model.ImageTypePrimary, Hash: "hX", AttemptedAt: time.Now()})).To(Succeed()) + Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "absent", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: time.Now()})).To(Succeed()) + + Expect(repo.EnqueueIfMissing( + item("al", "resolved", model.ArtworkPriorityScan), + item("al", "absent", model.ArtworkPriorityScan), + item("al", "brandnew", model.ArtworkPriorityScan), + )).To(Succeed()) + + got, err := repo.DequeueBatch(100) + Expect(err).ToNot(HaveOccurred()) + ids := slice.Map(got, func(it model.ArtworkQueueItem) string { return it.ItemID }) + Expect(ids).To(ConsistOf("brandnew"), "only an item with no state row may be enqueued") + }) + + It("EnqueueIfMissing leaves an already-queued row untouched", func() { + Expect(repo.Enqueue(item("al", "queued", model.ArtworkPriorityBump))).To(Succeed()) + + Expect(repo.EnqueueIfMissing(item("al", "queued", model.ArtworkPriorityScan))).To(Succeed()) + + got, _ := repo.DequeueBatch(100) + Expect(got).To(HaveLen(1)) + Expect(got[0].Priority).To(Equal(model.ArtworkPriorityBump), "the existing priority must survive") + }) + + It("does not disturb an already-queued entity when enqueueing missing rows", func() { + Expect(repo.Enqueue(item("al", albumRadioactivity.ID, model.ArtworkPriorityBump))).To(Succeed()) + + _, err := repo.EnqueueAllMissing(model.KindAlbumArtwork, model.ArtworkPriorityRecheck) + Expect(err).ToNot(HaveOccurred()) + + got, _ := repo.DequeueBatch(1000) + var count int + for _, it := range got { + if it.ItemID == albumRadioactivity.ID { + count++ + Expect(it.Priority).To(Equal(model.ArtworkPriorityBump), "existing bump priority must survive") + } + } + Expect(count).To(Equal(1), "the already-queued row must not be duplicated") + }) +}) diff --git a/persistence/artwork_repository.go b/persistence/artwork_repository.go new file mode 100644 index 000000000..22662b575 --- /dev/null +++ b/persistence/artwork_repository.go @@ -0,0 +1,176 @@ +package persistence + +import ( + "cmp" + "context" + "slices" + "time" + + . "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/model" + "github.com/pocketbase/dbx" +) + +const ( + itemArtworkTable = "item_artwork" + artworkBatchSize = 200 +) + +type artworkRepository struct { + sqlRepository + items sqlRepository +} + +func NewArtworkRepository(ctx context.Context, db dbx.Builder) model.ArtworkRepository { + r := &artworkRepository{} + r.ctx = ctx + r.db = db + r.tableName = "artwork" + r.items.ctx = ctx + r.items.db = db + r.items.tableName = itemArtworkTable + return r +} + +func (r *artworkRepository) GetImage(hash string) (*model.Artwork, error) { + sel := Select("*").From(r.tableName).Where(Eq{"hash": hash}) + var res model.Artwork + if err := r.queryOne(sel, &res); err != nil { + return nil, err + } + return &res, nil +} + +func (r *artworkRepository) PutImage(a *model.Artwork) error { + // created_at is the last-acquisition-write time the prune grace window keys on. + a.CreatedAt = time.Now() + values, err := toSQLArgs(*a) + if err != nil { + return err + } + // created_at=excluded.created_at: reacquiring an orphan must reset the prune grace window. + ins := Insert(r.tableName).SetMap(values).Suffix(`ON CONFLICT (hash) DO UPDATE SET mime=excluded.mime, width=excluded.width, + height=excluded.height, size_bytes=excluded.size_bytes, blur_hash=excluded.blur_hash, + thumb_hash=excluded.thumb_hash, dominant_color=excluded.dominant_color, + created_at=excluded.created_at`) + _, err = r.executeSQL(ins) + return err +} + +func (r *artworkRepository) GetMimeByHash() (map[string]string, error) { + sel := Select("hash", "mime").From(r.tableName) + var rows []struct { + Hash string + Mime string + } + if err := r.queryAll(sel, &rows); err != nil { + return nil, err + } + res := make(map[string]string, len(rows)) + for _, row := range rows { + res[row.Hash] = row.Mime + } + return res, nil +} + +func (r *artworkRepository) PurgeOrphans(createdBefore time.Time) (int64, error) { + del := Delete(r.tableName).Where(And{ + Lt{"created_at": createdBefore}, + Expr("hash NOT IN (SELECT hash FROM " + itemArtworkTable + " WHERE hash <> '')"), + }) + return r.executeSQL(del) +} + +// artworkOwnerTables maps an artwork kind to the table that owns the entity. +var artworkOwnerTables = map[model.Kind]string{ + model.KindAlbumArtwork: "album", + model.KindArtistArtwork: "artist", + model.KindPlaylistArtwork: "playlist", + model.KindRadioArtwork: "radio", + model.KindMediaFileArtwork: "media_file", +} + +// purgeDangling deletes rows in r's table whose owning entity is gone, one statement per kind. +func purgeDangling(r sqlRepository) (int64, error) { + var total int64 + for kind, entityTable := range artworkOwnerTables { + del := Delete(r.tableName).Where(And{ + Eq{"item_kind": kind.Prefix()}, + Expr("item_id NOT IN (SELECT id FROM " + entityTable + ")"), + }) + c, err := r.executeSQL(del) + if err != nil { + return total, err + } + total += c + } + return total, nil +} + +func (r *artworkRepository) PurgeDanglingItems() (int64, error) { + return purgeDangling(r.items) +} + +func (r *artworkRepository) GetItemArtwork(kind model.Kind, id, imageType string) (*model.ItemArtwork, error) { + sel := Select("*").From(itemArtworkTable). + Where(Eq{"item_kind": kind.Prefix(), "item_id": id, "image_type": imageType}) + var res model.ItemArtwork + if err := r.items.queryOne(sel, &res); err != nil { + return nil, err + } + return &res, nil +} + +func (r *artworkRepository) PutItemArtwork(ia *model.ItemArtwork) error { + ia.ImageType = cmp.Or(ia.ImageType, model.ImageTypePrimary) + ia.UpdatedAt = time.Now() + // PutItemArtwork records the outcome of an attempt, so an unset attempted_at is now. + if ia.AttemptedAt.IsZero() { + ia.AttemptedAt = ia.UpdatedAt + } + values, err := toSQLArgs(*ia) + if err != nil { + return err + } + ins := Insert(itemArtworkTable).SetMap(values).Suffix(`ON CONFLICT (item_kind, item_id, image_type) DO UPDATE SET + hash=excluded.hash, source=excluded.source, source_path=excluded.source_path, ref_mtime=excluded.ref_mtime, + attempted_at=excluded.attempted_at, updated_at=excluded.updated_at`) + _, err = r.items.executeSQL(ins) + return err +} + +func (r *artworkRepository) DeleteForItems(kind model.Kind, ids []string) error { + for chunk := range slices.Chunk(ids, artworkBatchSize) { + if err := r.items.delete(Eq{"item_kind": kind.Prefix(), "item_id": chunk}); err != nil { + return err + } + } + return nil +} + +func (r *artworkRepository) GetInfoForItems(kind model.Kind, ids []string) (map[string]model.ItemArtworkInfo, error) { + res := map[string]model.ItemArtworkInfo{} + for chunk := range slices.Chunk(ids, artworkBatchSize) { + sel := Select("ia.item_id", "ia.hash", "COALESCE(a.blur_hash, '') as blur_hash", + "COALESCE(a.thumb_hash, '') as thumb_hash", + "COALESCE(a.dominant_color, '') as dominant_color", + "COALESCE(a.width, 0) as width", "COALESCE(a.height, 0) as height"). + From(itemArtworkTable + " ia"). + LeftJoin("artwork a ON a.hash = ia.hash"). + Where(And{ + Eq{"ia.item_kind": kind.Prefix()}, + Eq{"ia.image_type": model.ImageTypePrimary}, + Eq{"ia.item_id": chunk}, + }) + var rows []model.ItemArtworkInfo + if err := r.items.queryAll(sel, &rows); err != nil { + return nil, err + } + for _, row := range rows { + res[row.ItemID] = row + } + } + return res, nil +} + +var _ model.ArtworkRepository = (*artworkRepository)(nil) diff --git a/persistence/artwork_repository_test.go b/persistence/artwork_repository_test.go new file mode 100644 index 000000000..683dc2d0f --- /dev/null +++ b/persistence/artwork_repository_test.go @@ -0,0 +1,248 @@ +package persistence + +import ( + "context" + "fmt" + "time" + + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/pocketbase/dbx" +) + +func clearArtworkTables() { + db := GetDBXBuilder() + for _, t := range []string{"artwork_queue", "item_artwork", "artwork"} { + _, err := db.NewQuery("DELETE FROM " + t).Execute() + Expect(err).ToNot(HaveOccurred()) + } +} + +var _ = Describe("ArtworkRepository", func() { + var repo model.ArtworkRepository + + BeforeEach(func() { + clearArtworkTables() + DeferCleanup(clearArtworkTables) + repo = NewArtworkRepository(context.Background(), GetDBXBuilder()) + }) + + Context("image identity", func() { + It("stores and retrieves an artwork by hash", func() { + a := &model.Artwork{Hash: "abc123", Mime: "image/jpeg", Width: 500, Height: 500, SizeBytes: 1234, BlurHash: "LKO2?U%2Tw=w"} + Expect(repo.PutImage(a)).To(Succeed()) + + got, err := repo.GetImage("abc123") + Expect(err).ToNot(HaveOccurred()) + Expect(got.Mime).To(Equal("image/jpeg")) + Expect(got.BlurHash).To(Equal("LKO2?U%2Tw=w")) + Expect(got.CreatedAt).ToNot(BeZero()) + }) + + It("round-trips the thumbhash alongside the blurhash", func() { + a := &model.Artwork{Hash: "both1", Mime: "image/jpeg", BlurHash: "LKO2?U%2Tw=w", + ThumbHash: "1QcSHQRnh493V4dIh4eXh1h4kJUI", DominantColor: "#336699"} + Expect(repo.PutImage(a)).To(Succeed()) + + got, err := repo.GetImage("both1") + Expect(err).ToNot(HaveOccurred()) + Expect(got.BlurHash).To(Equal("LKO2?U%2Tw=w")) + Expect(got.ThumbHash).To(Equal("1QcSHQRnh493V4dIh4eXh1h4kJUI")) + Expect(got.DominantColor).To(Equal("#336699")) + }) + + It("overwrites the thumbhash on re-acquisition", func() { + Expect(repo.PutImage(&model.Artwork{Hash: "th2", Mime: "image/png", ThumbHash: "first", DominantColor: "#111111"})).To(Succeed()) + Expect(repo.PutImage(&model.Artwork{Hash: "th2", Mime: "image/png", ThumbHash: "second", DominantColor: "#222222"})).To(Succeed()) + + got, err := repo.GetImage("th2") + Expect(err).ToNot(HaveOccurred()) + Expect(got.ThumbHash).To(Equal("second")) + Expect(got.DominantColor).To(Equal("#222222")) + }) + + It("is idempotent on Put (upsert by hash)", func() { + a := &model.Artwork{Hash: "dup1", Mime: "image/png"} + Expect(repo.PutImage(a)).To(Succeed()) + a.BlurHash = "XYZ" + Expect(repo.PutImage(a)).To(Succeed()) + got, _ := repo.GetImage("dup1") + Expect(got.BlurHash).To(Equal("XYZ")) + }) + + It("refreshes created_at when reacquiring an existing hash", func() { + Expect(repo.PutImage(&model.Artwork{Hash: "reacq", Mime: "image/jpeg"})).To(Succeed()) + _, err := GetDBXBuilder().NewQuery("UPDATE artwork SET created_at={:t} WHERE hash='reacq'"). + Bind(dbx.Params{"t": "2000-01-01 00:00:00"}).Execute() + Expect(err).ToNot(HaveOccurred()) + + Expect(repo.PutImage(&model.Artwork{Hash: "reacq", Mime: "image/png"})).To(Succeed()) + + got, err := repo.GetImage("reacq") + Expect(err).ToNot(HaveOccurred()) + Expect(got.CreatedAt).To(BeTemporally(">", time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC))) + }) + + It("returns ErrNotFound for a missing hash", func() { + _, err := repo.GetImage("nope") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("returns every stored hash with its current mime", func() { + Expect(repo.PutImage(&model.Artwork{Hash: "all1", Mime: "image/jpeg"})).To(Succeed()) + Expect(repo.PutImage(&model.Artwork{Hash: "all2", Mime: "image/png"})).To(Succeed()) + mimes, err := repo.GetMimeByHash() + Expect(err).ToNot(HaveOccurred()) + Expect(mimes).To(HaveKeyWithValue("all1", "image/jpeg")) + Expect(mimes).To(HaveKeyWithValue("all2", "image/png")) + }) + + It("deletes only unreferenced rows older than the cutoff, reporting the count", func() { + Expect(repo.PutImage(&model.Artwork{Hash: "d1", Mime: "image/jpeg"})).To(Succeed()) + Expect(repo.PutImage(&model.Artwork{Hash: "dref", Mime: "image/jpeg"})).To(Succeed()) + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "a1", + ImageType: model.ImageTypePrimary, Hash: "dref", Source: "folder"})).To(Succeed()) + + Expect(repo.PurgeOrphans(time.Now().Add(time.Minute))).To(BeNumerically("==", 1)) + + _, err := repo.GetImage("d1") + Expect(err).To(MatchError(model.ErrNotFound)) + _, err = repo.GetImage("dref") + Expect(err).ToNot(HaveOccurred()) + }) + + It("spares an unreferenced row younger than the cutoff", func() { + Expect(repo.PutImage(&model.Artwork{Hash: "young", Mime: "image/jpeg"})).To(Succeed()) + Expect(repo.PurgeOrphans(time.Now().Add(-time.Hour))).To(BeNumerically("==", 0)) + _, err := repo.GetImage("young") + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Context("dangling state cleanup", func() { + It("purges item_artwork rows per kind whose entity no longer exists, summing counts", func() { + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: albumSgtPeppers.ID, ImageType: model.ImageTypePrimary, Hash: "keepAl"})).To(Succeed()) + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "no-such-album", ImageType: model.ImageTypePrimary, Hash: "danglingAl"})).To(Succeed()) + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: artistKraftwerk.ID, ImageType: model.ImageTypePrimary, Hash: "keepAr"})).To(Succeed()) + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "no-such-artist", ImageType: model.ImageTypePrimary, Hash: "danglingAr"})).To(Succeed()) + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "pl", ItemID: plsBest.ID, ImageType: model.ImageTypePrimary, Hash: "keepPl"})).To(Succeed()) + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "pl", ItemID: "no-such-playlist", ImageType: model.ImageTypePrimary, Hash: "danglingPl"})).To(Succeed()) + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ra", ItemID: radioWithHomePage.ID, ImageType: model.ImageTypePrimary, Hash: "keepRa"})).To(Succeed()) + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ra", ItemID: "no-such-radio", ImageType: model.ImageTypePrimary, Hash: "danglingRa"})).To(Succeed()) + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "mf", ItemID: songDayInALife.ID, ImageType: model.ImageTypePrimary, Hash: "keepMf"})).To(Succeed()) + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "mf", ItemID: "no-such-mediafile", ImageType: model.ImageTypePrimary, Hash: "danglingMf"})).To(Succeed()) + + purged, err := repo.PurgeDanglingItems() + Expect(err).ToNot(HaveOccurred()) + Expect(purged).To(Equal(int64(5))) + + for _, kept := range []model.ItemArtwork{ + {ItemKind: "al", ItemID: albumSgtPeppers.ID}, + {ItemKind: "ar", ItemID: artistKraftwerk.ID}, + {ItemKind: "pl", ItemID: plsBest.ID}, + {ItemKind: "ra", ItemID: radioWithHomePage.ID}, + {ItemKind: "mf", ItemID: songDayInALife.ID}, + } { + k, _ := model.ParseKind(kept.ItemKind) + _, err := repo.GetItemArtwork(k, kept.ItemID, model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + } + for _, gone := range []model.ItemArtwork{ + {ItemKind: "al", ItemID: "no-such-album"}, + {ItemKind: "ar", ItemID: "no-such-artist"}, + {ItemKind: "pl", ItemID: "no-such-playlist"}, + {ItemKind: "ra", ItemID: "no-such-radio"}, + {ItemKind: "mf", ItemID: "no-such-mediafile"}, + } { + k, _ := model.ParseKind(gone.ItemKind) + _, err := repo.GetItemArtwork(k, gone.ItemID, model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + } + }) + }) + + Context("item state", func() { + It("upserts and reads state, including per-item provenance", func() { + ia := &model.ItemArtwork{ItemKind: "al", ItemID: "al1", ImageType: model.ImageTypePrimary, + Hash: "h1", Source: "folder", SourcePath: "/music/a/cover.jpg", RefMtime: 111, AttemptedAt: time.Now()} + Expect(repo.PutItemArtwork(ia)).To(Succeed()) + ia.Source = "embedded" + ia.SourcePath = "/music/a/track.mp3" + ia.RefMtime = 222 + Expect(repo.PutItemArtwork(ia)).To(Succeed()) + + got, err := repo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(got.Source).To(Equal("embedded")) + Expect(got.SourcePath).To(Equal("/music/a/track.mp3")) + Expect(got.RefMtime).To(Equal(int64(222))) + Expect(got.UpdatedAt).ToNot(BeZero()) + }) + + It("defaults attempted_at to now when unset", func() { + before := time.Now().Add(-time.Second) + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "noattempt", + ImageType: model.ImageTypePrimary, Hash: ""})).To(Succeed()) + got, err := repo.GetItemArtwork(model.KindArtistArtwork, "noattempt", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(got.AttemptedAt).To(BeTemporally(">", before)) + }) + + It("represents known-absent as empty hash", func() { + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "ar1", + ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: time.Now()})).To(Succeed()) + got, err := repo.GetItemArtwork(model.KindArtistArtwork, "ar1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(got.Hash).To(BeEmpty()) + }) + + It("hydrates a page in one batch, including blurhash, dimensions and absence", func() { + Expect(repo.PutImage(&model.Artwork{Hash: "h9", Mime: "image/jpeg", BlurHash: "BH9", + DominantColor: "#abcdef", Width: 1200, Height: 800})).To(Succeed()) + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "x1", ImageType: model.ImageTypePrimary, Hash: "h9", Source: "folder"})).To(Succeed()) + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "x2", ImageType: model.ImageTypePrimary, Hash: "", Source: ""})).To(Succeed()) + + info, err := repo.GetInfoForItems(model.KindAlbumArtwork, []string{"x1", "x2", "x3"}) + Expect(err).ToNot(HaveOccurred()) + Expect(info).To(HaveLen(2)) + Expect(info["x1"].Hash).To(Equal("h9")) + Expect(info["x1"].BlurHash).To(Equal("BH9")) + Expect(info["x1"].DominantColor).To(Equal("#abcdef")) + Expect(info["x1"].Width).To(Equal(1200)) + Expect(info["x1"].Height).To(Equal(800)) + Expect(info["x1"].Absent()).To(BeFalse()) + Expect(info["x2"].Absent()).To(BeTrue()) + _, unresolved := info["x3"] + Expect(unresolved).To(BeFalse()) + }) + + It("deletes all rows for a single item", func() { + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "pl", ItemID: "p1", ImageType: model.ImageTypePrimary, Hash: "h1"})).To(Succeed()) + Expect(repo.DeleteForItems(model.KindPlaylistArtwork, []string{"p1"})).To(Succeed()) + _, err := repo.GetItemArtwork(model.KindPlaylistArtwork, "p1", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("deletes rows for many items in chunks, leaving others untouched", func() { + const n = artworkBatchSize + 5 + ids := make([]string, n) + for i := range n { + id := fmt.Sprintf("mf-%d", i) + ids[i] = id + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "mf", ItemID: id, ImageType: model.ImageTypePrimary, Hash: "h1"})).To(Succeed()) + } + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "mf", ItemID: "keep", ImageType: model.ImageTypePrimary, Hash: "h1"})).To(Succeed()) + + Expect(repo.DeleteForItems(model.KindMediaFileArtwork, ids)).To(Succeed()) + + for _, id := range ids { + _, err := repo.GetItemArtwork(model.KindMediaFileArtwork, id, model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + } + kept, err := repo.GetItemArtwork(model.KindMediaFileArtwork, "keep", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(kept.ItemID).To(Equal("keep")) + }) + }) +}) diff --git a/persistence/e2e/e2e_suite_test.go b/persistence/e2e/e2e_suite_test.go index 1ff2139e6..2b617f5b0 100644 --- a/persistence/e2e/e2e_suite_test.go +++ b/persistence/e2e/e2e_suite_test.go @@ -13,7 +13,6 @@ import ( "github.com/Masterminds/squirrel" "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/metrics" "github.com/navidrome/navidrome/core/playlists" @@ -275,8 +274,8 @@ var _ = BeforeSuite(func() { ctx = request.WithUser(GinkgoT().Context(), adminUser) buildTestFS() - s := scanner.New(ctx, initDS, artwork.NoopCacheWarmer(), events.NoopBroker(), - playlists.NewPlaylists(initDS, core.NewImageUploadService()), metrics.NewNoopInstance()) + s := scanner.New(ctx, initDS, events.NoopBroker(), + playlists.NewPlaylists(initDS, artwork.NewUploader(initDS)), metrics.NewNoopInstance()) _, err = s.ScanAll(ctx, true) Expect(err).ToNot(HaveOccurred()) diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index ace61610c..df1472c2a 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -163,7 +163,9 @@ func (r *mediaFileRepository) CountBySuffix(options ...model.QueryOptions) (map[ } func (r *mediaFileRepository) Exists(id string) (bool, error) { - return r.exists(Eq{"media_file.id": id}) + // The exists() helper applies no library filter, so it would report rows the caller cannot see. + c, err := r.count(r.applyLibraryFilter(r.newSelect().Where(Eq{"media_file.id": id}))) + return c > 0, err } func (r *mediaFileRepository) Put(m *model.MediaFile) error { @@ -218,7 +220,13 @@ func (r *mediaFileRepository) GetAll(options ...model.QueryOptions) (model.Media if err != nil { return nil, err } - return res.toModels(), nil + mfs := res.toModels() + r.hydrateArtwork(mfs) + return mfs, nil +} + +func (r *mediaFileRepository) hydrateArtwork(mfs model.MediaFiles) { + hydrateMediaFileArtwork(r.ctx, r.db, mfs) } // GetRandom uses two passes so the random sort runs over a narrow rowid index instead of the @@ -252,7 +260,9 @@ func (r *mediaFileRepository) GetRandom(options ...model.QueryOptions) (model.Me if err := r.queryAll(sq, &res); err != nil { return nil, err } - return res.toModels(), nil + mfs := res.toModels() + r.hydrateArtwork(mfs) + return mfs, nil } func (r *mediaFileRepository) GetAllByTags(tag model.TagName, values []string, options ...model.QueryOptions) (model.MediaFiles, error) { @@ -289,6 +299,29 @@ func (r *mediaFileRepository) GetCursor(options ...model.QueryOptions) (model.Me return wrapMediaFileCursor(cursor), nil } +// GetAllIDs returns the IDs of GetAll's row set, skipping its wide column projection. +func (r *mediaFileRepository) GetAllIDs(options ...model.QueryOptions) ([]string, error) { + sq := r.applyLibraryFilter(r.newSelect(options...).Columns("media_file.id")) + if filtersNeedAnnotation(sq) { + sq = r.withAnnotation(sq, "media_file.id") + } + ids := []string{} + err := r.queryAllSlice(sq, &ids) + return ids, err +} + +// GetCursorWithArtwork streams the same rows as GetCursor, hydrated, via an id pre-pass. +func (r *mediaFileRepository) GetCursorWithArtwork(options ...model.QueryOptions) (model.MediaFileCursor, error) { + ids, err := r.GetAllIDs(options...) + if err != nil { + return nil, err + } + opts := chunkOptions(options, "media_file.id") + return model.MediaFileCursor(streamByIDs(ids, func(chunk []string) (model.MediaFiles, error) { + return r.GetAll(opts(chunk)) + })), nil +} + // FindByPaths finds media files by their paths. // The paths can be library-qualified (format: "libraryID:path") or unqualified ("path"). // Library-qualified paths search within the specified library, while unqualified paths @@ -487,7 +520,9 @@ func (r *mediaFileRepository) Search(q string, options ...model.QueryOptions) (m if err != nil { return nil, fmt.Errorf("searching media_file %q: %w", q, err) } - return res.toModels(), nil + mfs := res.toModels() + r.hydrateArtwork(mfs) + return mfs, nil } func (r *mediaFileRepository) Count(options ...rest.QueryOptions) (int64, error) { diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go index f6a744d8d..b9cba03ff 100644 --- a/persistence/mediafile_repository_test.go +++ b/persistence/mediafile_repository_test.go @@ -1093,4 +1093,40 @@ var _ = Describe("MediaRepository", func() { _ = mr.Delete(newID) }) }) + + Describe("AlbumImage hydration", func() { + It("carries the parent album's artwork state onto each track", func() { + mfs := model.MediaFiles{{ID: "mf-1", AlbumID: "al-1"}} + infos := map[string]model.ItemArtworkInfo{ + "al-1": {ItemID: "al-1", Hash: "0123456789abcdef", BlurHash: "LEHV6nWB2yk8"}, + } + applyItemImage(infos, mfs[0].AlbumID, &mfs[0].AlbumImage) + + Expect(mfs[0].AlbumImage.ImageHash).To(Equal("0123456789abcdef")) + Expect(mfs[0].AlbumImage.BlurHash).To(Equal("LEHV6nWB2yk8")) + Expect(mfs[0].AlbumImage.ImageAbsent).To(BeFalse()) + }) + + It("keeps the album state independent of the track's own art", func() { + mf := model.MediaFile{ID: "mf-2", AlbumID: "al-2"} + mf.ImageHash = "aaaaaaaaaaaaaaaa" // the track's own art + applyItemImage( + map[string]model.ItemArtworkInfo{"al-2": {ItemID: "al-2", Hash: "bbbbbbbbbbbbbbbb"}}, + mf.AlbumID, &mf.AlbumImage, + ) + Expect(mf.ImageHash).To(Equal("aaaaaaaaaaaaaaaa")) + Expect(mf.AlbumImage.ImageHash).To(Equal("bbbbbbbbbbbbbbbb")) + }) + }) + + // Exists must apply the same library filter as Get/GetAll/CountAll. + Describe("Exists library visibility", func() { + It("hides a track the user has no library access to", func() { + restricted := model.User{ID: "restricted_mf_user", UserName: "rm", Name: "RM", Email: "rm@t.com"} + rctx := request.WithUser(GinkgoT().Context(), restricted) + + Expect(mr.Exists(songAntenna.ID)).To(BeTrue(), "admin sees it") + Expect(NewMediaFileRepository(rctx, GetDBXBuilder()).Exists(songAntenna.ID)).To(BeFalse()) + }) + }) }) diff --git a/persistence/persistence.go b/persistence/persistence.go index 93f0e3e71..9d3a33cfc 100644 --- a/persistence/persistence.go +++ b/persistence/persistence.go @@ -97,6 +97,14 @@ func (s *SQLStore) Plugin(ctx context.Context) model.PluginRepository { return NewPluginRepository(ctx, s.getDBXBuilder()) } +func (s *SQLStore) Artwork(ctx context.Context) model.ArtworkRepository { + return NewArtworkRepository(ctx, s.getDBXBuilder()) +} + +func (s *SQLStore) ArtworkQueue(ctx context.Context) model.ArtworkQueueRepository { + return NewArtworkQueueRepository(ctx, s.getDBXBuilder()) +} + func (s *SQLStore) Resource(ctx context.Context, m any) model.ResourceRepository { switch m.(type) { case model.User: diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index c78e0df1e..081dab3fa 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -6,7 +6,6 @@ import ( "encoding/json" "errors" "fmt" - "iter" "slices" "time" @@ -112,7 +111,8 @@ func (r *playlistRepository) Put(p *model.Playlist, cols ...string) error { _, err := r.put(pls.ID, pls, cols...) return err } - if pls.ID == "" { + isNew := pls.ID == "" + if isNew { pls.CreatedAt = time.Now() } pls.UpdatedAt = time.Now() @@ -131,6 +131,12 @@ func (r *playlistRepository) Put(p *model.Playlist, cols ...string) error { if len(pls.Tracks) > 0 { return r.updateTracks(id, p.MediaFiles()) } + pls.ID = id // r.put assigns the generated id to p, not to this copy + if isNew { + // Even a trackless new playlist has art to find (an imported m3u can carry an + // ExternalImageURL); an update landing here changed only metadata, so leave its cover be. + r.enqueueCoverRebuild(id) + } return r.refreshCounters(&pls.Playlist) } @@ -172,7 +178,14 @@ func (r *playlistRepository) findBy(sql Sqlizer) (*model.Playlist, error) { return nil, model.ErrNotFound } - return &pls[0].Playlist, nil + list := model.Playlists{pls[0].Playlist} + r.hydrateArtwork(list) + return &list[0], nil +} + +func (r *playlistRepository) hydrateArtwork(playlists model.Playlists) { + hydrateItems(r.ctx, r.db, model.KindPlaylistArtwork, playlists, + func(p *model.Playlist) (string, *model.ItemImage) { return p.ID, &p.ItemImage }) } func (r *playlistRepository) GetAll(options ...model.QueryOptions) (model.Playlists, error) { @@ -186,22 +199,34 @@ func (r *playlistRepository) GetAll(options ...model.QueryOptions) (model.Playli for i, p := range res { playlists[i] = p.Playlist } + r.hydrateArtwork(playlists) return playlists, err } +// GetAllIDs returns the IDs of GetAll's row set, skipping its per-row processing. +func (r *playlistRepository) GetAllIDs(options ...model.QueryOptions) ([]string, error) { + // Joins a projection of user, not the table: its name/created_at columns would make an ORDER BY + // on the playlist's own ambiguous. + sq := r.newSelect(options...).Columns("playlist.id", "user.user_name as owner_name"). + Join("(select id, user_name from user) user on user.id = owner_id").Where(r.userFilter()) + if filtersNeedAnnotation(sq) { + sq = r.withAnnotation(sq, "playlist.id") + } + ids := []string{} + err := r.queryAllSlice(sq, &ids) + return ids, 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) + // Both passes apply userFilter, so a visibility change between them cannot widen the cursor. + ids, err := r.GetAllIDs(options...) 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 })) + opts := chunkOptions(options, "playlist.id") + return model.PlaylistCursor(streamByIDs(ids, func(chunk []string) (model.Playlists, error) { + return r.GetAll(opts(chunk)) + })), nil } func (r *playlistRepository) GetPlaylists(mediaFileId string) (model.Playlists, error) { @@ -220,6 +245,7 @@ func (r *playlistRepository) GetPlaylists(mediaFileId string) (model.Playlists, for i, p := range res { playlists[i] = p.Playlist } + r.hydrateArtwork(playlists) return playlists, nil } @@ -264,6 +290,7 @@ func (r *playlistRepository) addTracks(playlistId string, startingPos int, media } } + r.enqueueCoverRebuild(playlistId) return r.refreshCounters(&model.Playlist{ID: playlistId}) } @@ -300,6 +327,16 @@ func (r *playlistRepository) refreshCounters(pls *model.Playlist) error { return nil } +// enqueueCoverRebuild re-resolves the generated 2x2 grid. Call it only when the track set changes: +// the grid samples albums at random, so rebuilding after a mere rename would change the cover. +func (r *playlistRepository) enqueueCoverRebuild(id string) { + item := model.ArtworkQueueItem{ItemKind: model.KindPlaylistArtwork.Prefix(), ItemID: id, + ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityScan} + if err := NewArtworkQueueRepository(r.ctx, r.db).Enqueue(item); err != nil { + log.Warn(r.ctx, "could not enqueue playlist artwork after content change", "id", id, err) + } +} + // 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") @@ -332,7 +369,9 @@ func (r *playlistRepository) loadTracks(query SelectBuilder, id string) (model.P if err != nil { return nil, err } - return tracks.toModels(), err + res := tracks.toModels() + hydratePlaylistTrackArtwork(r.ctx, r.db, res) + return res, err } func (r *playlistRepository) Count(options ...rest.QueryOptions) (int64, error) { @@ -433,6 +472,7 @@ func (r *playlistRepository) renumber(id string) error { if err != nil { return err } + r.enqueueCoverRebuild(id) return r.refreshCounters(&model.Playlist{ID: id}) } diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go index 00d4ff9f2..fc2d4ae3f 100644 --- a/persistence/playlist_repository_test.go +++ b/persistence/playlist_repository_test.go @@ -8,6 +8,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/utils/slice" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/pocketbase/dbx" @@ -37,6 +38,17 @@ var _ = Describe("PlaylistRepository", func() { }) }) + Describe("GetAllIDs", func() { + It("returns the same id set as GetAll", func() { + want, err := repo.GetAll() + Expect(err).ToNot(HaveOccurred()) + Expect(want).ToNot(BeEmpty()) + ids, err := repo.GetAllIDs() + Expect(err).ToNot(HaveOccurred()) + Expect(ids).To(ConsistOf(slice.Map(want, func(p model.Playlist) string { return p.ID }))) + }) + }) + Describe("Exists", func() { It("returns true for an existing playlist", func() { Expect(repo.Exists(plsCool.ID)).To(BeTrue()) @@ -245,6 +257,59 @@ var _ = Describe("PlaylistRepository", func() { Expect(repo.Exists(newPls.ID)).To(BeFalse()) }) + It("enqueues a new empty playlist's artwork under its generated id, not an empty id", func() { + ctx := request.WithUser(log.NewContext(GinkgoT().Context()), model.User{ID: "userid", UserName: "userid", IsAdmin: true}) + newPls := model.Playlist{Name: "Empty PL", OwnerID: "userid"} // no tracks → refreshCounters path + Expect(repo.Put(&newPls)).To(Succeed()) + Expect(newPls.ID).ToNot(BeEmpty()) + DeferCleanup(func() { _ = repo.Delete(newPls.ID) }) + + queued, err := NewArtworkQueueRepository(ctx, GetDBXBuilder()).DequeueBatch(1000) + Expect(err).ToNot(HaveOccurred()) + Expect(queued).To(ContainElement(SatisfyAll(HaveField("ItemKind", "pl"), HaveField("ItemID", newPls.ID)))) + Expect(queued).ToNot(ContainElement(HaveField("ItemID", "")), "must not enqueue an empty playlist id") + }) + + // The grid samples albums at random, so re-resolving after a rename would change the cover. + It("does not enqueue artwork when only metadata changes", func() { + ctx := request.WithUser(log.NewContext(GinkgoT().Context()), model.User{ID: "userid", UserName: "userid", IsAdmin: true}) + newPls := model.Playlist{Name: "Rename Me", OwnerID: "userid"} + Expect(repo.Put(&newPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(newPls.ID) }) + // Clear the row creation just enqueued, so anything present afterwards came from the update. + queueRepo := NewArtworkQueueRepository(ctx, GetDBXBuilder()) + queued, err := queueRepo.DequeueBatch(1000) + Expect(err).ToNot(HaveOccurred()) + for _, q := range queued { + if q.ItemID == newPls.ID { + Expect(queueRepo.DeleteIfUnchanged(q.ItemKind, q.ItemID, q.ImageType, q.RetryAt)).To(Succeed()) + } + } + + newPls.Name = "Renamed" + newPls.Comment = "edited" + Expect(repo.Put(&newPls)).To(Succeed()) + + queued, err = queueRepo.DequeueBatch(1000) + Expect(err).ToNot(HaveOccurred()) + Expect(queued).ToNot(ContainElement(HaveField("ItemID", newPls.ID))) + }) + + It("enqueues the playlist's artwork when its track set changes", func() { + ctx := request.WithUser(log.NewContext(GinkgoT().Context()), model.User{ID: "userid", UserName: "userid", IsAdmin: true}) + newPls := model.Playlist{Name: "Grid PL", OwnerID: "userid"} + newPls.AddMediaFilesByID([]string{"1001", "1002"}) + Expect(repo.Put(&newPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(newPls.ID) }) + + queued, err := NewArtworkQueueRepository(ctx, GetDBXBuilder()).DequeueBatch(1000) + Expect(err).ToNot(HaveOccurred()) + Expect(queued).To(ContainElement(SatisfyAll( + HaveField("ItemKind", "pl"), + HaveField("ItemID", newPls.ID), + ))) + }) + Describe("GetAll", func() { It("returns all playlists from DB", func() { all, err := repo.GetAll() @@ -341,4 +406,27 @@ var _ = Describe("PlaylistRepository", func() { Expect(mediaFileIDs).To(Equal([]string{"1001", "1002"})) }) }) + + // Exists is ctx-sensitive through userFilter, so callers that only want "does it still exist" + // -- the public image route serving a share -- must elevate, or a private playlist looks gone. + Describe("Exists visibility", func() { + It("hides a private playlist from an unauthenticated context", func() { + // "userid" is the fixture user; playlist.owner_id has a FK to user(id). + owner := model.User{ID: "userid", UserName: "userid"} + octx := request.WithUser(GinkgoT().Context(), owner) + ownerRepo := NewPlaylistRepository(octx, GetDBXBuilder()) + pls := model.Playlist{Name: "Private One", OwnerID: owner.ID, Public: false} + Expect(ownerRepo.Put(&pls)).To(Succeed()) + DeferCleanup(func() { _ = ownerRepo.Delete(pls.ID) }) + + Expect(ownerRepo.Exists(pls.ID)).To(BeTrue(), "the owner sees it") + + anon := NewPlaylistRepository(GinkgoT().Context(), GetDBXBuilder()) + Expect(anon.Exists(pls.ID)).To(BeFalse(), "no user: userFilter hides it") + + admin := request.WithUser(GinkgoT().Context(), model.User{ID: "userid", IsAdmin: true}) + Expect(NewPlaylistRepository(admin, GetDBXBuilder()).Exists(pls.ID)).To(BeTrue(), + "elevating is what the public image route relies on") + }) + }) }) diff --git a/persistence/playlist_track_repository.go b/persistence/playlist_track_repository.go index e51ff8ea6..1a64efa20 100644 --- a/persistence/playlist_track_repository.go +++ b/persistence/playlist_track_repository.go @@ -130,8 +130,11 @@ func (r *playlistTrackRepository) GetCursor(options ...model.QueryOptions) (mode if err != nil { return nil, err } - return model.PlaylistTrackCursor(wrapCursor(cursor, func(t dbPlaylistTrack) *model.PlaylistTrack { + tracks := wrapCursor(cursor, func(t dbPlaylistTrack) *model.PlaylistTrack { return t.PlaylistTrack + }) + return model.PlaylistTrackCursor(hydrateCursor(tracks, func(batch []model.PlaylistTrack) { + hydratePlaylistTrackArtwork(r.ctx, r.db, batch) })), nil } diff --git a/persistence/radio_repository.go b/persistence/radio_repository.go index a073643db..b73487e40 100644 --- a/persistence/radio_repository.go +++ b/persistence/radio_repository.go @@ -7,6 +7,7 @@ import ( . "github.com/Masterminds/squirrel" "github.com/deluan/rest" + "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" "github.com/pocketbase/dbx" @@ -36,6 +37,11 @@ func (r *radioRepository) CountAll(options ...model.QueryOptions) (int64, error) return r.count(sql, options...) } +// Exists needs no library or ownership filter: radios are visible to every user. +func (r *radioRepository) Exists(id string) (bool, error) { + return r.exists(Eq{"id": id}) +} + func (r *radioRepository) Delete(id string) error { if !r.isPermitted() { return rest.ErrPermissionDenied @@ -48,14 +54,37 @@ func (r *radioRepository) Get(id string) (*model.Radio, error) { sel := r.newSelect().Where(Eq{"id": id}).Columns("*") res := model.Radio{} err := r.queryOne(sel, &res) - return &res, err + if err != nil { + return &res, err + } + list := model.Radios{res} + r.hydrateArtwork(list) + return &list[0], nil } func (r *radioRepository) GetAll(options ...model.QueryOptions) (model.Radios, error) { sel := r.newSelect(options...).Columns("*") res := model.Radios{} err := r.queryAll(sel, &res) - return res, err + if err != nil { + return res, err + } + r.hydrateArtwork(res) + return res, nil +} + +// hydrateArtwork fills each radio's ImageHash/ImageAbsent from one batched item_artwork lookup. +func (r *radioRepository) hydrateArtwork(radios model.Radios) { + hydrateItems(r.ctx, r.db, model.KindRadioArtwork, radios, + func(rd *model.Radio) (string, *model.ItemImage) { return rd.ID, &rd.ItemImage }) +} + +// GetAllIDs returns just the radio IDs. Used by bulk enumeration (artwork backfill). +func (r *radioRepository) GetAllIDs(options ...model.QueryOptions) ([]string, error) { + sel := r.newSelect(options...).Columns("id") + ids := []string{} + err := r.queryAllSlice(sel, &ids) + return ids, err } func (r *radioRepository) Put(radio *model.Radio, colsToUpdate ...string) error { @@ -72,7 +101,17 @@ func (r *radioRepository) Put(radio *model.Radio, colsToUpdate ...string) error colsToUpdate = append(colsToUpdate, "UpdatedAt") } _, err := r.put(radio.ID, radio, colsToUpdate...) - return err + if err != nil { + return err + } + // Enqueue artwork resolution for the created/updated radio at Bump priority so a new + // radio's cover resolves proactively. Never fails the save. + item := model.ArtworkQueueItem{ItemKind: model.KindRadioArtwork.Prefix(), ItemID: radio.ID, ImageType: model.ImageTypePrimary, + Priority: model.ArtworkPriorityBump} + if err := NewArtworkQueueRepository(r.ctx, r.db).Enqueue(item); err != nil { + log.Warn(r.ctx, "could not enqueue radio artwork", "id", radio.ID, err) + } + return nil } func (r *radioRepository) Count(options ...rest.QueryOptions) (int64, error) { diff --git a/persistence/radio_repository_test.go b/persistence/radio_repository_test.go index 05628ca41..e2564455d 100644 --- a/persistence/radio_repository_test.go +++ b/persistence/radio_repository_test.go @@ -7,6 +7,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/utils/slice" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -78,6 +79,17 @@ var _ = Describe("RadioRepository", func() { }) }) + Describe("GetAllIDs", func() { + It("returns the same id set as GetAll", func() { + want, err := repo.GetAll() + Expect(err).To(BeNil()) + Expect(want).ToNot(BeEmpty()) + ids, err := repo.GetAllIDs() + Expect(err).To(BeNil()) + Expect(ids).To(ConsistOf(slice.Map(want, func(r model.Radio) string { return r.ID }))) + }) + }) + Describe("Put", func() { It("successfully updates item", func() { err := repo.Put(&model.Radio{ @@ -107,6 +119,27 @@ var _ = Describe("RadioRepository", func() { Expect(err).To(BeNil()) Expect(all[2].StreamUrl).To(Equal("https://example.com:4533/app")) }) + + It("enqueues artwork resolution for the saved radio", func() { + err := repo.Put(&model.Radio{ + Name: "Artwork radio", + StreamUrl: "https://example.com:4533/artwork", + }) + Expect(err).To(BeNil()) + + all, err := repo.GetAll() + Expect(err).To(BeNil()) + created := all[len(all)-1] + + queueRepo := NewArtworkQueueRepository(context.Background(), GetDBXBuilder()) + queued, err := queueRepo.DequeueBatch(1000) + Expect(err).To(BeNil()) + Expect(queued).To(ContainElement(SatisfyAll( + HaveField("ItemKind", "ra"), + HaveField("ItemID", created.ID), + HaveField("Priority", model.ArtworkPriorityBump), + ))) + }) }) }) diff --git a/persistence/sql_base_repository.go b/persistence/sql_base_repository.go index d0cbb2946..33450fe9f 100644 --- a/persistence/sql_base_repository.go +++ b/persistence/sql_base_repository.go @@ -15,6 +15,7 @@ import ( . "github.com/Masterminds/squirrel" "github.com/deluan/rest" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" id2 "github.com/navidrome/navidrome/model/id" @@ -597,9 +598,15 @@ func (r sqlRepository) delete(cond Sqlizer) error { func (r sqlRepository) logSQL(sql string, args dbx.Params, err error, rowsAffected int64, start time.Time) { elapsed := time.Since(start) + fields := []any{r.ctx, "SQL: `" + sql + "`", "args", args, "rowsAffected", rowsAffected, "elapsedTime", elapsed} if err == nil || errors.Is(err, context.Canceled) { - log.Trace(r.ctx, "SQL: `"+sql+"`", "args", args, "rowsAffected", rowsAffected, "elapsedTime", elapsed, err) - } else { - log.Error(r.ctx, "SQL: `"+sql+"`", "args", args, "rowsAffected", rowsAffected, "elapsedTime", elapsed, err) + log.Trace(append(fields, err)...) + return } + // The result codes separate errors that share a message, notably SQLITE_BUSY from + // SQLITE_BUSY_SNAPSHOT, which no busy_timeout can retry. + if code, extended, ok := db.ErrorCodes(err); ok { + fields = append(fields, "sqliteCode", code, "sqliteExtended", extended) + } + log.Error(append(fields, err)...) } diff --git a/scanner/controller.go b/scanner/controller.go index 463718ba3..df5aeb6f9 100644 --- a/scanner/controller.go +++ b/scanner/controller.go @@ -11,7 +11,6 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playlists" @@ -28,12 +27,11 @@ var ( ErrAlreadyScanning = errors.New("already scanning") ) -func New(rootCtx context.Context, ds model.DataStore, cw artwork.CacheWarmer, broker events.Broker, +func New(rootCtx context.Context, ds model.DataStore, broker events.Broker, pls playlists.Playlists, m metrics.Metrics) model.Scanner { c := &controller{ rootCtx: rootCtx, ds: ds, - cw: cw, broker: broker, pls: pls, metrics: m, @@ -49,7 +47,7 @@ func (s *controller) getScanner() scanner { if s.devExternalScanner { return &scannerExternal{} } - return &scannerImpl{ds: s.ds, cw: s.cw, pls: s.pls} + return &scannerImpl{ds: s.ds, pls: s.pls} } // CallScan starts an in-process scan of specific library/folder pairs. @@ -66,7 +64,7 @@ func CallScan(ctx context.Context, ds model.DataStore, pls playlists.Playlists, progress := make(chan *ProgressInfo, 100) go func() { defer close(progress) - scanner := &scannerImpl{ds: ds, cw: artwork.NoopCacheWarmer(), pls: pls} + scanner := &scannerImpl{ds: ds, pls: pls} scanner.scanFolders(ctx, fullScan, targets, progress) }() return progress, nil @@ -97,7 +95,6 @@ type scanner interface { type controller struct { rootCtx context.Context ds model.DataStore - cw artwork.CacheWarmer broker events.Broker metrics metrics.Metrics pls playlists.Playlists diff --git a/scanner/controller_test.go b/scanner/controller_test.go index e4814da64..7ffdd69d4 100644 --- a/scanner/controller_test.go +++ b/scanner/controller_test.go @@ -5,7 +5,6 @@ import ( "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" "github.com/navidrome/navidrome/core/playlists" @@ -32,7 +31,7 @@ var _ = Describe("Controller", func() { DeferCleanup(configtest.SetupConfig()) ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} ds.MockedProperty = &tests.MockedPropertyRepo{} - ctrl = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) + ctrl = scanner.New(ctx, ds, events.NoopBroker(), playlists.NewPlaylists(ds, artwork.NewUploader(ds)), metrics.NewNoopInstance()) }) It("includes last scan error", func() { diff --git a/scanner/phase_1_folders.go b/scanner/phase_1_folders.go index 5e898590b..e853948e6 100644 --- a/scanner/phase_1_folders.go +++ b/scanner/phase_1_folders.go @@ -16,7 +16,6 @@ import ( ppl "github.com/google/go-pipeline/pkg/pipeline" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/storage" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -26,7 +25,7 @@ import ( "github.com/navidrome/navidrome/utils/slice" ) -func createPhaseFolders(ctx context.Context, state *scanState, ds model.DataStore, cw artwork.CacheWarmer) *phaseFolders { +func createPhaseFolders(ctx context.Context, state *scanState, ds model.DataStore) *phaseFolders { var jobs []*scanJob // Create scan jobs for all libraries @@ -37,7 +36,7 @@ func createPhaseFolders(ctx context.Context, state *scanState, ds model.DataStor targetFolders = state.targets[lib.ID] } - job, err := newScanJob(ctx, ds, cw, lib, state.fullScan, targetFolders) + job, err := newScanJob(ctx, ds, lib, state.fullScan, targetFolders) if err != nil { log.Error(ctx, "Scanner: Error creating scan context", "lib", lib.Name, err) state.sendError(err) @@ -52,14 +51,13 @@ func createPhaseFolders(ctx context.Context, state *scanState, ds model.DataStor type scanJob struct { lib model.Library fs storage.MusicFS - cw artwork.CacheWarmer lastUpdates map[string]model.FolderUpdateInfo // Holds last update info for all (DB) folders in this library targetFolders []string // Specific folders to scan (including all descendants) lock sync.Mutex numFolders atomic.Int64 } -func newScanJob(ctx context.Context, ds model.DataStore, cw artwork.CacheWarmer, lib model.Library, fullScan bool, targetFolders []string) (*scanJob, error) { +func newScanJob(ctx context.Context, ds model.DataStore, lib model.Library, fullScan bool, targetFolders []string) (*scanJob, error) { // Get folder updates, optionally filtered to specific target folders lastUpdates, err := ds.Folder(ctx).GetFolderUpdateInfo(lib, targetFolders...) if err != nil { @@ -85,7 +83,6 @@ func newScanJob(ctx context.Context, ds model.DataStore, cw artwork.CacheWarmer, return &scanJob{ lib: lib, fs: fsys, - cw: cw, lastUpdates: lastUpdates, targetFolders: targetFolders, }, nil @@ -330,8 +327,8 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error) defer p.measure(entry)() p.state.changesDetected.Store(true) - // Collect artwork IDs to pre-cache after the transaction commits - var artworkIDs []model.ArtworkID + // Collect artwork queue items for changed albums/artists, enqueued in the same transaction + var queueItems []model.ArtworkQueueItem err := p.ds.WithTx(func(tx model.DataStore) error { // Instantiate all repositories just once per folder @@ -371,7 +368,10 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error) return err } if entry.artists[i].Name != consts.UnknownArtist && entry.artists[i].Name != consts.VariousArtists { - artworkIDs = append(artworkIDs, entry.artists[i].CoverArtID()) + queueItems = append(queueItems, model.ArtworkQueueItem{ + ItemKind: model.KindArtistArtwork.Prefix(), ItemID: entry.artists[i].ID, ImageType: model.ImageTypePrimary, + Priority: model.ArtworkPriorityScan, + }) } } @@ -383,7 +383,10 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error) return err } if entry.albums[i].Name != consts.UnknownAlbum { - artworkIDs = append(artworkIDs, entry.albums[i].CoverArtID()) + queueItems = append(queueItems, model.ArtworkQueueItem{ + ItemKind: model.KindAlbumArtwork.Prefix(), ItemID: entry.albums[i].ID, ImageType: model.ImageTypePrimary, + Priority: model.ArtworkPriorityScan, + }) } } @@ -396,6 +399,14 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error) } } + // A re-imported track returns to unresolved so new embedded art is picked up lazily. + if len(entry.tracks) > 0 { + trackIDs := slice.Map(entry.tracks, func(t model.MediaFile) string { return t.ID }) + if err := tx.Artwork(p.ctx).DeleteForItems(model.KindMediaFileArtwork, trackIDs); err != nil { + log.Warn(p.ctx, "Scanner: could not invalidate media_file artwork", "folder", entry.path, err) + } + } + // Mark all missing tracks as not available if len(entry.missingTracks) > 0 { err = mfRepo.MarkMissing(true, entry.missingTracks...) @@ -415,19 +426,25 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error) return err } } + + // Enqueue artwork resolution for changed albums/artists. Never fails the scan. + // A full scan re-imports every track, so a re-import is no evidence the art changed. + if len(queueItems) > 0 { + queue := tx.ArtworkQueue(p.ctx) + enqueue := queue.Enqueue + if p.state.fullScan { + enqueue = queue.EnqueueIfMissing + } + if err := enqueue(queueItems...); err != nil { + log.Warn(p.ctx, "Scanner: could not enqueue artwork resolution", "folder", entry.path, err) + } + } return nil }, "scanner: persist changes") if err != nil { log.Error(p.ctx, "Scanner: Error persisting changes to DB", "folder", entry.path, err) } - // Pre-cache artwork after the transaction commits successfully - if err == nil { - for _, artID := range artworkIDs { - entry.job.cw.PreCache(artID) - } - } - return entry, err } diff --git a/scanner/phase_4_playlists.go b/scanner/phase_4_playlists.go index 8ba014235..baa8b749a 100644 --- a/scanner/phase_4_playlists.go +++ b/scanner/phase_4_playlists.go @@ -12,7 +12,6 @@ import ( ppl "github.com/google/go-pipeline/pkg/pipeline" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -24,18 +23,16 @@ type phasePlaylists struct { scanState *scanState ds model.DataStore pls playlists.Playlists - cw artwork.CacheWarmer refreshed atomic.Uint32 pendingImport bool } -func createPhasePlaylists(ctx context.Context, scanState *scanState, ds model.DataStore, pls playlists.Playlists, cw artwork.CacheWarmer) *phasePlaylists { +func createPhasePlaylists(ctx context.Context, scanState *scanState, ds model.DataStore, pls playlists.Playlists) *phasePlaylists { return &phasePlaylists{ ctx: ctx, scanState: scanState, ds: ds, pls: pls, - cw: cw, } } @@ -148,7 +145,11 @@ func (p *phasePlaylists) processPlaylistsInFolder(folder *model.Folder) (*model. } else { log.Debug("Scanner: Imported playlist", "name", pls.Name, "lastUpdated", pls.UpdatedAt, "path", pls.Path, "numTracks", len(pls.Tracks), "elapsed", time.Since(started)) } - p.cw.PreCache(pls.CoverArtID()) + item := model.ArtworkQueueItem{ItemKind: model.KindPlaylistArtwork.Prefix(), ItemID: pls.ID, ImageType: model.ImageTypePrimary, + Priority: model.ArtworkPriorityScan} + if err := p.ds.ArtworkQueue(p.ctx).Enqueue(item); err != nil { + log.Warn(p.ctx, "Scanner: could not enqueue playlist artwork", "id", pls.ID, err) + } p.refreshed.Add(1) } return folder, nil diff --git a/scanner/phase_4_playlists_test.go b/scanner/phase_4_playlists_test.go index 49ffc7fb7..93ec1a36d 100644 --- a/scanner/phase_4_playlists_test.go +++ b/scanner/phase_4_playlists_test.go @@ -10,7 +10,6 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" @@ -27,7 +26,6 @@ var _ = Describe("phasePlaylists", func() { folderRepo *mockFolderRepository ds *tests.MockDataStore pls *mockPlaylists - cw artwork.CacheWarmer ) var userRepo *tests.MockedUserRepo @@ -48,9 +46,8 @@ var _ = Describe("phasePlaylists", func() { MockedProperty: propRepo, } pls = &mockPlaylists{} - cw = artwork.NoopCacheWarmer() state = &scanState{} - phase = createPhasePlaylists(ctx, state, ds, pls, cw) + phase = createPhasePlaylists(ctx, state, ds, pls) }) Describe("description", func() { @@ -193,6 +190,29 @@ var _ = Describe("phasePlaylists", func() { Expect(phase.refreshed.Load()).To(Equal(uint32(2))) }) + It("enqueues artwork resolution for the imported playlist", func() { + libPath := GinkgoT().TempDir() + folder := &model.Folder{LibraryPath: libPath, Path: "path/to", Name: "folder"} + _ = os.MkdirAll(folder.AbsolutePath(), 0755) + + file1 := filepath.Join(folder.AbsolutePath(), "playlist1.m3u") + _ = os.WriteFile(file1, []byte{}, 0600) + + pls.On("ImportFromFolder", mock.Anything, folder, "playlist1.m3u"). + Return(&model.Playlist{ID: "pl1"}, nil) + + _, err := phase.processPlaylistsInFolder(folder) + Expect(err).ToNot(HaveOccurred()) + + queued, err := ds.ArtworkQueue(ctx).DequeueBatch(10) + Expect(err).ToNot(HaveOccurred()) + Expect(queued).To(ContainElement(SatisfyAll( + HaveField("ItemKind", "pl"), + HaveField("ItemID", "pl1"), + HaveField("Priority", model.ArtworkPriorityScan), + ))) + }) + It("reports an error if there is an error reading files", func() { tests.SkipOnWindows("relies on Unix /etc filesystem") progress := make(chan *ProgressInfo) diff --git a/scanner/scanner.go b/scanner/scanner.go index 27e2b19d2..29a1f9b4e 100644 --- a/scanner/scanner.go +++ b/scanner/scanner.go @@ -11,7 +11,6 @@ import ( ppl "github.com/google/go-pipeline/pkg/pipeline" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -21,7 +20,6 @@ import ( type scannerImpl struct { ds model.DataStore - cw artwork.CacheWarmer pls playlists.Playlists } @@ -136,7 +134,7 @@ func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets [] err = run.Sequentially( // Phase 1: Scan all libraries and import new/updated files - runPhase[*folderEntry](ctx, 1, createPhaseFolders(ctx, &state, s.ds, s.cw)), + runPhase[*folderEntry](ctx, 1, createPhaseFolders(ctx, &state, s.ds)), // Phase 2: Process missing files, checking for moves runPhase[*missingTracks](ctx, 2, createPhaseMissingTracks(ctx, &state, s.ds)), @@ -147,7 +145,7 @@ func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets [] runPhase[*model.Album](ctx, 3, createPhaseRefreshAlbums(ctx, &state, s.ds)), // Phase 4: Import/update playlists - runPhase[*model.Folder](ctx, 4, createPhasePlaylists(ctx, &state, s.ds, s.pls, s.cw)), + runPhase[*model.Folder](ctx, 4, createPhasePlaylists(ctx, &state, s.ds, s.pls)), ), // Final Steps (cannot be parallelized): @@ -155,6 +153,9 @@ func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets [] // Run GC if there were any changes (Remove dangling tracks, empty albums and artists, and orphan annotations) s.runGC(ctx, &state), + // Queue artwork for entities that never resolved (after GC, so nothing dangling is queued) + s.runEnqueueMissingArtwork(ctx, &state), + // Refresh artist and tags stats s.runRefreshStats(ctx, &state), @@ -251,6 +252,29 @@ func (s *scannerImpl) runGC(ctx context.Context, state *scanState) func() error } } +// runEnqueueMissingArtwork is the safety net for entities phase 1 never enqueued. +func (s *scannerImpl) runEnqueueMissingArtwork(ctx context.Context, state *scanState) func() error { + return func() error { + if !state.changesDetected.Load() { + log.Debug(ctx, "Scanner: No changes detected, skipping artwork enqueue") + return nil + } + start := time.Now() + queue := s.ds.ArtworkQueue(ctx) + var total int64 + for _, kind := range []model.Kind{model.KindAlbumArtwork, model.KindArtistArtwork} { + n, err := queue.EnqueueAllMissing(kind, model.ArtworkPriorityScan) + if err != nil { + log.Error(ctx, "Scanner: Error enqueueing missing artwork", "kind", kind, err) + return fmt.Errorf("enqueueing missing artwork: %w", err) + } + total += n + } + log.Debug(ctx, "Scanner: Enqueued missing artwork", "items", total, "elapsed", time.Since(start)) + return nil + } +} + func (s *scannerImpl) runRefreshStats(ctx context.Context, state *scanState) func() error { return func() error { if !state.changesDetected.Load() { diff --git a/scanner/scanner_benchmark_test.go b/scanner/scanner_benchmark_test.go index 8f0dcd340..ca1064631 100644 --- a/scanner/scanner_benchmark_test.go +++ b/scanner/scanner_benchmark_test.go @@ -12,7 +12,6 @@ import ( "github.com/dustin/go-humanize" "github.com/google/uuid" "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playlists" @@ -40,8 +39,8 @@ func BenchmarkScan(b *testing.B) { ds := persistence.New(db.Db()) conf.Server.DevExternalScanner = false - s := scanner.New(context.Background(), ds, artwork.NoopCacheWarmer(), events.NoopBroker(), - playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) + s := scanner.New(context.Background(), ds, events.NoopBroker(), + playlists.NewPlaylists(ds, artwork.NewUploader(ds)), metrics.NewNoopInstance()) fs := storagetest.FakeFS{} storagetest.Register("fake", &fs) diff --git a/scanner/scanner_multilibrary_test.go b/scanner/scanner_multilibrary_test.go index 3ae50933c..c0d5d4ece 100644 --- a/scanner/scanner_multilibrary_test.go +++ b/scanner/scanner_multilibrary_test.go @@ -11,7 +11,6 @@ import ( "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" "github.com/navidrome/navidrome/core/playlists" @@ -78,8 +77,8 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() { } Expect(ds.User(ctx).Put(&adminUser)).To(Succeed()) - s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), - playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) + s = scanner.New(ctx, ds, events.NoopBroker(), + playlists.NewPlaylists(ds, artwork.NewUploader(ds)), metrics.NewNoopInstance()) // Create two test libraries (let DB auto-assign IDs) lib1 = model.Library{Name: "Rock Collection", Path: "rock:///music"} diff --git a/scanner/scanner_selective_test.go b/scanner/scanner_selective_test.go index 17772bf9d..acaa8f850 100644 --- a/scanner/scanner_selective_test.go +++ b/scanner/scanner_selective_test.go @@ -10,7 +10,6 @@ import ( "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" "github.com/navidrome/navidrome/core/playlists" @@ -66,8 +65,8 @@ var _ = Describe("ScanFolders", Ordered, func() { } Expect(ds.User(ctx).Put(&adminUser)).To(Succeed()) - s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), - playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) + s = scanner.New(ctx, ds, events.NoopBroker(), + playlists.NewPlaylists(ds, artwork.NewUploader(ds)), metrics.NewNoopInstance()) lib = model.Library{ID: 1, Name: "Fake Library", Path: "fake:///music"} Expect(ds.Library(ctx).Put(&lib)).To(Succeed()) diff --git a/scanner/scanner_test.go b/scanner/scanner_test.go index cc3720717..6098cbd66 100644 --- a/scanner/scanner_test.go +++ b/scanner/scanner_test.go @@ -13,7 +13,6 @@ import ( "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" "github.com/navidrome/navidrome/core/playlists" @@ -86,8 +85,8 @@ var _ = Describe("Scanner", Ordered, func() { } Expect(ds.User(ctx).Put(&adminUser)).To(Succeed()) - s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), - playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) + s = scanner.New(ctx, ds, events.NoopBroker(), + playlists.NewPlaylists(ds, artwork.NewUploader(ds)), metrics.NewNoopInstance()) lib = model.Library{ID: 1, Name: "Fake Library", Path: "fake:///music"} Expect(ds.Library(ctx).Put(&lib)).To(Succeed()) @@ -98,6 +97,22 @@ var _ = Describe("Scanner", Ordered, func() { return err } + // Stands in for the artwork worker: drains the queue and records every item as resolved, + // so a later scan can only queue genuine reprocessing. + resolveQueuedArtwork := func() []model.ArtworkQueueItem { + GinkgoHelper() + queued, err := ds.ArtworkQueue(ctx).DequeueBatch(1000) + Expect(err).ToNot(HaveOccurred()) + for _, it := range queued { + Expect(ds.Artwork(ctx).PutItemArtwork(&model.ItemArtwork{ + ItemKind: it.ItemKind, ItemID: it.ItemID, ImageType: it.ImageType, + Hash: "resolved", Source: "embedded", UpdatedAt: time.Now(), + })).To(Succeed()) + Expect(ds.ArtworkQueue(ctx).DeleteIfUnchanged(it.ItemKind, it.ItemID, it.ImageType, it.RetryAt)).To(Succeed()) + } + return queued + } + Context("Simple library, 'artis/album/track - title.mp3'", func() { var help, revolver func(...map[string]any) *fstest.MapFile var fsys storagetest.FakeFS @@ -152,6 +167,40 @@ var _ = Describe("Scanner", Ordered, func() { HaveField("SongCount", Equal(4)), )) }) + It("should enqueue artwork resolution for the scanned albums and artists", func() { + Expect(runScanner(ctx, true)).To(Succeed()) + + albums, _ := ds.Album(ctx).GetAll() + artists, _ := ds.Artist(ctx).GetAll(model.QueryOptions{Filters: squirrel.NotEq{"name": consts.UnknownArtist}}) + queued, err := ds.ArtworkQueue(ctx).DequeueBatch(1000) + Expect(err).ToNot(HaveOccurred()) + + for _, al := range albums { + Expect(queued).To(ContainElement(SatisfyAll( + HaveField("ItemKind", "al"), + HaveField("ItemID", al.ID), + HaveField("Priority", model.ArtworkPriorityScan), + ))) + } + for _, ar := range artists { + Expect(queued).To(ContainElement(SatisfyAll( + HaveField("ItemKind", "ar"), + HaveField("ItemID", ar.ID), + HaveField("Priority", model.ArtworkPriorityScan), + ))) + } + }) + It("should not re-enqueue already resolved artwork on a repeat full scan", func() { + Expect(runScanner(ctx, true)).To(Succeed()) + + Expect(resolveQueuedArtwork()).ToNot(BeEmpty()) + + Expect(runScanner(ctx, true)).To(Succeed()) + + requeued, err := ds.ArtworkQueue(ctx).DequeueBatch(1000) + Expect(err).ToNot(HaveOccurred()) + Expect(requeued).To(BeEmpty()) + }) }) When("a file was changed", func() { It("should update the media_file", func() { @@ -169,6 +218,25 @@ var _ = Describe("Scanner", Ordered, func() { Expect(mf[0].Tags).To(HaveKeyWithValue(model.TagName("barcode"), []string{"123"})) }) + It("should re-enqueue the album artwork even though it already resolved", func() { + tests.SkipOnWindows("path separator bug (#TBD-path-sep-scanner)") + Expect(runScanner(ctx, true)).To(Succeed()) + + resolveQueuedArtwork() + + fsys.UpdateTags("The Beatles/Help!/01 - Help!.mp3", _t{"producer": "George Martin"}) + Expect(runScanner(ctx, false)).To(Succeed()) + + albums, err := ds.Album(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"album.name": "Help!"}}) + Expect(err).ToNot(HaveOccurred()) + requeued, err := ds.ArtworkQueue(ctx).DequeueBatch(1000) + Expect(err).ToNot(HaveOccurred()) + Expect(requeued).To(ContainElement(SatisfyAll( + HaveField("ItemKind", "al"), + HaveField("ItemID", albums[0].ID), + ))) + }) + It("should update the album", func() { tests.SkipOnWindows("path separator bug (#TBD-path-sep-scanner)") Expect(runScanner(ctx, true)).To(Succeed()) @@ -187,6 +255,26 @@ var _ = Describe("Scanner", Ordered, func() { Expect(albums[0].Participants.First(model.RoleProducer).Name).To(Equal("George Martin")) Expect(albums[0].SongCount).To(Equal(3)) }) + + It("invalidates the media_file artwork state so new embedded art is picked up lazily", func() { + Expect(runScanner(ctx, true)).To(Succeed()) + + mf, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"title": "Help!"}}) + Expect(err).ToNot(HaveOccurred()) + Expect(mf).ToNot(BeEmpty()) + trackID := mf[0].ID + + Expect(ds.Artwork(ctx).PutItemArtwork(&model.ItemArtwork{ + ItemKind: "mf", ItemID: trackID, ImageType: model.ImageTypePrimary, + Source: "embedded", Hash: "stalehash", + })).To(Succeed()) + + fsys.UpdateTags("The Beatles/Help!/01 - Help!.mp3", _t{"comment": "reimport"}) + Expect(runScanner(ctx, true)).To(Succeed()) + + _, err = ds.Artwork(ctx).GetItemArtwork(model.KindMediaFileArtwork, trackID, model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + }) }) }) diff --git a/server/imghttp/headers.go b/server/imghttp/headers.go new file mode 100644 index 000000000..9308bcdab --- /dev/null +++ b/server/imghttp/headers.go @@ -0,0 +1,67 @@ +// Package imghttp holds the HTTP caching contract shared by the subsonic, public, and jellyfin +// image handlers, so they apply identical headers without importing each other. +package imghttp + +import ( + "net/http" + "strings" + + "github.com/navidrome/navidrome/core/artwork" +) + +// WriteImageHeaders applies the artwork caching contract and reports whether a 304 was written +// (the caller must then not write a body). requestedHash is the hash the client asserted, or "". +func WriteImageHeaders(w http.ResponseWriter, r *http.Request, img *artwork.Image, requestedHash string) (wrote304 bool) { + h := w.Header() + // Placeholders are transient stand-ins for unresolved art: never cached, no validators. + if img.Placeholder { + h.Set("Cache-Control", "no-store") + return false + } + + // The ETag versions the served representation, so a re-encoding config change invalidates a + // revalidating client's cache; full-size originals are their own pixel hash. + etag := img.ETag + if etag == "" { + etag = img.Hash + } + // An empty validator would be shared by every such response, 304ing clients that echo it back. + if etag != "" { + h.Set("ETag", `"`+etag+`"`) + } + if !img.LastUpdated.IsZero() { + h.Set("Last-Modified", img.LastUpdated.UTC().Format(http.TimeFormat)) + } + // Immutable only when the client asked for the current pixel hash; anything else revalidates, + // so art that gets re-resolved is never pinned. + if requestedHash != "" && requestedHash == img.Hash { + h.Set("Cache-Control", "public, max-age=31536000, immutable") + } else { + h.Set("Cache-Control", "public, no-cache") + } + + if etag != "" && ifNoneMatch(r.Header.Get("If-None-Match"), etag) { + w.WriteHeader(http.StatusNotModified) + return true + } + return false +} + +// ifNoneMatch reports whether If-None-Match asserts hash, using RFC 9110 weak comparison. +func ifNoneMatch(header, hash string) bool { + header = strings.TrimSpace(header) + if header == "" { + return false + } + if header == "*" { + return true + } + for tag := range strings.SplitSeq(header, ",") { + tag = strings.TrimSpace(tag) + tag = strings.TrimPrefix(tag, "W/") + if strings.Trim(tag, `"`) == hash { + return true + } + } + return false +} diff --git a/server/imghttp/headers_test.go b/server/imghttp/headers_test.go new file mode 100644 index 000000000..2e8313faf --- /dev/null +++ b/server/imghttp/headers_test.go @@ -0,0 +1,117 @@ +package imghttp_test + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "time" + + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/server/imghttp" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +const testHash = "0123456789abcdef" +const testRepTag = testHash + ".300.false.q75" + +var lastMod = time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC) + +func found() *artwork.Image { + return &artwork.Image{ + ReadCloser: io.NopCloser(strings.NewReader("IMG")), + Hash: testHash, + LastUpdated: lastMod, + } +} + +func placeholder() *artwork.Image { + return &artwork.Image{ReadCloser: io.NopCloser(strings.NewReader("PH")), Placeholder: true} +} + +// A re-encoded response carries a representation ETag distinct from the pixel hash, so the +// validator versions with the encode settings. +func resized() *artwork.Image { + return &artwork.Image{ + ReadCloser: io.NopCloser(strings.NewReader("IMG")), + Hash: testHash, + ETag: testRepTag, + LastUpdated: lastMod, + } +} + +func unvalidated() *artwork.Image { + return &artwork.Image{ReadCloser: io.NopCloser(strings.NewReader("IMG")), LastUpdated: lastMod} +} + +var _ = Describe("WriteImageHeaders", func() { + type testCase struct { + img *artwork.Image + requestedHash string + ifNoneMatch string + want304 bool + wantCache string + wantETag string + wantLastMod bool + } + + DescribeTable("applies the artwork caching contract", + func(c testCase) { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/img", nil) + if c.ifNoneMatch != "" { + r.Header.Set("If-None-Match", c.ifNoneMatch) + } + + Expect(imghttp.WriteImageHeaders(w, r, c.img, c.requestedHash)).To(Equal(c.want304)) + + h := w.Header() + Expect(h.Get("Cache-Control")).To(Equal(c.wantCache)) + Expect(h.Get("ETag")).To(Equal(c.wantETag)) + if c.img.Placeholder { + Expect(h.Get("ETag")).To(BeEmpty(), "placeholder must not set an ETag") + Expect(h.Get("Last-Modified")).To(BeEmpty(), "placeholder must not set Last-Modified") + } + Expect(h.Get("Last-Modified") != "").To(Equal(c.wantLastMod)) + if c.want304 { + Expect(w.Code).To(Equal(http.StatusNotModified)) + Expect(w.Body.Len()).To(BeZero(), "a 304 must have an empty body") + } + }, + Entry("placeholder is never cached and carries no validators", + testCase{img: placeholder(), wantCache: "no-store"}), + Entry("found with matching requested hash is immutable", + testCase{img: found(), requestedHash: testHash, wantCache: "public, max-age=31536000, immutable", wantETag: `"` + testHash + `"`, wantLastMod: true}), + Entry("found with bare id revalidates via no-cache", + testCase{img: found(), wantCache: "public, no-cache", wantETag: `"` + testHash + `"`, wantLastMod: true}), + Entry("found with mismatched requested hash revalidates", + testCase{img: found(), requestedHash: "ffffffffffffffff", wantCache: "public, no-cache", wantETag: `"` + testHash + `"`, wantLastMod: true}), + Entry("resized keeps pixel-hash immutable but serves the representation ETag", + testCase{img: resized(), requestedHash: testHash, wantCache: "public, max-age=31536000, immutable", wantETag: `"` + testRepTag + `"`, wantLastMod: true}), + Entry("resized 304s on the representation ETag, not the pixel hash", + testCase{img: resized(), ifNoneMatch: `"` + testRepTag + `"`, want304: true, wantCache: "public, no-cache", wantETag: `"` + testRepTag + `"`, wantLastMod: true}), + Entry("resized does not 304 on a stale pixel-hash validator (config changed)", + testCase{img: resized(), ifNoneMatch: `"` + testHash + `"`, want304: false, wantCache: "public, no-cache", wantETag: `"` + testRepTag + `"`, wantLastMod: true}), + Entry("If-None-Match matching the hash yields 304", + testCase{img: found(), requestedHash: testHash, ifNoneMatch: `"` + testHash + `"`, want304: true, wantCache: "public, max-age=31536000, immutable", wantETag: `"` + testHash + `"`, wantLastMod: true}), + Entry("weak If-None-Match matches (weak comparison)", + testCase{img: found(), ifNoneMatch: `W/"` + testHash + `"`, want304: true, wantCache: "public, no-cache", wantETag: `"` + testHash + `"`, wantLastMod: true}), + Entry("If-None-Match with multiple values matches one", + testCase{img: found(), ifNoneMatch: `"deadbeefdeadbeef", W/"` + testHash + `", "cafecafecafecafe"`, want304: true, wantCache: "public, no-cache", wantETag: `"` + testHash + `"`, wantLastMod: true}), + Entry("If-None-Match star matches any current representation", + testCase{img: found(), ifNoneMatch: "*", want304: true, wantCache: "public, no-cache", wantETag: `"` + testHash + `"`, wantLastMod: true}), + Entry("non-matching If-None-Match serves body", + testCase{img: found(), ifNoneMatch: `"deadbeefdeadbeef"`, want304: false, wantCache: "public, no-cache", wantETag: `"` + testHash + `"`, wantLastMod: true}), + Entry("placeholder ignores If-None-Match and never 304s", + testCase{img: placeholder(), ifNoneMatch: "*", want304: false, wantCache: "no-store"}), + + // With no validator, an emitted ETag would be the same empty tag on every such response, + // and matching it would 304 changed bytes. + Entry("omits the ETag entirely when there is no validator", + testCase{img: unvalidated(), wantCache: "public, no-cache", wantLastMod: true}), + Entry("never 304s an empty validator echoed back by the client", + testCase{img: unvalidated(), ifNoneMatch: `""`, want304: false, + wantCache: "public, no-cache", wantLastMod: true}), + ) +}) diff --git a/server/imghttp/imghttp_suite_test.go b/server/imghttp/imghttp_suite_test.go new file mode 100644 index 000000000..7a747db5b --- /dev/null +++ b/server/imghttp/imghttp_suite_test.go @@ -0,0 +1,17 @@ +package imghttp_test + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestImgHTTP(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "ImgHTTP Suite") +} diff --git a/server/jellyfin/dto/blurhash.go b/server/jellyfin/dto/blurhash.go deleted file mode 100644 index aaf6ff2af..000000000 --- a/server/jellyfin/dto/blurhash.go +++ /dev/null @@ -1,36 +0,0 @@ -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 deleted file mode 100644 index a6e36131d..000000000 --- a/server/jellyfin/dto/blurhash_test.go +++ /dev/null @@ -1,27 +0,0 @@ -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 index 9b2c35c69..5a2a4c2b1 100644 --- a/server/jellyfin/dto/dto.go +++ b/server/jellyfin/dto/dto.go @@ -81,12 +81,15 @@ type BaseItemDto struct { // 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"` + ImageBlurHashes map[string]map[string]string `json:"ImageBlurHashes,omitempty"` + // PrimaryImageAspectRatio is width/height of the Primary image, attached only when the request's + // Fields asks for it; omitted rather than guessed, since a wrong ratio mis-shapes a placeholder. + PrimaryImageAspectRatio *float64 `json:"PrimaryImageAspectRatio,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 diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go index 444a25cac..352fce11d 100644 --- a/server/jellyfin/dto/mappers.go +++ b/server/jellyfin/dto/mappers.go @@ -5,6 +5,7 @@ import ( "fmt" "time" + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/slice" ) @@ -192,31 +193,68 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto { } 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)}} + // Clients prefer ImageTags.Primary over AlbumId, so a track's own cover must win here. + if mf.ImageHash != "" && mf.ImageHash != mf.AlbumImage.ImageHash { + tag, blurs, ratio := primaryImage(mf.ItemImage, mf.ID, fields) + item.ImageTags = map[string]string{"Primary": tag} + item.ImageBlurHashes = blurs + item.PrimaryImageAspectRatio = ratio + } else if embeddedArtPending(mf) { + // Nothing enqueues media files: advertising the id is what makes a client ask, and that + // request is what extracts the embedded art and queues the track. + item.ImageTags = map[string]string{"Primary": mf.ID} + } else if mf.AlbumID != "" { + if tag, blurs, ratio := primaryImage(mf.AlbumImage, mf.AlbumID, fields); tag != "" { + item.AlbumPrimaryImageTag = tag + item.ImageBlurHashes = blurs + item.PrimaryImageAspectRatio = ratio + } } return item } +func embeddedArtPending(mf model.MediaFile) bool { + return mf.HasCoverArt && conf.Server.EnableMediaFileCoverArt && + mf.ImageHash == "" && !mf.ItemImage.ImageAbsent +} + +// primaryImage never fakes a blurhash: clients key their cover cache on the value, which would +// pin a stale cover forever. +func primaryImage(img model.ItemImage, fallback string, fields Fields) (tag string, blurs map[string]map[string]string, ratio *float64) { + if img.ImageAbsent { + return "", nil, nil + } + tag = cmp.Or(img.ImageHash, fallback) + if img.BlurHash != "" { + blurs = map[string]map[string]string{"Primary": {tag: img.BlurHash}} + } + if fields.Has("PrimaryImageAspectRatio") { + ratio = img.AspectRatio() + } + return tag, blurs, ratio +} + func AlbumToBaseItem(al model.Album, fields Fields) BaseItemDto { + tag, blurs, ratio := primaryImage(al.ItemImage, al.ID, fields) 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), + 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), + ImageBlurHashes: blurs, + PrimaryImageAspectRatio: ratio, + BackdropImageTags: []string{}, + UserData: UserData(al.Annotations, al.ID), + } + if tag != "" { + item.ImageTags = map[string]string{"Primary": tag} } if al.AlbumArtistID != "" { item.AlbumArtists = []NameGuidPair{{Name: al.AlbumArtist, Id: EncodeID(al.AlbumArtistID)}} @@ -246,20 +284,25 @@ func AlbumToBaseItem(al model.Album, fields Fields) BaseItemDto { 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 ArtistToBaseItem(ar model.Artist, fields Fields) BaseItemDto { + tag, blurs, ratio := primaryImage(ar.ItemImage, ar.ID, fields) + item := BaseItemDto{ + Name: ar.Name, + Id: EncodeID(ar.ID), + Type: "MusicArtist", + IsFolder: true, + AlbumCount: new(ar.AlbumCount), + SongCount: new(ar.SongCount), + DateCreated: jellyfinDate(ar.CreatedAt), + ImageBlurHashes: blurs, + PrimaryImageAspectRatio: ratio, + BackdropImageTags: []string{}, + UserData: UserData(ar.Annotations, ar.ID), } + if tag != "" { + item.ImageTags = map[string]string{"Primary": tag} + } + return item } func GenreToBaseItem(g model.Genre) BaseItemDto { @@ -282,26 +325,28 @@ func StudioToBaseItem(t model.Tag) BaseItemDto { } // 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{ +func PlaylistToBaseItem(p model.Playlist, fields Fields) BaseItemDto { + tag, blurs, ratio := primaryImage(p.ItemImage, p.ID, fields) + item := 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), + Path: "/data/playlists/" + p.ID, + IsFolder: true, + MediaType: "Audio", + ChildCount: new(p.SongCount), + RunTimeTicks: TicksFromSeconds(p.Duration), + ImageBlurHashes: blurs, + PrimaryImageAspectRatio: ratio, + BackdropImageTags: []string{}, + UserData: UserData(p.Annotations, p.ID), } + if tag != "" { + item.ImageTags = map[string]string{"Primary": tag} + } + return item } // LyricDtoFromLyrics maps one lyric track to Jellyfin's LyricDto. Clients infer synced-vs-plain diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go index c11f38eaf..a7b381c5c 100644 --- a/server/jellyfin/dto/mappers_test.go +++ b/server/jellyfin/dto/mappers_test.go @@ -4,6 +4,8 @@ import ( "encoding/json" "time" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -34,8 +36,8 @@ var _ = Describe("mappers", func() { 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)) + Expect(item.AlbumPrimaryImageTag).To(Equal("alb-1")) + Expect(item.ImageBlurHashes).To(BeNil()) Expect(item.Genres).To(Equal([]string{"genre 1", "genre 2"})) Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID("1"), Name: "genre 1"}, {Id: EncodeID("2"), Name: "genre 2"}})) }) @@ -254,8 +256,8 @@ var _ = Describe("mappers", func() { 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)) + Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "alb-1")) + Expect(item.ImageBlurHashes).To(BeNil()) Expect(item.Genres).To(Equal([]string{"genre 1", "genre 2"})) Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID("1"), Name: "genre 1"}, {Id: EncodeID("2"), Name: "genre 2"}})) }) @@ -288,9 +290,74 @@ var _ = Describe("mappers", func() { Expect(string(b)).ToNot(ContainSubstring("NormalizationGain")) }) + Describe("PrimaryImageAspectRatio", func() { + nonSquare := func() model.Album { + al := model.Album{ID: "al1", Name: "Album"} + al.ImageHash, al.ImageWidth, al.ImageHeight = "abc", 1200, 800 + return al + } + + It("is omitted unless the request asks for it", func() { + Expect(AlbumToBaseItem(nonSquare(), nil).PrimaryImageAspectRatio).To(BeNil()) + b, err := json.Marshal(AlbumToBaseItem(nonSquare(), nil)) + Expect(err).ToNot(HaveOccurred()) + Expect(string(b)).ToNot(ContainSubstring("PrimaryImageAspectRatio")) + }) + + It("carries the real ratio when asked", func() { + item := AlbumToBaseItem(nonSquare(), ParseFields("PrimaryImageAspectRatio")) + Expect(*item.PrimaryImageAspectRatio).To(BeNumerically("~", 1.5, 0.0001)) + }) + + It("is omitted when the dimensions are unknown, rather than guessing square", func() { + al := model.Album{ID: "al1", Name: "Album"} + al.ImageHash = "abc" + item := AlbumToBaseItem(al, ParseFields("PrimaryImageAspectRatio")) + Expect(item.PrimaryImageAspectRatio).To(BeNil()) + }) + + It("is omitted when the item has no image at all", func() { + al := model.Album{ID: "al1", Name: "Album"} + al.ImageAbsent = true + al.ImageWidth, al.ImageHeight = 1200, 800 + item := AlbumToBaseItem(al, ParseFields("PrimaryImageAspectRatio")) + Expect(item.PrimaryImageAspectRatio).To(BeNil()) + }) + + It("carries the ratio for an artist", func() { + ar := model.Artist{ID: "ar1", Name: "Artist"} + ar.ImageHash, ar.ImageWidth, ar.ImageHeight = "abc", 1000, 500 + Expect(*ArtistToBaseItem(ar, ParseFields("PrimaryImageAspectRatio")).PrimaryImageAspectRatio). + To(BeNumerically("~", 2.0, 0.0001)) + }) + + It("carries the ratio for a playlist", func() { + pl := model.Playlist{ID: "pl1", Name: "Playlist"} + pl.ImageHash, pl.ImageWidth, pl.ImageHeight = "abc", 400, 800 + Expect(*PlaylistToBaseItem(pl, ParseFields("PrimaryImageAspectRatio")).PrimaryImageAspectRatio). + To(BeNumerically("~", 0.5, 0.0001)) + }) + + It("carries the ratio for a song with its own art", func() { + mf := model.MediaFile{ID: "mf1", Title: "Song"} + mf.ImageHash, mf.ImageWidth, mf.ImageHeight = "abc", 300, 600 + Expect(*SongToBaseItem(mf, ParseFields("PrimaryImageAspectRatio")).PrimaryImageAspectRatio). + To(BeNumerically("~", 0.5, 0.0001)) + }) + + // A track without its own art shows the album's, so the ratio has to describe that image. + It("uses the album's dimensions for a track falling back to album art", func() { + mf := model.MediaFile{ID: "mf1", Title: "Song", AlbumID: "al1"} + mf.AlbumImage.ImageHash, mf.AlbumImage.ImageWidth, mf.AlbumImage.ImageHeight = "abc", 1200, 800 + item := SongToBaseItem(mf, ParseFields("PrimaryImageAspectRatio")) + Expect(item.AlbumPrimaryImageTag).To(Equal("abc")) + Expect(*item.PrimaryImageAspectRatio).To(BeNumerically("~", 1.5, 0.0001)) + }) + }) + 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) + item := ArtistToBaseItem(ar, nil) Expect(item.Type).To(Equal("MusicArtist")) Expect(item.IsFolder).To(BeTrue()) Expect(item.Id).To(Equal(EncodeID("art-1"))) @@ -352,7 +419,7 @@ var _ = Describe("mappers", func() { ID: "pl-1", Name: "Chill", SongCount: 7, Duration: 120, Annotations: model.Annotations{Starred: true, Rating: 4, PlayCount: 2}, } - item := PlaylistToBaseItem(p) + item := PlaylistToBaseItem(p, nil) Expect(item.Type).To(Equal("Playlist")) Expect(item.IsFolder).To(BeTrue()) Expect(item.Id).To(Equal(EncodeID("pl-1"))) @@ -363,26 +430,202 @@ var _ = Describe("mappers", func() { 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)) + Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "pl-1")) + Expect(item.ImageBlurHashes).To(BeNil()) }) - 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) + It("changes the playlist image tag when the cover content changes", func() { + p := model.Playlist{ID: "pl-1", Name: "Chill"} + p.ImageHash = "1111111111111111" + before := PlaylistToBaseItem(p, nil) + p.ImageHash = "2222222222222222" + after := PlaylistToBaseItem(p, nil) - // 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"])) + Expect(before.ImageTags["Primary"]).To(Equal("1111111111111111")) + Expect(after.ImageTags["Primary"]).To(Equal("2222222222222222")) }) - It("keeps the playlist image tag stable when nothing changed", func() { + It("keeps the playlist image tag stable across a metadata-only edit", 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)) + p.ImageHash = "1111111111111111" + before := PlaylistToBaseItem(p, nil) + p.UpdatedAt = time.Date(2026, 7, 2, 0, 0, 0, 0, time.UTC) + after := PlaylistToBaseItem(p, nil) + + Expect(after.ImageTags["Primary"]).To(Equal(before.ImageTags["Primary"])) + }) + + // Nothing enqueues media files, so a track's own art only resolves when a client requests it; + // advertising just the album image would leave that cover unreachable. + Describe("unresolved embedded art", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.EnableMediaFileCoverArt = true + }) + + It("advertises the track id so the client triggers the read-through", func() { + mf := model.MediaFile{ID: "mf-1", AlbumID: "alb-1", HasCoverArt: true} + mf.AlbumImage.ImageHash = "0123456789abcdef" + + item := SongToBaseItem(mf, nil) + Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "mf-1")) + Expect(item.ImageBlurHashes).To(BeNil(), "no resolved image means no blurhash to send") + Expect(item.AlbumPrimaryImageTag).To(BeEmpty()) + }) + + It("falls back to the album when the track has no art of its own", func() { + mf := model.MediaFile{ID: "mf-2", AlbumID: "alb-1", HasCoverArt: false} + mf.AlbumImage.ImageHash = "0123456789abcdef" + + item := SongToBaseItem(mf, nil) + Expect(item.ImageTags).To(BeEmpty()) + Expect(item.AlbumPrimaryImageTag).To(Equal("0123456789abcdef")) + }) + + It("falls back to the album once the track's art is known absent", func() { + mf := model.MediaFile{ID: "mf-3", AlbumID: "alb-1", HasCoverArt: true} + mf.ItemImage.ImageAbsent = true + mf.AlbumImage.ImageHash = "0123456789abcdef" + + item := SongToBaseItem(mf, nil) + Expect(item.ImageTags).To(BeEmpty()) + Expect(item.AlbumPrimaryImageTag).To(Equal("0123456789abcdef")) + }) + + It("falls back to the album when per-track art is disabled", func() { + conf.Server.EnableMediaFileCoverArt = false + mf := model.MediaFile{ID: "mf-4", AlbumID: "alb-1", HasCoverArt: true} + mf.AlbumImage.ImageHash = "0123456789abcdef" + + item := SongToBaseItem(mf, nil) + Expect(item.ImageTags).To(BeEmpty()) + Expect(item.AlbumPrimaryImageTag).To(Equal("0123456789abcdef")) + }) + }) + + Describe("primary image tags", func() { + It("uses the content hash as the tag and emits the real blurhash", func() { + al := model.Album{ID: "alb-1", Name: "Album"} + al.ImageHash = "0123456789abcdef" + al.BlurHash = "LEHV6nWB2yk8" + + item := AlbumToBaseItem(al, nil) + Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "0123456789abcdef")) + Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("0123456789abcdef", "LEHV6nWB2yk8")) + }) + + It("omits the blurhash entirely when none was computed", func() { + al := model.Album{ID: "alb-2", Name: "Album"} + al.ImageHash = "0123456789abcdef" + + item := AlbumToBaseItem(al, nil) + Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "0123456789abcdef")) + Expect(item.ImageBlurHashes).To(BeNil(), "a synthesized blurhash pins stale covers in Finamp") + }) + + It("omits tags for known-absent artwork", func() { + al := model.Album{ID: "alb-3", Name: "Album"} + al.ImageAbsent = true + + item := AlbumToBaseItem(al, nil) + Expect(item.ImageTags).To(BeEmpty()) + Expect(item.ImageBlurHashes).To(BeNil()) + }) + + It("falls back to the entity id while artwork is still unresolved", func() { + item := AlbumToBaseItem(model.Album{ID: "alb-4", Name: "Album"}, nil) + Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "alb-4")) + Expect(item.ImageBlurHashes).To(BeNil()) + }) + + It("versions an artist's tag by content hash", func() { + ar := model.Artist{ID: "art-1", Name: "Artist"} + ar.ImageHash = "fedcba9876543210" + ar.BlurHash = "L6PZfSi_.AyE" + + item := ArtistToBaseItem(ar, nil) + Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "fedcba9876543210")) + Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("fedcba9876543210", "L6PZfSi_.AyE")) + }) + }) + + Describe("song and playlist image tags", func() { + It("versions a song's album tag by the album's content hash", func() { + mf := model.MediaFile{ID: "song-1", Title: "Song", AlbumID: "alb-1"} + mf.AlbumImage.ImageHash = "0123456789abcdef" + mf.AlbumImage.BlurHash = "LEHV6nWB2yk8" + + item := SongToBaseItem(mf, nil) + Expect(item.AlbumPrimaryImageTag).To(Equal("0123456789abcdef")) + Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("0123456789abcdef", "LEHV6nWB2yk8")) + }) + + It("never synthesizes a song blurhash when the album has none", func() { + mf := model.MediaFile{ID: "song-2", Title: "Song", AlbumID: "alb-2"} + mf.AlbumImage.ImageHash = "0123456789abcdef" + + item := SongToBaseItem(mf, nil) + Expect(item.AlbumPrimaryImageTag).To(Equal("0123456789abcdef")) + Expect(item.ImageBlurHashes).To(BeNil()) + }) + + It("omits a song's album tag when the album art is known absent", func() { + mf := model.MediaFile{ID: "song-3", Title: "Song", AlbumID: "alb-3"} + mf.AlbumImage.ImageAbsent = true + + item := SongToBaseItem(mf, nil) + Expect(item.AlbumPrimaryImageTag).To(BeEmpty()) + Expect(item.ImageBlurHashes).To(BeNil()) + }) + + It("versions a playlist tag by content hash instead of UpdatedAt", func() { + pl := model.Playlist{ID: "pl-1", Name: "Playlist"} + pl.ImageHash = "abcdef0123456789" + + item := PlaylistToBaseItem(pl, nil) + Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "abcdef0123456789")) + }) + }) + + Describe("per-song artwork", func() { + It("emits the track's own Primary tag when it has distinct art", func() { + mf := model.MediaFile{ID: "song-own", Title: "Song", AlbumID: "alb-1"} + mf.ImageHash = "aaaaaaaaaaaaaaaa" + mf.BlurHash = "LTRACKblur" + mf.AlbumImage.ImageHash = "bbbbbbbbbbbbbbbb" + mf.AlbumImage.BlurHash = "LALBUMblur" + + item := SongToBaseItem(mf, nil) + Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "aaaaaaaaaaaaaaaa")) + Expect(item.ImageBlurHashes["Primary"]).To(HaveLen(1), + "exactly one Primary entry: Go sorts map keys, so a second entry could pair the wrong blurhash with imageId") + Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("aaaaaaaaaaaaaaaa", "LTRACKblur")) + }) + + It("falls back to the album tag when the track has no distinct art", func() { + mf := model.MediaFile{ID: "song-inherit", Title: "Song", AlbumID: "alb-1"} + mf.ImageHash = "bbbbbbbbbbbbbbbb" + mf.BlurHash = "LALBUMblur" + mf.AlbumImage.ImageHash = "bbbbbbbbbbbbbbbb" + mf.AlbumImage.BlurHash = "LALBUMblur" + + item := SongToBaseItem(mf, nil) + Expect(item.ImageTags).To(BeEmpty(), "an inherited cover is the album's image, not the track's") + Expect(item.AlbumPrimaryImageTag).To(Equal("bbbbbbbbbbbbbbbb")) + Expect(item.ImageBlurHashes["Primary"]).To(HaveLen(1)) + Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("bbbbbbbbbbbbbbbb", "LALBUMblur")) + }) + + It("omits the track tag when its own art is known absent", func() { + mf := model.MediaFile{ID: "song-absent", Title: "Song", AlbumID: "alb-1"} + mf.ImageAbsent = true + mf.AlbumImage.ImageAbsent = true + + item := SongToBaseItem(mf, nil) + Expect(item.ImageTags).To(BeEmpty()) + Expect(item.AlbumPrimaryImageTag).To(BeEmpty()) + Expect(item.ImageBlurHashes).To(BeNil()) + }) }) }) diff --git a/server/jellyfin/e2e/e2e_suite_test.go b/server/jellyfin/e2e/e2e_suite_test.go index 31d98d1d9..ca1308e9e 100644 --- a/server/jellyfin/e2e/e2e_suite_test.go +++ b/server/jellyfin/e2e/e2e_suite_test.go @@ -33,7 +33,6 @@ import ( "strings" "testing" "testing/fstest" - "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" @@ -327,7 +326,7 @@ func setupTestDB() { decider, core.NewPlayers(ds), scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil), - playlists.NewPlaylists(ds, core.NewImageUploadService()), + playlists.NewPlaylists(ds, artwork.NewUploader(ds)), providerFake, sonicSvc, lyrics.NewLyrics(ds, nil), @@ -406,18 +405,18 @@ type spyArtwork struct { 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) Get(context.Context, model.ArtworkID, int, bool) (*artwork.Image, error) { + return nil, model.ErrNotFound } -func (s *spyArtwork) GetOrPlaceholder(c context.Context, id string, _ int, _ bool) (io.ReadCloser, time.Time, error) { +func (s *spyArtwork) GetOrPlaceholder(c context.Context, id string, _ int, _ bool) (*artwork.Image, 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 + return &artwork.Image{ReadCloser: io.NopCloser(bytes.NewReader(d))}, nil } var _ artwork.Artwork = &spyArtwork{} diff --git a/server/jellyfin/e2e/playlists_test.go b/server/jellyfin/e2e/playlists_test.go index d2ff49db9..08d6f4f6f 100644 --- a/server/jellyfin/e2e/playlists_test.go +++ b/server/jellyfin/e2e/playlists_test.go @@ -6,8 +6,8 @@ import ( jpeglib "image/jpeg" "net/http" "os" - "time" + "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server/jellyfin/dto" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -215,26 +215,26 @@ var _ = Describe("Playlists", func() { 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() { + // An upload must clear the resolved artwork state, or clients keep serving the stale cover + // from their tag-keyed cache until the next scan. + It("clears the resolved image tag after a cover upload", func() { plID := createPlaylist("Cover Tag", nil) + Expect(ds.Artwork(ctx).PutItemArtwork(&model.ItemArtwork{ + ItemKind: model.KindPlaylistArtwork.Prefix(), ItemID: plID, Hash: "1111111111111111", + })).To(Succeed()) + 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()) + Expect(imageTag()).To(Equal("1111111111111111")) - 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)) + // The upload re-queues resolution instead of resolving inline, so the tag goes bare. + Expect(imageTag()).ToNot(Equal("1111111111111111")) }) }) diff --git a/server/jellyfin/images.go b/server/jellyfin/images.go index 0ec34f491..b844e7c74 100644 --- a/server/jellyfin/images.go +++ b/server/jellyfin/images.go @@ -16,10 +16,11 @@ import ( "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/core/artwork" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/imghttp" "github.com/navidrome/navidrome/server/jellyfin/dto" _ "golang.org/x/image/webp" ) @@ -32,7 +33,7 @@ func (api *Router) getItemImage(w http.ResponseWriter, r *http.Request) { size, _ := strconv.Atoi(r.URL.Query().Get("maxwidth")) artID := api.resolveArtworkID(ctx, itemId) - reader, _, err := api.artwork.GetOrPlaceholder(ctx, artID, size, false) + img, err := api.artwork.GetOrPlaceholder(ctx, artID, size, false) switch { case errors.Is(err, context.Canceled): return @@ -41,9 +42,27 @@ func (api *Router) getItemImage(w http.ResponseWriter, r *http.Request) { http.Error(w, "Not Found", http.StatusNotFound) return } - defer reader.Close() + defer img.Close() + if imghttp.WriteImageHeaders(w, r, img, hashFromTag(r)) { + return + } // Leave Content-Type unset so net/http sniffs it (covers may be PNG/WebP/JPEG). - _, _ = io.Copy(w, reader) + _, _ = io.Copy(w, img) +} + +// hashFromTag returns the ?tag query param when it is exactly a 16-char lowercase-hex content +// hash (what Finamp/Jellyfin clients append), so a matching request can be served immutable. +func hashFromTag(r *http.Request) string { + tag := r.URL.Query().Get("tag") + if len(tag) != 16 { + return "" + } + for _, c := range tag { + if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') { + return "" + } + } + return tag } // resolveArtworkID maps a Jellyfin item id to a Navidrome ArtworkID, probing @@ -79,7 +98,7 @@ func (api *Router) postItemImage(w http.ResponseWriter, r *http.Request) { } // 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() + limit := artwork.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", diff --git a/server/jellyfin/images_test.go b/server/jellyfin/images_test.go index e435b99fd..bdd47e956 100644 --- a/server/jellyfin/images_test.go +++ b/server/jellyfin/images_test.go @@ -33,16 +33,21 @@ type fakeArtwork struct { recvId string recvCtx context.Context data []byte + hash string } -func (f *fakeArtwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error) { +func (f *fakeArtwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (*artwork.Image, 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 + return &artwork.Image{ + ReadCloser: io.NopCloser(bytes.NewReader(data)), + Hash: f.hash, + LastUpdated: time.Now(), + }, nil } func newImageRequest(itemId string) (*httptest.ResponseRecorder, *http.Request) { @@ -70,6 +75,19 @@ var _ = Describe("Images", func() { Expect(fa.recvId).To(ContainSubstring("a1")) }) + // resolveArtworkID probes the entity tables, so a deleted item yields no artwork id at all. + It("asks for no artwork once the item is deleted, rather than its lingering state", func() { + ds := &tests.MockDataStore{} // no albums/artists/tracks/playlists at all + fa := &fakeArtwork{} + api := &Router{ds: ds, artwork: fa} + + w, r := newImageRequest(dto.EncodeID("deleted-item")) + api.getItemImage(w, r) + + Expect(fa.recvId).To(BeEmpty(), "an empty artwork id can only yield a placeholder") + Expect(w.Body.String()).ToNot(ContainSubstring("deleted-item")) + }) + 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"}}) @@ -115,6 +133,38 @@ var _ = Describe("Images", func() { Expect(ok).To(BeTrue()) Expect(u.IsAdmin).To(BeTrue()) }) + + It("serves immutable when the tag param asserts the current hash", func() { + const hash = "0123456789abcdef" + ds := &tests.MockDataStore{} + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + fa := &fakeArtwork{hash: hash} + api := &Router{ds: ds, artwork: fa} + + w, r := newImageRequest(dto.EncodeID("a1")) + q := r.URL.Query() + q.Set("tag", hash) + r.URL.RawQuery = q.Encode() + api.getItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Cache-Control")).To(Equal("public, max-age=31536000, immutable")) + Expect(w.Header().Get("ETag")).To(Equal(`"` + hash + `"`)) + }) + + It("revalidates via no-cache when no tag is provided", func() { + const hash = "0123456789abcdef" + ds := &tests.MockDataStore{} + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + fa := &fakeArtwork{hash: hash} + 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("Cache-Control")).To(Equal("public, no-cache")) + }) }) // Real image fixtures: postItemImage validates uploads by decoding them. diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go index 7cf2a17ba..631530b6d 100644 --- a/server/jellyfin/items.go +++ b/server/jellyfin/items.go @@ -588,7 +588,7 @@ func (api *Router) listSongs(ctx context.Context, opts model.QueryOptions, q ite // A full-library request (Finamp's sync, with MediaSources) is tens of thousands of fat rows. total, _ := repo.CountAll(model.QueryOptions{Filters: opts.Filters}) open := streamCursor(func() (func(func(model.MediaFile, error) bool), error) { - return repo.GetCursor(opts) + return repo.GetCursorWithArtwork(opts) }, toItem) return streamed(open, int(total), opts.Offset), nil } @@ -598,6 +598,7 @@ func (api *Router) listSongs(ctx context.Context, opts model.QueryOptions, q ite // genreIds isn't applied to search — a name lookup, like role (see below). func (api *Router) listArtists(ctx context.Context, opts model.QueryOptions, q itemsQuery, role model.Role) (itemsResult, error) { repo := api.ds.Artist(ctx) + toItem := func(ar model.Artist) dto.BaseItemDto { return dto.ArtistToBaseItem(ar, q.fields) } // 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 @@ -613,7 +614,7 @@ func (api *Router) listArtists(ctx context.Context, opts model.QueryOptions, q i if err != nil { return itemsResult{}, err } - return materialized(result(slice.Map(artists, dto.ArtistToBaseItem), total, opts.Offset)), nil + return materialized(result(slice.Map(artists, toItem), total, opts.Offset)), nil } if q.favOnly { @@ -629,7 +630,7 @@ func (api *Router) listArtists(ctx context.Context, opts model.QueryOptions, q i total, _ := repo.CountAll(model.QueryOptions{Filters: opts.Filters}) open := streamCursor(func() (func(func(model.Artist, error) bool), error) { return repo.GetCursor(opts) - }, dto.ArtistToBaseItem) + }, toItem) return streamed(open, int(total), opts.Offset), nil } @@ -663,7 +664,7 @@ func (api *Router) listPlaylists(ctx context.Context, opts model.QueryOptions, q } open := streamCursor(func() (func(func(model.Playlist, error) bool), error) { return repo.GetCursor(opts) - }, dto.PlaylistToBaseItem) + }, func(p model.Playlist) dto.BaseItemDto { return dto.PlaylistToBaseItem(p, q.fields) }) return streamed(open, int(total), opts.Offset), nil } @@ -698,7 +699,7 @@ func (api *Router) resolveItemByID(ctx context.Context, id string, fields dto.Fi 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 + return dto.ArtistToBaseItem(*ar, fields), true } if mf, err := api.ds.MediaFile(ctx).Get(id); err == nil { if !u.HasLibraryAccess(mf.LibraryID) { @@ -708,7 +709,7 @@ func (api *Router) resolveItemByID(ctx context.Context, id string, fields dto.Fi } // 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.PlaylistToBaseItem(*pl, fields), true } return dto.BaseItemDto{}, false } diff --git a/server/jellyfin/similar.go b/server/jellyfin/similar.go index 661fb95cc..2698073bf 100644 --- a/server/jellyfin/similar.go +++ b/server/jellyfin/similar.go @@ -138,7 +138,7 @@ func (api *Router) similarArtists(ctx context.Context, id string, limit int) dto return result(nil, 0, 0) } present := slice.Filter(artist.SimilarArtists, func(a model.Artist) bool { return a.ID != "" }) - items := slice.Map(present, dto.ArtistToBaseItem) + items := slice.Map(present, func(a model.Artist) dto.BaseItemDto { return dto.ArtistToBaseItem(a, nil) }) return result(items, len(items), 0) } diff --git a/server/nativeapi/artists.go b/server/nativeapi/artists.go index daa918d00..193f88eda 100644 --- a/server/nativeapi/artists.go +++ b/server/nativeapi/artists.go @@ -46,7 +46,11 @@ func (api *Router) uploadArtistImage() http.HandlerFunc { } ar.UploadedImage = filename ar.UpdatedAt = new(time.Now()) - return api.ds.Artist(ctx).Put(ar, "uploaded_image", "updated_at") + if err := api.ds.Artist(ctx).Put(ar, "uploaded_image", "updated_at"); err != nil { + return err + } + api.imgUpload.EnqueueArtwork(ctx, consts.EntityArtist, ar.ID) + return nil }) } @@ -65,6 +69,10 @@ func (api *Router) deleteArtistImage() http.HandlerFunc { } ar.UploadedImage = "" ar.UpdatedAt = new(time.Now()) - return api.ds.Artist(ctx).Put(ar, "uploaded_image", "updated_at") + if err := api.ds.Artist(ctx).Put(ar, "uploaded_image", "updated_at"); err != nil { + return err + } + api.imgUpload.EnqueueArtwork(ctx, consts.EntityArtist, ar.ID) + return nil }) } diff --git a/server/nativeapi/artwork.go b/server/nativeapi/artwork.go new file mode 100644 index 000000000..3583c2db7 --- /dev/null +++ b/server/nativeapi/artwork.go @@ -0,0 +1,42 @@ +package nativeapi + +import ( + "net/http" + "slices" + + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" +) + +var refreshableArtworkKinds = []model.Kind{ + model.KindAlbumArtwork, + model.KindArtistArtwork, + model.KindPlaylistArtwork, + model.KindRadioArtwork, + model.KindMediaFileArtwork, +} + +func (api *Router) addArtworkRoute(r chi.Router) { + r.Post("/artwork/{kind}/{id}/refresh", api.refreshArtwork()) +} + +// State is deliberately cleared so a wrong pick disappears immediately (placeholder until re-resolved). +func (api *Router) refreshArtwork() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + kind, _ := model.ParseKind(chi.URLParam(r, "kind")) + id := chi.URLParam(r, "id") + if !slices.Contains(refreshableArtworkKinds, kind) { + http.Error(w, "invalid artwork kind", http.StatusBadRequest) + return + } + if err := artwork.Refresh(ctx, api.ds, kind, id); err != nil { + log.Error(ctx, "Error refreshing artwork", "kind", kind, "id", id, err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) + } +} diff --git a/server/nativeapi/artwork_test.go b/server/nativeapi/artwork_test.go new file mode 100644 index 000000000..b26a94e6a --- /dev/null +++ b/server/nativeapi/artwork_test.go @@ -0,0 +1,95 @@ +package nativeapi + +import ( + "context" + "net/http" + "net/http/httptest" + + "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/server" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Artwork API", func() { + var ds *tests.MockDataStore + var artRepo *tests.MockArtworkRepo + var queueRepo *tests.MockArtworkQueueRepo + var router http.Handler + var adminToken, userToken string + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.EnableSharing = false + artRepo = tests.CreateMockArtworkRepo() + queueRepo = tests.CreateMockArtworkQueueRepo() + ds = &tests.MockDataStore{MockedArtwork: artRepo, MockedArtworkQueue: queueRepo} + auth.Init(ds) + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil) + router = server.JWTVerifier(nativeRouter) + + adminUser := model.User{ID: "admin-1", UserName: "admin", IsAdmin: true, NewPassword: "adminpass"} + regularUser := model.User{ID: "user-1", UserName: "regular", IsAdmin: false, NewPassword: "userpass"} + Expect(ds.User(context.TODO()).Put(&adminUser)).To(Succeed()) + Expect(ds.User(context.TODO()).Put(®ularUser)).To(Succeed()) + + var err error + adminToken, err = auth.CreateToken(&adminUser) + Expect(err).ToNot(HaveOccurred()) + userToken, err = auth.CreateToken(®ularUser) + Expect(err).ToNot(HaveOccurred()) + }) + + Describe("POST /api/artwork/{kind}/{id}/refresh", func() { + It("clears state and enqueues a Bump for admins", func() { + Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ + ItemKind: "al", ItemID: "al-1", Hash: "oldhash", Source: "external", + })).To(Succeed()) + + req := createAuthenticatedRequest("POST", "/artwork/al/al-1/refresh", nil, adminToken) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + + _, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + + queued, err := queueRepo.DequeueBatch(1000) + Expect(err).ToNot(HaveOccurred()) + Expect(queued).To(ContainElement(SatisfyAll( + HaveField("ItemKind", "al"), + HaveField("ItemID", "al-1"), + HaveField("Priority", model.ArtworkPriorityBump), + ))) + }) + + It("returns 400 for an invalid kind", func() { + req := createAuthenticatedRequest("POST", "/artwork/xx/id-1/refresh", nil, adminToken) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusBadRequest)) + }) + + It("denies access to regular users", func() { + req := createAuthenticatedRequest("POST", "/artwork/al/al-1/refresh", nil, userToken) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusForbidden)) + }) + + It("denies access without authentication", func() { + req := createUnauthenticatedRequest("POST", "/artwork/al/al-1/refresh", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) + }) +}) diff --git a/server/nativeapi/image_upload.go b/server/nativeapi/image_upload.go index 077eac35e..c4ff62e42 100644 --- a/server/nativeapi/image_upload.go +++ b/server/nativeapi/image_upload.go @@ -14,7 +14,7 @@ import ( "strings" "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -31,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 := core.MaxImageUploadSize() + maxImageSize := artwork.MaxImageUploadSize() return func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() if !checkImageUploadPermission(w, r) { diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index 5a7023eb6..f97bd26b2 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -13,6 +13,7 @@ import ( "github.com/go-chi/chi/v5/middleware" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/metrics" playlistsvc "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/log" @@ -44,10 +45,10 @@ type Router struct { users core.User maintenance core.Maintenance pluginManager PluginManager - imgUpload core.ImageUploadService + imgUpload artwork.Uploader } -func New(ds model.DataStore, share core.Share, playlists playlistsvc.Playlists, insights metrics.Insights, libraryService core.Library, userService core.User, maintenance core.Maintenance, pluginManager PluginManager, imgUpload core.ImageUploadService) *Router { +func New(ds model.DataStore, share core.Share, playlists playlistsvc.Playlists, insights metrics.Insights, libraryService core.Library, userService core.User, maintenance core.Maintenance, pluginManager PluginManager, imgUpload artwork.Uploader) *Router { r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, users: userService, maintenance: maintenance, pluginManager: pluginManager, imgUpload: imgUpload} r.Handler = r.routes() return r @@ -91,6 +92,7 @@ func (api *Router) routes() http.Handler { api.addConfigRoute(r) api.addUserLibraryRoute(r) api.addPluginRoute(r) + api.addArtworkRoute(r) api.RX(r, "/library", api.libs.NewRepository, true) }) }) diff --git a/server/nativeapi/radios.go b/server/nativeapi/radios.go index 701c6c926..3e88af287 100644 --- a/server/nativeapi/radios.go +++ b/server/nativeapi/radios.go @@ -47,7 +47,11 @@ func (api *Router) uploadRadioImage() http.HandlerFunc { return err } radio.UploadedImage = filename - return api.ds.Radio(ctx).Put(radio, "UploadedImage") + if err := api.ds.Radio(ctx).Put(radio, "UploadedImage"); err != nil { + return err + } + api.imgUpload.EnqueueArtwork(ctx, consts.EntityRadio, radio.ID) + return nil }) } @@ -65,6 +69,10 @@ func (api *Router) deleteRadioImage() http.HandlerFunc { return err } radio.UploadedImage = "" - return api.ds.Radio(ctx).Put(radio, "UploadedImage") + if err := api.ds.Radio(ctx).Put(radio, "UploadedImage"); err != nil { + return err + } + api.imgUpload.EnqueueArtwork(ctx, consts.EntityRadio, radio.ID) + return nil }) } diff --git a/server/public/handle_images.go b/server/public/handle_images.go index 633665e0f..6416190bd 100644 --- a/server/public/handle_images.go +++ b/server/public/handle_images.go @@ -11,6 +11,8 @@ import ( "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/imghttp" "github.com/navidrome/navidrome/utils/req" ) @@ -37,10 +39,13 @@ func (pub *Router) handleImages(w http.ResponseWriter, r *http.Request) { http.Error(w, "invalid request", http.StatusBadRequest) return } + // The token is the authorization, so the entity check must ask "is it still there", not + // "may this user see it" -- the latter would hide a shared private playlist. + ctx = request.WithUser(ctx, model.User{IsAdmin: true}) size := p.IntOr("size", 0) square := p.BoolOr("square", false) - imgReader, lastUpdate, err := pub.artwork.Get(ctx, artId, size, square) + img, err := pub.artwork.Get(ctx, artId, size, square) switch { case errors.Is(err, context.Canceled): return @@ -58,10 +63,11 @@ func (pub *Router) handleImages(w http.ResponseWriter, r *http.Request) { return } - defer imgReader.Close() - w.Header().Set("Cache-Control", "public, max-age=315360000") - w.Header().Set("Last-Modified", lastUpdate.Format(http.TimeFormat)) - cnt, err := io.Copy(w, imgReader) + defer img.Close() + if imghttp.WriteImageHeaders(w, r, img, artId.Hash) { + return + } + cnt, err := io.Copy(w, img) if err != nil { log.Warn(ctx, "Error sending image", "count", cnt, err) } diff --git a/server/public/handle_images_test.go b/server/public/handle_images_test.go index 669f879a6..d77b6f427 100644 --- a/server/public/handle_images_test.go +++ b/server/public/handle_images_test.go @@ -1,8 +1,18 @@ package public import ( + "bytes" + "context" + "io" + "net/http" + "net/http/httptest" + "net/url" + "github.com/go-chi/jwtauth/v5" + "github.com/navidrome/navidrome/core/artwork" "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" ) @@ -23,3 +33,56 @@ var _ = Describe("decodeArtworkID", func() { Expect(err).To(HaveOccurred()) }) }) + +type fakeArtwork struct { + artwork.Artwork + img *artwork.Image + err error +} + +func (f *fakeArtwork) Get(context.Context, model.ArtworkID, int, bool) (*artwork.Image, error) { + return f.img, f.err +} + +var _ = Describe("handleImages", func() { + var w *httptest.ResponseRecorder + + newImageRequest := func(claimID string) *http.Request { + auth.PublicTokenAuth = jwtauth.New("HS256", []byte("super secret"), nil) + token, _ := auth.CreatePublicToken(auth.Claims{ID: claimID}) + return httptest.NewRequest("GET", "/img?:id="+url.QueryEscape(token), nil) + } + + // The handler re-checks that the entity behind the token still exists, so "1" must be live. + var ds *tests.MockDataStore + + BeforeEach(func() { + w = httptest.NewRecorder() + albumRepo := tests.CreateMockAlbumRepo() + albumRepo.SetData(model.Albums{{ID: "1"}}) + ds = &tests.MockDataStore{MockedAlbum: albumRepo} + }) + + It("returns 404 when the artwork is unavailable", func() { + pub := &Router{ds: ds, artwork: &fakeArtwork{err: artwork.ErrUnavailable}} + pub.handleImages(w, newImageRequest("al-1")) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 404 when the artwork is not found", func() { + pub := &Router{ds: ds, artwork: &fakeArtwork{err: model.ErrNotFound}} + pub.handleImages(w, newImageRequest("al-1")) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("serves the image immutable when the token asserts the current hash", func() { + const hash = "0123456789abcdef" + img := &artwork.Image{ReadCloser: io.NopCloser(bytes.NewReader([]byte("IMG"))), Hash: hash} + pub := &Router{ds: ds, artwork: &fakeArtwork{img: img}} + pub.handleImages(w, newImageRequest("al-1_"+hash)) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Cache-Control")).To(Equal("public, max-age=31536000, immutable")) + Expect(w.Header().Get("ETag")).To(Equal(`"` + hash + `"`)) + }) + +}) diff --git a/server/subsonic/browsing.go b/server/subsonic/browsing.go index 59d267321..d32a5c857 100644 --- a/server/subsonic/browsing.go +++ b/server/subsonic/browsing.go @@ -230,9 +230,11 @@ func (api *Router) GetAlbumInfo(r *http.Request) (*responses.Subsonic, error) { response := newResponse() response.AlbumInfo = &responses.AlbumInfo{} response.AlbumInfo.Notes = album.Description - response.AlbumInfo.SmallImageUrl = publicurl.ImageURL(r, album.CoverArtID(), 300) - response.AlbumInfo.MediumImageUrl = publicurl.ImageURL(r, album.CoverArtID(), 600) - response.AlbumInfo.LargeImageUrl = publicurl.ImageURL(r, album.CoverArtID(), 1200) + if !album.ImageAbsent { + response.AlbumInfo.SmallImageUrl = publicurl.ImageURL(r, album.CoverArtID(), 300) + response.AlbumInfo.MediumImageUrl = publicurl.ImageURL(r, album.CoverArtID(), 600) + response.AlbumInfo.LargeImageUrl = publicurl.ImageURL(r, album.CoverArtID(), 1200) + } response.AlbumInfo.LastFmUrl = album.ExternalUrl response.AlbumInfo.MusicBrainzID = album.MbzAlbumID @@ -295,9 +297,11 @@ func (api *Router) getArtistInfo(r *http.Request) (*responses.ArtistInfoBase, *m base := responses.ArtistInfoBase{} base.Biography = artist.Biography - base.SmallImageUrl = publicurl.ImageURL(r, artist.CoverArtID(), 300) - base.MediumImageUrl = publicurl.ImageURL(r, artist.CoverArtID(), 600) - base.LargeImageUrl = publicurl.ImageURL(r, artist.CoverArtID(), 1200) + if !artist.ImageAbsent { + base.SmallImageUrl = publicurl.ImageURL(r, artist.CoverArtID(), 300) + base.MediumImageUrl = publicurl.ImageURL(r, artist.CoverArtID(), 600) + base.LargeImageUrl = publicurl.ImageURL(r, artist.CoverArtID(), 1200) + } base.LastFmUrl = artist.ExternalUrl base.MusicBrainzID = artist.MbzArtistID @@ -454,7 +458,7 @@ func (api *Router) buildAlbumDirectory(ctx context.Context, album *model.Album) dir.AverageRating = album.AverageRating } dir.SongCount = int32(album.SongCount) - dir.CoverArt = album.CoverArtID().String() + dir.CoverArt = coverArtOrEmpty(album.CoverArtID(), album.ImageAbsent) if album.Starred { dir.Starred = album.StarredAt } diff --git a/server/subsonic/browsing_test.go b/server/subsonic/browsing_test.go index b8f510aed..d34da2f37 100644 --- a/server/subsonic/browsing_test.go +++ b/server/subsonic/browsing_test.go @@ -6,6 +6,7 @@ import ( "net/http/httptest" "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" @@ -157,4 +158,57 @@ var _ = Describe("Browsing", func() { Expect(response.Artist).ToNot(BeNil()) }) }) + + Describe("GetAlbumInfo", func() { + It("emits image URLs when the album artwork is unresolved", func() { + api.provider = &fakeInfoProvider{album: &model.Album{ID: "al-1"}} + r := httptest.NewRequest("GET", "/rest/getAlbumInfo?id=al-1", nil) + resp, err := api.GetAlbumInfo(r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.AlbumInfo.SmallImageUrl).ToNot(BeEmpty()) + Expect(resp.AlbumInfo.LargeImageUrl).ToNot(BeEmpty()) + }) + It("omits image URLs when the album artwork is known absent", func() { + api.provider = &fakeInfoProvider{album: &model.Album{ID: "al-1", ItemImage: model.ItemImage{ImageAbsent: true}}} + r := httptest.NewRequest("GET", "/rest/getAlbumInfo?id=al-1", nil) + resp, err := api.GetAlbumInfo(r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.AlbumInfo.SmallImageUrl).To(BeEmpty()) + Expect(resp.AlbumInfo.MediumImageUrl).To(BeEmpty()) + Expect(resp.AlbumInfo.LargeImageUrl).To(BeEmpty()) + }) + }) + + Describe("GetArtistInfo", func() { + It("emits image URLs when the artist artwork is unresolved", func() { + api.provider = &fakeInfoProvider{artist: &model.Artist{ID: "ar-1"}} + r := httptest.NewRequest("GET", "/rest/getArtistInfo?id=ar-1", nil) + resp, err := api.GetArtistInfo(r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.ArtistInfo.SmallImageUrl).ToNot(BeEmpty()) + }) + It("omits image URLs when the artist artwork is known absent", func() { + api.provider = &fakeInfoProvider{artist: &model.Artist{ID: "ar-1", ItemImage: model.ItemImage{ImageAbsent: true}}} + r := httptest.NewRequest("GET", "/rest/getArtistInfo?id=ar-1", nil) + resp, err := api.GetArtistInfo(r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.ArtistInfo.SmallImageUrl).To(BeEmpty()) + Expect(resp.ArtistInfo.MediumImageUrl).To(BeEmpty()) + Expect(resp.ArtistInfo.LargeImageUrl).To(BeEmpty()) + }) + }) }) + +type fakeInfoProvider struct { + external.Provider + album *model.Album + artist *model.Artist +} + +func (f *fakeInfoProvider) UpdateAlbumInfo(context.Context, string) (*model.Album, error) { + return f.album, nil +} + +func (f *fakeInfoProvider) UpdateArtistInfo(context.Context, string, int, bool) (*model.Artist, error) { + return f.artist, nil +} diff --git a/server/subsonic/e2e/e2e_suite_test.go b/server/subsonic/e2e/e2e_suite_test.go index 97d7fb40a..8998731a4 100644 --- a/server/subsonic/e2e/e2e_suite_test.go +++ b/server/subsonic/e2e/e2e_suite_test.go @@ -311,12 +311,12 @@ func parseJSONResponse(w *httptest.ResponseRecorder) *responses.Subsonic { // noopArtwork implements artwork.Artwork type noopArtwork struct{} -func (n noopArtwork) Get(context.Context, model.ArtworkID, int, bool) (io.ReadCloser, time.Time, error) { - return nil, time.Time{}, model.ErrNotFound +func (n noopArtwork) Get(context.Context, model.ArtworkID, int, bool) (*artwork.Image, error) { + return nil, model.ErrNotFound } -func (n noopArtwork) GetOrPlaceholder(_ context.Context, _ string, _ int, _ bool) (io.ReadCloser, time.Time, error) { - return io.NopCloser(io.LimitReader(nil, 0)), time.Time{}, nil +func (n noopArtwork) GetOrPlaceholder(_ context.Context, _ string, _ int, _ bool) (*artwork.Image, error) { + return &artwork.Image{ReadCloser: io.NopCloser(io.LimitReader(nil, 0))}, nil } // noopArchiver implements core.Archiver @@ -357,14 +357,6 @@ func (n noopProvider) TopSongs(context.Context, string, string, int) (model.Medi return nil, nil } -func (n noopProvider) ArtistImage(context.Context, string) (*url.URL, error) { - return nil, model.ErrNotFound -} - -func (n noopProvider) AlbumImage(context.Context, string) (*url.URL, error) { - return nil, model.ErrNotFound -} - // Compile-time interface checks var ( _ artwork.Artwork = noopArtwork{} @@ -416,8 +408,8 @@ func setupTestDB() { // Create the Subsonic Router with real DS, streamer spy, and real Decider 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()) + s := scanner.New(ctx, ds, events.NoopBroker(), + playlists.NewPlaylists(ds, artwork.NewUploader(ds)), metrics.NewNoopInstance()) router = subsonic.New( ds, noopArtwork{}, @@ -427,7 +419,7 @@ func setupTestDB() { noopProvider{}, s, events.NoopBroker(), - playlists.NewPlaylists(ds, core.NewImageUploadService()), + playlists.NewPlaylists(ds, artwork.NewUploader(ds)), scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil), core.NewShare(ds), playback.PlaybackServer(nil), diff --git a/server/subsonic/e2e/subsonic_artwork_test.go b/server/subsonic/e2e/subsonic_artwork_test.go new file mode 100644 index 000000000..619619810 --- /dev/null +++ b/server/subsonic/e2e/subsonic_artwork_test.go @@ -0,0 +1,328 @@ +package e2e + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "time" + + "github.com/Masterminds/squirrel" + _ "github.com/navidrome/navidrome/adapters/gotaglib" // registers the "taglib" local-storage extractor + "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/agents" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/core/lyrics" + "github.com/navidrome/navidrome/core/metrics" + "github.com/navidrome/navidrome/core/playback" + "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/core/scrobbler" + _ "github.com/navidrome/navidrome/core/storage/local" // registers the "file" storage scheme + "github.com/navidrome/navidrome/core/stream" + "github.com/navidrome/navidrome/db" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/persistence" + "github.com/navidrome/navidrome/resources" + "github.com/navidrome/navidrome/scanner" + "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/server/public" + "github.com/navidrome/navidrome/server/subsonic" + "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/tests/harness" + "github.com/navidrome/navidrome/utils/cache" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.senan.xyz/taglib" +) + +// The serving path streams folder-backed originals via os.Open, which a fake FS cannot back, so +// this suite scans a small REAL on-disk library and drives the real worker and artwork.Artwork. +var _ = Describe("Artwork Serving", Ordered, func() { + var ( + artRouter *subsonic.Router + pubRouter *public.Router + artSvc artwork.Artwork + worker *artwork.Worker + artfulID string + artlessID string + artfulHash string + placeholder []byte + ) + + albumCoverInList := func(albumID string) string { + GinkgoHelper() + resp := doReq("getAlbumList2", "type", "alphabeticalByName", "size", "500") + Expect(resp.AlbumList2).ToNot(BeNil()) + for _, al := range resp.AlbumList2.Album { + if al.Id == albumID { + return al.CoverArt + } + } + Fail("album " + albumID + " not found in getAlbumList2") + return "" + } + + getCover := func(params ...string) *httptest.ResponseRecorder { + GinkgoHelper() + w := httptest.NewRecorder() + r := buildReq(adminUser, "getCoverArt", params...) + artRouter.ServeHTTP(w, r) + return w + } + + BeforeAll(func() { + DeferCleanup(configtest.SetupConfig()) + DeferCleanup(func() { Eventually(scanner.IsScanning).Should(BeFalse()) }) + ctx = request.WithUser(GinkgoT().Context(), adminUser) + + musicDir := GinkgoT().TempDir() + writeArtworkTrack(musicDir, "Artful Artist", "Artful Album", "01 - Come Together", true) + writeArtworkTrack(musicDir, "Artless Artist", "Artless Album", "01 - Lonely", false) + + conf.Server.MusicFolder = musicDir + conf.Server.DevExternalScanner = false + conf.Server.DevEnableMediaFileProbe = false + conf.Server.CoverArtPriority = "cover.jpg" + conf.Server.ArtistArtPriority = "artist.png" // offline: artists resolve absent, out of scope here + conf.Server.EnableMediaFileCoverArt = false + conf.Server.DevArtworkWorkerConcurrency = 1 + conf.Server.CacheFolder = conf.NewDir(GinkgoT().TempDir()) + conf.Server.EnableSharing = true + conf.Server.DevArtworkMaxRequests = 100 + conf.Server.DevArtworkThrottleBacklogLimit = 100 + conf.Server.DevArtworkThrottleBacklogTimeout = time.Minute + + goldenDB.Restore() + ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} + auth.Init(ds) + + // Re-scanning already-populated data deadlocks the parallel album-refresh phase at the + // harness's MaxOpenConns=1, so wipe the golden content and import this library fresh. + wipeScannedContent() + artLib := model.Library{Name: "Artwork Library", Path: musicDir} + Expect(ds.Library(ctx).Put(&artLib)).To(Succeed()) + Expect(ds.User(ctx).SetUserLibraries(adminUser.ID, []int{artLib.ID})).To(Succeed()) + + s := scanner.New(ctx, ds, events.NoopBroker(), + playlists.NewPlaylists(ds, artwork.NewUploader(ds)), metrics.NewNoopInstance()) + _, err := s.ScanAll(ctx, true) + Expect(err).ToNot(HaveOccurred()) + + // Focus the worker on the entities under test. + _, err = db.Db().Exec("DELETE FROM artwork_queue") + Expect(err).ToNot(HaveOccurred()) + + artfulID = albumIDByName("Artful Album") + artlessID = albumIDByName("Artless Album") + + ph, err := resources.FS().Open(consts.PlaceholderAlbumArt) + Expect(err).ToNot(HaveOccurred()) + placeholder, err = io.ReadAll(ph) + Expect(err).ToNot(HaveOccurred()) + _ = ph.Close() + + store := artwork.NewImageStore(GinkgoT().TempDir()) + imgCache := newDummyImageCache(ctx) + ffm := harness.NoopFFmpeg{} + artSvc = artwork.NewArtwork(ds, imgCache, store, ffm) + worker = artwork.NewWorker(ds, store, agents.GetAgents(ds, nil), ffm, events.NoopBroker(), imgCache) + + artRouter = buildArtworkRouter(artSvc) + router = artRouter // so the shared doReq/doRawReq helpers hit the artwork-wired router + pubRouter = public.New(ds, artSvc, streamerSpy, core.NewShare(ds), noopArchiver{}) + }) + + It("emits a bare optimistic coverArt id before the queue is drained", func() { + Expect(albumCoverInList(artfulID)).To(Equal("al-" + artfulID)) + Expect(albumCoverInList(artlessID)).To(Equal("al-" + artlessID)) + }) + + It("drains the queue: folder art is acquired, the artless album settles absent", func() { + // Enqueues the way the serving paths do, so the drain is driven by a plain queue row. + for _, id := range []string{artfulID, artlessID} { + Expect(ds.ArtworkQueue(ctx).EnqueuePreservingBackoff(model.ArtworkQueueItem{ + ItemKind: model.KindAlbumArtwork.Prefix(), ItemID: id, + ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBump, + })).To(Succeed()) + } + runWorkerUntil(ctx, worker, func() bool { + found, err := ds.Artwork(ctx).GetItemArtwork(model.KindAlbumArtwork, artfulID, model.ImageTypePrimary) + if err != nil || found.Hash == "" { + return false + } + absent, err := ds.Artwork(ctx).GetItemArtwork(model.KindAlbumArtwork, artlessID, model.ImageTypePrimary) + return err == nil && absent.Hash == "" + }) + ia, err := ds.Artwork(ctx).GetItemArtwork(model.KindAlbumArtwork, artfulID, model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(ia.Source).To(Equal("folder")) + artfulHash = ia.Hash + Expect(artfulHash).ToNot(BeEmpty()) + }) + + It("promotes the list id to the hash suffix and omits the absent album's coverArt", func() { + Expect(albumCoverInList(artfulID)).To(Equal("al-" + artfulID + "_" + artfulHash)) + Expect(albumCoverInList(artlessID)).To(BeEmpty()) + }) + + It("serves the found album's bytes with revalidation headers for a bare id", func() { + w := getCover("id", "al-"+artfulID) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.Bytes()).To(Equal(readArtworkFixture("cover.jpg"))) + Expect(w.Header().Get("ETag")).To(Equal(`"` + artfulHash + `"`)) + Expect(w.Header().Get("Cache-Control")).To(Equal("public, no-cache")) + }) + + It("serves the found album immutably when the exact hash is requested", func() { + w := getCover("id", "al-"+artfulID+"_"+artfulHash) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.Bytes()).To(Equal(readArtworkFixture("cover.jpg"))) + Expect(w.Header().Get("Cache-Control")).To(Equal("public, max-age=31536000, immutable")) + }) + + It("returns 304 with an empty body when the client already holds the hash", func() { + w := httptest.NewRecorder() + r := buildReq(adminUser, "getCoverArt", "id", "al-"+artfulID) + r.Header.Set("If-None-Match", `"`+artfulHash+`"`) + artRouter.ServeHTTP(w, r) + Expect(w.Code).To(Equal(http.StatusNotModified)) + Expect(w.Body.Bytes()).To(BeEmpty()) + }) + + It("serves the placeholder with no-store for an absent album", func() { + w := getCover("id", "al-"+artlessID) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.Bytes()).To(Equal(placeholder)) + Expect(w.Header().Get("Cache-Control")).To(Equal("no-store")) + }) + + // Only adminUser was granted this library, so regularUser must get the placeholder even + // though the artwork state and bytes are perfectly servable by id. + It("serves the placeholder for an album in a library the caller cannot see", func() { + w := httptest.NewRecorder() + artRouter.ServeHTTP(w, buildReq(regularUser, "getCoverArt", "id", "al-"+artfulID)) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.Bytes()).To(Equal(placeholder), "must not leak the real cover") + Expect(w.Header().Get("Cache-Control")).To(Equal("no-store")) + + Expect(getCover("id", "al-"+artfulID).Body.Bytes()).ToNot(Equal(placeholder)) + }) + + // An id naming no entity is a different answer from an album with no art: no placeholder. + It("answers error 70 for an id that matches no entity", func() { + w := getCover("id", "al-nosuchalbum") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.String()).To(ContainSubstring(`"code":70`)) + Expect(w.Body.Bytes()).ToNot(Equal(placeholder)) + }) + + It("serves /share/img immutably for a JWT whose payload carries the hash", func() { + token, err := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), + auth.Claims{ID: "al-" + artfulID + "_" + artfulHash}) + Expect(err).ToNot(HaveOccurred()) + + w := httptest.NewRecorder() + pubRouter.ServeHTTP(w, httptest.NewRequest("GET", "/img/"+token, nil)) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.Bytes()).To(Equal(readArtworkFixture("cover.jpg"))) + Expect(w.Header().Get("Cache-Control")).To(Equal("public, max-age=31536000, immutable")) + }) + + It("returns 404 from /share/img for an absent entity", func() { + token, err := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), + auth.Claims{ID: "al-" + artlessID}) + Expect(err).ToNot(HaveOccurred()) + + w := httptest.NewRecorder() + pubRouter.ServeHTTP(w, httptest.NewRequest("GET", "/img/"+token, nil)) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) +}) + +// buildArtworkRouter mirrors setupTestDB's Subsonic wiring but with the real artwork.Artwork. +func buildArtworkRouter(art artwork.Artwork) *subsonic.Router { + decider := stream.NewTranscodeDecider(ds, harness.NoopFFmpeg{}) + s := scanner.New(ctx, ds, events.NoopBroker(), + playlists.NewPlaylists(ds, artwork.NewUploader(ds)), metrics.NewNoopInstance()) + return subsonic.New( + ds, art, streamerSpy, noopArchiver{}, core.NewPlayers(ds), noopProvider{}, s, + events.NoopBroker(), playlists.NewPlaylists(ds, artwork.NewUploader(ds)), + scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil), core.NewShare(ds), + playback.PlaybackServer(nil), metrics.NewNoopInstance(), lyrics.NewLyrics(ds, nil), decider, nil, + ) +} + +// wipeScannedContent clears all scanned content so the next scan is a clean import. FKs are +// disabled for the bulk delete, mirroring harness.Restore. +func wipeScannedContent() { + GinkgoHelper() + _, err := db.Db().Exec("PRAGMA foreign_keys = OFF") + Expect(err).ToNot(HaveOccurred()) + for _, t := range []string{"media_file", "album", "artist", "folder", "item_artwork", "artwork_queue", "artwork", "library"} { + _, err = db.Db().Exec(`DELETE FROM "` + t + `"`) + Expect(err).ToNot(HaveOccurred(), "wiping %s", t) + } + _, err = db.Db().Exec("PRAGMA foreign_keys = ON") + Expect(err).ToNot(HaveOccurred()) +} + +func albumIDByName(name string) string { + GinkgoHelper() + albums, err := ds.Album(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"album.name": name}}) + Expect(err).ToNot(HaveOccurred()) + Expect(albums).To(HaveLen(1), "expected exactly one album named %q", name) + return albums[0].ID +} + +// Distinct ALBUM/ALBUMARTIST tags keep the generated albums from merging into one. +func writeArtworkTrack(root, artist, album, title string, withCover bool) { + GinkgoHelper() + dir := filepath.Join(root, artist, album) + Expect(os.MkdirAll(dir, 0o755)).To(Succeed()) + mp3 := filepath.Join(dir, title+".mp3") + Expect(os.WriteFile(mp3, readArtworkFixture("test.mp3"), 0o600)).To(Succeed()) + Expect(taglib.WriteTags(mp3, map[string][]string{ + "ALBUM": {album}, "ALBUMARTIST": {artist}, "ARTIST": {artist}, "TITLE": {title}, + }, taglib.Clear)).To(Succeed()) + if withCover { + Expect(os.WriteFile(filepath.Join(dir, "cover.jpg"), readArtworkFixture("cover.jpg"), 0o600)).To(Succeed()) + } +} + +func readArtworkFixture(name string) []byte { + GinkgoHelper() + data, err := os.ReadFile(filepath.Join("tests", "fixtures", "artist", "an-album", name)) + Expect(err).ToNot(HaveOccurred()) + return data +} + +// size=0 requests stream originals and never invoke the reader, so this resize cache only has +// to satisfy the constructor. +func newDummyImageCache(ctx context.Context) cache.FileCache { + GinkgoHelper() + c := cache.NewFileCache("SubsonicArtworkE2E", "100MB", "images", 0, + func(context.Context, cache.Item) (io.Reader, error) { + return nil, errors.New("resize not exercised in subsonic artwork e2e") + }) + Eventually(func() bool { return c.Available(ctx) }).Should(BeTrue()) + return c +} + +func runWorkerUntil(ctx context.Context, worker *artwork.Worker, until func() bool) { + GinkgoHelper() + runCtx, cancel := context.WithCancel(ctx) + done := make(chan error, 1) + go func() { done <- worker.Run(runCtx) }() + Eventually(until, 10*time.Second, 20*time.Millisecond).Should(BeTrue()) + cancel() + Eventually(done, 2*time.Second).Should(Receive(BeNil())) +} diff --git a/server/subsonic/e2e/subsonic_multilibrary_test.go b/server/subsonic/e2e/subsonic_multilibrary_test.go index e652cf492..98f87ca17 100644 --- a/server/subsonic/e2e/subsonic_multilibrary_test.go +++ b/server/subsonic/e2e/subsonic_multilibrary_test.go @@ -6,7 +6,6 @@ import ( "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playlists" @@ -53,8 +52,8 @@ var _ = Describe("Multi-Library Support", Ordered, func() { adminWithLibs = *loadedAdmin // Run incremental scan to import lib2 content (lib1 files unchanged → skipped) - s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), - playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) + s := scanner.New(ctx, ds, events.NoopBroker(), + playlists.NewPlaylists(ds, artwork.NewUploader(ds)), metrics.NewNoopInstance()) _, err = s.ScanAll(ctx, false) Expect(err).ToNot(HaveOccurred()) diff --git a/server/subsonic/e2e/subsonic_sonic_similarity_test.go b/server/subsonic/e2e/subsonic_sonic_similarity_test.go index 775fefe89..c0cb1d359 100644 --- a/server/subsonic/e2e/subsonic_sonic_similarity_test.go +++ b/server/subsonic/e2e/subsonic_sonic_similarity_test.go @@ -9,6 +9,7 @@ import ( "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/core/matcher" "github.com/navidrome/navidrome/core/metrics" @@ -43,7 +44,7 @@ func buildSonicRouter(provider sonic.Provider) *subsonic.Router { noopProvider{}, nil, // scanner events.NoopBroker(), - playlists.NewPlaylists(ds, core.NewImageUploadService()), + playlists.NewPlaylists(ds, artwork.NewUploader(ds)), scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil), core.NewShare(ds), playback.PlaybackServer(nil), diff --git a/server/subsonic/helpers.go b/server/subsonic/helpers.go index b986200ae..e367cdee5 100644 --- a/server/subsonic/helpers.go +++ b/server/subsonic/helpers.go @@ -95,13 +95,24 @@ func getArtistAlbumCount(a *model.Artist) int32 { } } +// coverArtOrEmpty stamps the (hash-suffixed) coverArt id, or omits it (empty +// string + the field's omitempty tag) when artwork is known absent. +func coverArtOrEmpty(id model.ArtworkID, absent bool) string { + if absent { + return "" + } + return id.String() +} + func toArtist(r *http.Request, a model.Artist) responses.Artist { artist := responses.Artist{ - Id: a.ID, - Name: a.Name, - UserRating: int32(a.Rating), - CoverArt: a.CoverArtID().String(), - ArtistImageUrl: publicurl.ImageURL(r, a.CoverArtID(), 600), + Id: a.ID, + Name: a.Name, + UserRating: int32(a.Rating), + CoverArt: coverArtOrEmpty(a.CoverArtID(), a.ImageAbsent), + } + if !a.ImageAbsent { + artist.ArtistImageUrl = publicurl.ImageURL(r, a.CoverArtID(), 600) } if conf.Server.Subsonic.EnableAverageRating { artist.AverageRating = a.AverageRating @@ -114,12 +125,14 @@ func toArtist(r *http.Request, a model.Artist) responses.Artist { func toArtistID3(r *http.Request, a model.Artist) responses.ArtistID3 { artist := responses.ArtistID3{ - Id: a.ID, - Name: a.Name, - AlbumCount: getArtistAlbumCount(&a), - CoverArt: a.CoverArtID().String(), - ArtistImageUrl: publicurl.ImageURL(r, a.CoverArtID(), 600), - UserRating: int32(a.Rating), + Id: a.ID, + Name: a.Name, + AlbumCount: getArtistAlbumCount(&a), + CoverArt: coverArtOrEmpty(a.CoverArtID(), a.ImageAbsent), + UserRating: int32(a.Rating), + } + if !a.ImageAbsent { + artist.ArtistImageUrl = publicurl.ImageURL(r, a.CoverArtID(), 600) } if conf.Server.Subsonic.EnableAverageRating { artist.AverageRating = a.AverageRating @@ -208,7 +221,7 @@ func childFromMediaFile(ctx context.Context, mf model.MediaFile) responses.Child child.Size = mf.Size child.Suffix = mf.Suffix child.BitRate = int32(mf.BitRate) - child.CoverArt = mf.CoverArtID().String() + child.CoverArt = coverArtOrEmpty(mf.CoverArtID(), mf.ImageAbsent) child.ContentType = mf.ContentType() if ok && player.ReportRealPath { @@ -369,7 +382,7 @@ func childFromAlbum(ctx context.Context, al model.Album) responses.Child { child.Artist = al.AlbumArtist child.Year = int32(cmp.Or(al.MaxOriginalYear, al.MaxYear)) child.Genre = al.Genre - child.CoverArt = al.CoverArtID().String() + child.CoverArt = coverArtOrEmpty(al.CoverArtID(), al.ImageAbsent) child.Created = new(albumCreatedAt(al)) child.Parent = al.AlbumArtistID child.ArtistId = al.AlbumArtistID @@ -460,7 +473,7 @@ func buildAlbumID3(ctx context.Context, album model.Album) responses.AlbumID3 { dir.Name = album.FullName() dir.Artist = album.AlbumArtist dir.ArtistId = album.AlbumArtistID - dir.CoverArt = album.CoverArtID().String() + dir.CoverArt = coverArtOrEmpty(album.CoverArtID(), album.ImageAbsent) dir.SongCount = int32(album.SongCount) dir.Duration = int32(album.Duration) dir.PlayCount = album.PlayCount diff --git a/server/subsonic/helpers_test.go b/server/subsonic/helpers_test.go index 8db624649..69fda4681 100644 --- a/server/subsonic/helpers_test.go +++ b/server/subsonic/helpers_test.go @@ -321,6 +321,82 @@ var _ = Describe("helpers", func() { }) }) + Describe("artwork coverArt emission", func() { + const hash = "0123456789abcdef" + ctx := context.Background() + + DescribeTable("coverArtOrEmpty", + func(id model.ArtworkID, absent bool, expected string) { + Expect(coverArtOrEmpty(id, absent)).To(Equal(expected)) + }, + Entry("emits the bare id when unresolved", model.ArtworkID{Kind: model.KindAlbumArtwork, ID: "1"}, false, "al-1"), + Entry("emits the hash-suffixed id when resolved", model.ArtworkID{Kind: model.KindAlbumArtwork, ID: "1", Hash: hash}, false, "al-1_"+hash), + Entry("omits (empty) when known absent", model.ArtworkID{Kind: model.KindAlbumArtwork, ID: "1", Hash: hash}, true, ""), + ) + + Describe("childFromAlbum", func() { + It("suffixes coverArt with the content hash when resolved", func() { + al := model.Album{ID: "al-1", ItemImage: model.ItemImage{ImageHash: hash}} + Expect(childFromAlbum(ctx, al).CoverArt).To(Equal("al-al-1_" + hash)) + }) + It("omits coverArt when known absent", func() { + al := model.Album{ID: "al-1", ItemImage: model.ItemImage{ImageAbsent: true}} + Expect(childFromAlbum(ctx, al).CoverArt).To(BeEmpty()) + }) + It("emits the bare id when unresolved", func() { + al := model.Album{ID: "al-1"} + Expect(childFromAlbum(ctx, al).CoverArt).To(Equal("al-al-1")) + }) + }) + + Describe("childFromMediaFile", func() { + // An al- id must carry the album's hash (AlbumImage), never the track's own. + It("suffixes coverArt with the album's content hash when resolved", func() { + mf := model.MediaFile{ID: "mf-1", AlbumID: "al-1", AlbumImage: model.ItemImage{ImageHash: hash}} + Expect(childFromMediaFile(ctx, mf).CoverArt).To(Equal("al-al-1_" + hash)) + }) + + It("does not stamp a track's own art hash onto the album id", func() { + mf := model.MediaFile{ID: "mf-1", AlbumID: "al-1", + ItemImage: model.ItemImage{ImageHash: "0000ownarthash00"}, + AlbumImage: model.ItemImage{ImageHash: hash}} + Expect(childFromMediaFile(ctx, mf).CoverArt).To(Equal("al-al-1_" + hash)) + }) + It("omits coverArt when known absent", func() { + mf := model.MediaFile{ID: "mf-1", AlbumID: "al-1", ItemImage: model.ItemImage{ImageAbsent: true}} + Expect(childFromMediaFile(ctx, mf).CoverArt).To(BeEmpty()) + }) + It("emits the bare id when unresolved", func() { + mf := model.MediaFile{ID: "mf-1", AlbumID: "al-1"} + Expect(childFromMediaFile(ctx, mf).CoverArt).To(Equal("al-al-1")) + }) + }) + + Describe("toArtist / toArtistID3", func() { + r := httptest.NewRequest("GET", "/test", nil) + It("emits both coverArt and imageUrl when resolved", func() { + a := model.Artist{ID: "ar-1", ItemImage: model.ItemImage{ImageHash: hash}} + artist := toArtist(r, a) + Expect(artist.CoverArt).To(Equal("ar-ar-1_" + hash)) + Expect(artist.ArtistImageUrl).ToNot(BeEmpty()) + }) + It("omits coverArt and imageUrl when known absent", func() { + a := model.Artist{ID: "ar-1", ItemImage: model.ItemImage{ImageAbsent: true}} + artist := toArtist(r, a) + Expect(artist.CoverArt).To(BeEmpty()) + Expect(artist.ArtistImageUrl).To(BeEmpty()) + + id3 := toArtistID3(r, a) + Expect(id3.CoverArt).To(BeEmpty()) + Expect(id3.ArtistImageUrl).To(BeEmpty()) + }) + It("emits an imageUrl when unresolved", func() { + a := model.Artist{ID: "ar-1"} + Expect(toArtist(r, a).ArtistImageUrl).ToNot(BeEmpty()) + }) + }) + }) + Describe("osChildFromMediaFile", func() { var mf model.MediaFile var ctx context.Context diff --git a/server/subsonic/media_retrieval.go b/server/subsonic/media_retrieval.go index 089a1fdda..8a5152a9d 100644 --- a/server/subsonic/media_retrieval.go +++ b/server/subsonic/media_retrieval.go @@ -13,6 +13,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/resources" + "github.com/navidrome/navidrome/server/imghttp" "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/utils/gravatar" "github.com/navidrome/navidrome/utils/req" @@ -66,7 +67,7 @@ func (api *Router) GetCoverArt(w http.ResponseWriter, r *http.Request) (*respons size := p.IntOr("size", 0) square := p.BoolOr("square", false) - imgReader, lastUpdate, err := api.artwork.GetOrPlaceholder(ctx, id, size, square) + img, err := api.artwork.GetOrPlaceholder(ctx, id, size, square) switch { case errors.Is(err, context.Canceled): return nil, nil @@ -78,11 +79,13 @@ func (api *Router) GetCoverArt(w http.ResponseWriter, r *http.Request) (*respons return nil, err } - defer imgReader.Close() - w.Header().Set("cache-control", "public, max-age=315360000") - w.Header().Set("last-modified", lastUpdate.Format(http.TimeFormat)) + defer img.Close() - cnt, err := io.Copy(w, imgReader) + artID, _ := model.ParseArtworkID(id) + if imghttp.WriteImageHeaders(w, r, img, artID.Hash) { + return nil, nil + } + cnt, err := io.Copy(w, img) if err != nil { log.Warn(ctx, "Error sending image", "count", cnt, err) } diff --git a/server/subsonic/media_retrieval_test.go b/server/subsonic/media_retrieval_test.go index 9331dfbe4..7610c866a 100644 --- a/server/subsonic/media_retrieval_test.go +++ b/server/subsonic/media_retrieval_test.go @@ -17,6 +17,7 @@ import ( "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/lyrics" "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" @@ -30,8 +31,14 @@ var _ = Describe("MediaRetrievalController", func() { var w *httptest.ResponseRecorder BeforeEach(func() { + albumRepo := &tests.MockAlbumRepo{} + albumRepo.SetData(model.Albums{{ID: "34"}}) // the id the specs request, made accessible + radioRepo := tests.CreateMockedRadioRepo() + Expect(radioRepo.Put(&model.Radio{ID: "rd1", Name: "Radio"})).To(Succeed()) ds = &tests.MockDataStore{ MockedMediaFile: mockRepo, + MockedAlbum: albumRepo, + MockedRadio: radioRepo, } artwork = &fakeArtwork{data: "image data"} router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(ds, nil), nil, nil) @@ -60,6 +67,22 @@ var _ = Describe("MediaRetrievalController", func() { Expect(w.Body.String()).To(Equal(artwork.data)) }) + // The service applies the library filter from the caller's context, so elevating here + // would bypass it. + It("passes the caller's context to the service rather than elevating", func() { + r := newGetRequest("id=al-34") + usr := model.User{ID: "u1", UserName: "u1"} + r = r.WithContext(request.WithUser(r.Context(), usr)) + + _, err := router.GetCoverArt(w, r) + + Expect(err).ToNot(HaveOccurred()) + got, ok := request.UserFrom(artwork.recvCtx) + Expect(ok).To(BeTrue(), "the service must see who is asking") + Expect(got.ID).To(Equal("u1")) + Expect(got.IsAdmin).To(BeFalse(), "the handler must not elevate") + }) + It("should fail when the file is not found", func() { artwork.err = model.ErrNotFound r := newGetRequest("id=34", "size=128", "square=true") @@ -108,6 +131,53 @@ var _ = Describe("MediaRetrievalController", func() { Expect(w.Body.String()).To(BeEmpty()) }) }) + + Describe("caching headers", func() { + const hash = "0123456789abcdef" + + It("sets an ETag and no-cache for a bare id", func() { + artwork.hash = hash + r := newGetRequest("id=al-34") + _, err := router.GetCoverArt(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(w.Header().Get("ETag")).To(Equal(`"` + hash + `"`)) + Expect(w.Header().Get("Cache-Control")).To(Equal("public, no-cache")) + Expect(w.Body.String()).To(Equal(artwork.data)) + }) + + It("marks the response immutable when the id asserts the current hash", func() { + artwork.hash = hash + r := newGetRequest("id=al-34_" + hash) + _, err := router.GetCoverArt(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(w.Header().Get("Cache-Control")).To(Equal("public, max-age=31536000, immutable")) + }) + + It("returns 304 with no body when If-None-Match matches", func() { + artwork.hash = hash + r := newGetRequest("id=al-34") + r.Header.Set("If-None-Match", `"`+hash+`"`) + _, err := router.GetCoverArt(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(w.Code).To(Equal(304)) + Expect(w.Body.Len()).To(BeZero()) + }) + + It("never caches a placeholder", func() { + artwork.placeholder = true + r := newGetRequest("id=al-missing") + _, err := router.GetCoverArt(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(w.Code).To(Equal(200)) + Expect(w.Header().Get("Cache-Control")).To(Equal("no-store")) + Expect(w.Header().Get("ETag")).To(BeEmpty()) + Expect(w.Body.String()).To(Equal(artwork.data)) + }) + }) }) Describe("GetLyrics", func() { @@ -188,25 +258,35 @@ var _ = Describe("MediaRetrievalController", func() { type fakeArtwork struct { artwork.Artwork data string + hash string + lastUpdated time.Time + placeholder bool err error ctxCancelFunc func() recvId string recvSize int recvSquare bool + recvCtx context.Context } -func (c *fakeArtwork) GetOrPlaceholder(_ context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error) { +func (c *fakeArtwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (*artwork.Image, error) { + c.recvCtx = ctx if c.err != nil { - return nil, time.Time{}, c.err + return nil, c.err } c.recvId = id c.recvSize = size c.recvSquare = square if c.ctxCancelFunc != nil { c.ctxCancelFunc() - return nil, time.Time{}, context.Canceled + return nil, context.Canceled } - return io.NopCloser(bytes.NewReader([]byte(c.data))), time.Time{}, nil + return &artwork.Image{ + ReadCloser: io.NopCloser(bytes.NewReader([]byte(c.data))), + Hash: c.hash, + LastUpdated: c.lastUpdated, + Placeholder: c.placeholder, + }, nil } type mockedMediaFile struct { diff --git a/server/subsonic/playlists.go b/server/subsonic/playlists.go index 17ba1b2c9..bd53528d9 100644 --- a/server/subsonic/playlists.go +++ b/server/subsonic/playlists.go @@ -151,7 +151,7 @@ func (api *Router) buildPlaylist(ctx context.Context, p model.Playlist) response pls.Comment = p.Comment pls.Owner = p.OwnerName pls.Public = p.Public - pls.CoverArt = p.CoverArtID().String() + pls.CoverArt = coverArtOrEmpty(p.CoverArtID(), p.ImageAbsent) pls.OpenSubsonicPlaylist = buildOSPlaylist(ctx, p) return pls diff --git a/server/subsonic/playlists_test.go b/server/subsonic/playlists_test.go index f0a2f8ac5..a7c9e2ec7 100644 --- a/server/subsonic/playlists_test.go +++ b/server/subsonic/playlists_test.go @@ -129,6 +129,19 @@ var _ = Describe("buildPlaylist", func() { }) }) + Context("artwork emission", func() { + It("suffixes coverArt with the content hash when resolved", func() { + playlist.ImageHash = "0123456789abcdef" + result := router.buildPlaylist(ctx, playlist) + Expect(result.CoverArt).To(Equal("pl-pls-1_0123456789abcdef")) + }) + It("omits coverArt when known absent", func() { + playlist.ImageAbsent = true + result := router.buildPlaylist(ctx, playlist) + Expect(result.CoverArt).To(BeEmpty()) + }) + }) + Context("with legacy client", func() { BeforeEach(func() { conf.Server.Subsonic.LegacyClients = "legacy-client" diff --git a/server/subsonic/radio.go b/server/subsonic/radio.go index 4fbd6a53d..1fb266f1d 100644 --- a/server/subsonic/radio.go +++ b/server/subsonic/radio.go @@ -74,10 +74,10 @@ func (api *Router) GetInternetRadios(r *http.Request) (*responses.Subsonic, erro if strings.Contains(conf.Server.Subsonic.LegacyClients, player.Client) { continue } - // Add coverArt if not legacy client + // Add coverArt if not legacy client; only radios have an uploaded image as their sole art source var coverArt string if g.UploadedImage != "" { - coverArt = g.CoverArtID().String() + coverArt = coverArtOrEmpty(g.CoverArtID(), g.ImageAbsent) } res[i].OpenSubsonicRadio = &responses.OpenSubsonicRadio{ CoverArt: coverArt, diff --git a/server/subsonic/radio_test.go b/server/subsonic/radio_test.go index e959ebe29..aa805abbf 100644 --- a/server/subsonic/radio_test.go +++ b/server/subsonic/radio_test.go @@ -71,10 +71,25 @@ var _ = Describe("Radio", func() { Expect(err).ToNot(HaveOccurred()) Expect(response.InternetRadioStations.Radios).To(HaveLen(2)) Expect(response.InternetRadioStations.Radios[0].OpenSubsonicRadio).ToNot(BeNil()) - Expect(response.InternetRadioStations.Radios[0].CoverArt).To(Equal("ra-rd-1_0")) + Expect(response.InternetRadioStations.Radios[0].CoverArt).To(Equal("ra-rd-1")) Expect(response.InternetRadioStations.Radios[1].OpenSubsonicRadio).ToNot(BeNil()) Expect(response.InternetRadioStations.Radios[1].CoverArt).To(BeEmpty()) }) + + It("suffixes coverArt with the content hash when resolved and omits it when known absent", func() { + radioRepo.All = model.Radios{ + {ID: "rd-1", Name: "Radio 1", UploadedImage: "rd-1_cover.jpg", ItemImage: model.ItemImage{ImageHash: "0123456789abcdef"}}, + {ID: "rd-2", Name: "Radio 2", UploadedImage: "rd-2_cover.jpg", ItemImage: model.ItemImage{ImageAbsent: true}}, + } + r := httptest.NewRequest("GET", "/rest/getInternetRadios", nil) + r = r.WithContext(ctx) + + response, err := api.GetInternetRadios(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(response.InternetRadioStations.Radios[0].CoverArt).To(Equal("ra-rd-1_0123456789abcdef")) + Expect(response.InternetRadioStations.Radios[1].CoverArt).To(BeEmpty()) + }) }) Context("with a legacy client", func() { @@ -129,7 +144,7 @@ var _ = Describe("Radio", func() { Expect(err).ToNot(HaveOccurred()) Expect(response.InternetRadioStations.Radios[0].OpenSubsonicRadio).ToNot(BeNil()) - Expect(response.InternetRadioStations.Radios[0].CoverArt).To(Equal("ra-rd-1_0")) + Expect(response.InternetRadioStations.Radios[0].CoverArt).To(Equal("ra-rd-1")) }) }) diff --git a/server/subsonic/searching.go b/server/subsonic/searching.go index 5d4989ae5..cc787f041 100644 --- a/server/subsonic/searching.go +++ b/server/subsonic/searching.go @@ -110,11 +110,13 @@ func (api *Router) Search2(r *http.Request) (*responses.Subsonic, error) { searchResult2 := &responses.SearchResult2{} searchResult2.Artist = slice.Map(as, func(artist model.Artist) responses.Artist { a := responses.Artist{ - Id: artist.ID, - Name: artist.Name, - UserRating: int32(artist.Rating), - CoverArt: artist.CoverArtID().String(), - ArtistImageUrl: publicurl.ImageURL(r, artist.CoverArtID(), 600), + Id: artist.ID, + Name: artist.Name, + UserRating: int32(artist.Rating), + CoverArt: coverArtOrEmpty(artist.CoverArtID(), artist.ImageAbsent), + } + if !artist.ImageAbsent { + a.ArtistImageUrl = publicurl.ImageURL(r, artist.CoverArtID(), 600) } if artist.Starred { a.Starred = artist.StarredAt diff --git a/server/subsonic/searching_test.go b/server/subsonic/searching_test.go index 4e72bd2e6..d31a50cfa 100644 --- a/server/subsonic/searching_test.go +++ b/server/subsonic/searching_test.go @@ -1,6 +1,8 @@ package subsonic import ( + "net/http" + "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/model" @@ -209,4 +211,32 @@ var _ = Describe("Search", func() { }) }) }) + + Describe("artwork emission", func() { + newSearchRequest := func() *http.Request { + r := newGetRequest("query=test") + ctx := request.WithUser(r.Context(), model.User{ID: "user1", Libraries: []model.Library{{ID: 1}}}) + return r.WithContext(ctx) + } + + It("omits coverArt and imageUrl for a known-absent artist", func() { + mockArtistRepo.SetData(model.Artists{ + {ID: "ar-1", Name: "Absent Artist", ItemImage: model.ItemImage{ImageAbsent: true}}, + }) + resp, err := router.Search2(newSearchRequest()) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.SearchResult2.Artist).To(HaveLen(1)) + Expect(resp.SearchResult2.Artist[0].CoverArt).To(BeEmpty()) + Expect(resp.SearchResult2.Artist[0].ArtistImageUrl).To(BeEmpty()) + }) + + It("emits coverArt and imageUrl for an unresolved artist", func() { + mockArtistRepo.SetData(model.Artists{{ID: "ar-1", Name: "Unresolved Artist"}}) + resp, err := router.Search2(newSearchRequest()) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.SearchResult2.Artist).To(HaveLen(1)) + Expect(resp.SearchResult2.Artist[0].CoverArt).To(Equal("ar-ar-1")) + Expect(resp.SearchResult2.Artist[0].ArtistImageUrl).ToNot(BeEmpty()) + }) + }) }) diff --git a/tests/harness/harness.go b/tests/harness/harness.go index ff5ce8919..5949c4fae 100644 --- a/tests/harness/harness.go +++ b/tests/harness/harness.go @@ -13,7 +13,6 @@ import ( "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" @@ -74,8 +73,8 @@ func SetupDB(ctx context.Context, users ...*model.User) *DB { u.Libraries = loaded.Libraries } - s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), - playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) + s := scanner.New(ctx, ds, events.NoopBroker(), + playlists.NewPlaylists(ds, artwork.NewUploader(ds)), metrics.NewNoopInstance()) _, err := s.ScanAll(ctx, true) Expect(err).ToNot(HaveOccurred()) @@ -87,17 +86,12 @@ func SetupDB(ctx context.Context, users ...*model.User) *DB { 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_%'") +// ResettableTables lists the tables a per-spec reset may write. FTS shadow tables are excluded: +// their content tables' triggers keep them in sync, and writing them directly corrupts the index. +func ResettableTables() []string { + rows, err := db.Db().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()) + defer rows.Close() var tables []string for rows.Next() { var name string @@ -105,12 +99,37 @@ func (h *DB) Restore() { tables = append(tables, name) } Expect(rows.Err()).ToNot(HaveOccurred()) - rows.Close() + return tables +} + +// TruncateDB empties every resettable table, leaving the migrated schema in place — for suites +// whose specs each build their own library, so the schema is not re-migrated per spec. +func TruncateDB(tables []string) { + sqlDB := db.Db() + _, err := sqlDB.Exec("PRAGMA foreign_keys = OFF") + Expect(err).ToNot(HaveOccurred()) + defer func() { _, _ = sqlDB.Exec("PRAGMA foreign_keys = ON") }() 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()) + } +} + +// Restore reloads every table from the golden snapshot via ATTACH DATABASE — much faster than a +// rescan. +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()) + + for _, table := range ResettableTables() { + // 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()) } diff --git a/tests/mock_album_repo.go b/tests/mock_album_repo.go index cc7d66b4d..ff6f9cff6 100644 --- a/tests/mock_album_repo.go +++ b/tests/mock_album_repo.go @@ -2,10 +2,12 @@ package tests import ( "errors" + "sync" "time" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" + "github.com/navidrome/navidrome/utils/slice" ) func CreateMockAlbumRepo() *MockAlbumRepo { @@ -20,6 +22,7 @@ type MockAlbumRepo struct { All model.Albums Err bool Options model.QueryOptions + optionsMu sync.Mutex SearchQuery string // last query passed to Search ReassignAnnotationCalls map[string]string // prevID -> newID CopyAttributesCalls map[string]string // fromID -> toID @@ -68,7 +71,10 @@ func (m *MockAlbumRepo) Put(al *model.Album) error { func (m *MockAlbumRepo) GetAll(qo ...model.QueryOptions) (model.Albums, error) { if len(qo) > 0 { + // Recording the last options is a read-path write, and callers resolve concurrently. + m.optionsMu.Lock() m.Options = qo[0] + m.optionsMu.Unlock() } if m.Err { return nil, errors.New("unexpected error") @@ -76,6 +82,14 @@ func (m *MockAlbumRepo) GetAll(qo ...model.QueryOptions) (model.Albums, error) { return m.All, nil } +func (m *MockAlbumRepo) GetAllIDs(qo ...model.QueryOptions) ([]string, error) { + all, err := m.GetAll(qo...) + if err != nil { + return nil, err + } + return slice.Map(all, func(a model.Album) string { return a.ID }), nil +} + func (m *MockAlbumRepo) GetCursor(qo ...model.QueryOptions) (model.AlbumCursor, error) { res, err := m.GetAll(qo...) if err != nil { diff --git a/tests/mock_artist_repo.go b/tests/mock_artist_repo.go index e6ea7aea4..9691a6584 100644 --- a/tests/mock_artist_repo.go +++ b/tests/mock_artist_repo.go @@ -6,6 +6,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" + "github.com/navidrome/navidrome/utils/slice" ) func CreateMockArtistRepo() *MockArtistRepo { @@ -113,6 +114,14 @@ func (m *MockArtistRepo) GetAll(options ...model.QueryOptions) (model.Artists, e return allArtists, nil } +func (m *MockArtistRepo) GetAllIDs(options ...model.QueryOptions) ([]string, error) { + all, err := m.GetAll(options...) + if err != nil { + return nil, err + } + return slice.Map(all, func(a model.Artist) string { return a.ID }), nil +} + func (m *MockArtistRepo) GetCursor(options ...model.QueryOptions) (model.ArtistCursor, error) { res, err := m.GetAll(options...) if err != nil { diff --git a/tests/mock_artwork_queue_repo.go b/tests/mock_artwork_queue_repo.go new file mode 100644 index 000000000..1b097ca32 --- /dev/null +++ b/tests/mock_artwork_queue_repo.go @@ -0,0 +1,258 @@ +package tests + +import ( + "cmp" + "slices" + "sync" + "time" + + "github.com/navidrome/navidrome/model" +) + +type MockArtworkQueueRepo struct { + model.ArtworkQueueRepository + // mu guards Data: the worker drains this mock concurrently. + mu sync.Mutex + Data map[string]model.ArtworkQueueItem // keyed by iaKey(kind, id, imageType) + Err error + // ItemArtworkSource, when set, backs EnqueueStaleAbsent with real item_artwork state. + ItemArtworkSource *MockArtworkRepo + // ExistingIDs is keyed by item_kind; a nil per-kind map means PurgeDangling keeps that kind. + ExistingIDs map[string]map[string]bool +} + +func CreateMockArtworkQueueRepo() *MockArtworkQueueRepo { + return &MockArtworkQueueRepo{Data: map[string]model.ArtworkQueueItem{}} +} + +func (m *MockArtworkQueueRepo) Enqueue(items ...model.ArtworkQueueItem) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return m.Err + } + m.enqueueLocked(items) + return nil +} + +func (m *MockArtworkQueueRepo) enqueueLocked(items []model.ArtworkQueueItem) { + now := time.Now() + for _, it := range items { + if it.ImageType == "" { + it.ImageType = model.ImageTypePrimary + } + k := iaKey(it.ItemKind, it.ItemID, it.ImageType) + // Mirror the SQL: retry_at/enqueued_at are server-set, never taken from the caller. + if prev, ok := m.Data[k]; ok { + prev.Priority = max(prev.Priority, it.Priority) + prev.RetryAt = now + prev.Attempts = 0 + prev.EnqueuedAt = now + m.Data[k] = prev + continue + } + it.Attempts = 0 + it.RetryAt = now + it.EnqueuedAt = now + m.Data[k] = it + } +} + +// EnqueueIfMissing mirrors the SQL anti-join: skip anything that already has an item_artwork row. +func (m *MockArtworkQueueRepo) EnqueueIfMissing(items ...model.ArtworkQueueItem) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return m.Err + } + var fresh []model.ArtworkQueueItem + for _, it := range items { + k := iaKey(it.ItemKind, it.ItemID, cmp.Or(it.ImageType, model.ImageTypePrimary)) + if m.ItemArtworkSource != nil { + if _, ok := m.ItemArtworkSource.ItemData[k]; ok { + continue + } + } + if _, ok := m.Data[k]; ok { // DO NOTHING + continue + } + fresh = append(fresh, it) + } + m.enqueueLocked(fresh) + return nil +} + +func (m *MockArtworkQueueRepo) DequeueBatch(n int, kinds ...string) ([]model.ArtworkQueueItem, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return nil, m.Err + } + var res []model.ArtworkQueueItem + now := time.Now() + for _, it := range m.Data { + if !it.RetryAt.After(now) && (len(kinds) == 0 || slices.Contains(kinds, it.ItemKind)) { + res = append(res, it) + } + } + slices.SortFunc(res, func(a, b model.ArtworkQueueItem) int { + return cmp.Or(cmp.Compare(b.Priority, a.Priority), a.EnqueuedAt.Compare(b.EnqueuedAt)) + }) + if len(res) > n { + res = res[:n] + } + return res, nil +} + +func (m *MockArtworkQueueRepo) MarkFailedIfUnchanged(kind, id, imageType string, seenRetryAt, retryAt time.Time) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return m.Err + } + k := iaKey(kind, id, imageType) + if it, ok := m.Data[k]; ok && it.RetryAt.Equal(seenRetryAt) { + it.Attempts++ + it.RetryAt = retryAt + m.Data[k] = it + } + return nil +} + +func (m *MockArtworkQueueRepo) DeleteIfUnchanged(kind, id, imageType string, retryAt time.Time) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return m.Err + } + k := iaKey(kind, id, imageType) + if it, ok := m.Data[k]; ok && it.RetryAt.Equal(retryAt) { + delete(m.Data, k) + } + return nil +} + +func (m *MockArtworkQueueRepo) PurgeDangling() (int64, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return 0, m.Err + } + var purged int64 + for k, it := range m.Data { + existing := m.ExistingIDs[it.ItemKind] + if existing == nil { + continue + } + if !existing[it.ItemID] { + delete(m.Data, k) + purged++ + } + } + return purged, nil +} + +func (m *MockArtworkQueueRepo) Count() (int64, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return 0, m.Err + } + return int64(len(m.Data)), nil +} + +func (m *MockArtworkQueueRepo) EnqueuePreservingBackoff(items ...model.ArtworkQueueItem) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return m.Err + } + now := time.Now() + for _, it := range items { + if it.ImageType == "" { + it.ImageType = model.ImageTypePrimary + } + k := iaKey(it.ItemKind, it.ItemID, it.ImageType) + // Preserve an existing row's retry_at: a bump raises priority without resetting backoff. + if prev, ok := m.Data[k]; ok { + prev.Priority = max(prev.Priority, it.Priority) + m.Data[k] = prev + continue + } + it.Attempts = 0 + it.RetryAt = now + it.EnqueuedAt = now + m.Data[k] = it + } + return nil +} + +func (m *MockArtworkQueueRepo) EnqueueStaleAbsent(kind model.Kind, attemptedBefore time.Time) (int64, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil || m.ItemArtworkSource == nil { + return 0, m.Err + } + now := time.Now() + var inserted int64 + for _, ia := range m.ItemArtworkSource.ItemData { + if ia.ItemKind != kind.Prefix() || ia.Hash != "" || !ia.AttemptedAt.Before(attemptedBefore) { + continue + } + k := iaKey(ia.ItemKind, ia.ItemID, ia.ImageType) + if _, ok := m.Data[k]; ok { // DO NOTHING: never touch existing queue rows + continue + } + m.Data[k] = model.ArtworkQueueItem{ + ItemKind: ia.ItemKind, + ItemID: ia.ItemID, + ImageType: ia.ImageType, + Priority: model.ArtworkPriorityRecheck, + RetryAt: now, + EnqueuedAt: now, + } + inserted++ + } + return inserted, nil +} + +// EnqueueMissing mirrors the SQL set-difference insert: ExistingIDs[kind] minus ItemArtworkSource. +func (m *MockArtworkQueueRepo) EnqueueAllMissing(kind model.Kind, priority int) (int64, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return 0, m.Err + } + hasRow := func(id string) bool { + if m.ItemArtworkSource == nil { + return false + } + for _, ia := range m.ItemArtworkSource.ItemData { + if ia.ItemKind == kind.Prefix() && ia.ItemID == id { + return true + } + } + return false + } + now := time.Now() + var inserted int64 + for id := range m.ExistingIDs[kind.Prefix()] { + if hasRow(id) { + continue + } + k := iaKey(kind.Prefix(), id, model.ImageTypePrimary) + if _, ok := m.Data[k]; ok { // DO NOTHING: never touch existing queue rows + continue + } + m.Data[k] = model.ArtworkQueueItem{ + ItemKind: kind.Prefix(), + ItemID: id, + ImageType: model.ImageTypePrimary, + Priority: priority, + RetryAt: now, + EnqueuedAt: now, + } + inserted++ + } + return inserted, nil +} diff --git a/tests/mock_artwork_repo.go b/tests/mock_artwork_repo.go new file mode 100644 index 000000000..2ace0daba --- /dev/null +++ b/tests/mock_artwork_repo.go @@ -0,0 +1,175 @@ +package tests + +import ( + "maps" + "sync" + "time" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/slice" +) + +type MockArtworkRepo struct { + model.ArtworkRepository + // mu guards the maps so the worker's concurrent drain can hit this mock race-free. + mu sync.Mutex + Data map[string]model.Artwork + ItemData map[string]model.ItemArtwork // keyed by iaKey(kind, id, imageType) + Err error + // ExistingIDs, keyed by item_kind, backs PurgeDanglingItems; nil map keeps everything. + ExistingIDs map[string]map[string]bool +} + +func CreateMockArtworkRepo() *MockArtworkRepo { + return &MockArtworkRepo{Data: map[string]model.Artwork{}, ItemData: map[string]model.ItemArtwork{}} +} + +func iaKey(kind, id, imageType string) string { return kind + "|" + id + "|" + imageType } + +func (m *MockArtworkRepo) GetImage(hash string) (*model.Artwork, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return nil, m.Err + } + if a, ok := m.Data[hash]; ok { + return &a, nil + } + return nil, model.ErrNotFound +} + +func (m *MockArtworkRepo) PutImage(a *model.Artwork) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return m.Err + } + // Mirrors the SQL repository: every upsert refreshes created_at. Age fixtures via Data directly. + a.CreatedAt = time.Now() + m.Data[a.Hash] = *a + return nil +} + +func (m *MockArtworkRepo) GetMimeByHash() (map[string]string, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return nil, m.Err + } + mimes := make(map[string]string, len(m.Data)) + for h, a := range m.Data { + mimes[h] = a.Mime + } + return mimes, nil +} + +func (m *MockArtworkRepo) PurgeDanglingItems() (int64, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return 0, m.Err + } + var purged int64 + for k, ia := range m.ItemData { + // A nil per-kind map means that kind isn't tracked by the test, so keep it. + existing := m.ExistingIDs[ia.ItemKind] + if existing == nil { + continue + } + if !existing[ia.ItemID] { + delete(m.ItemData, k) + purged++ + } + } + return purged, nil +} + +func (m *MockArtworkRepo) PurgeOrphans(createdBefore time.Time) (int64, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return 0, m.Err + } + var deleted int64 + for h, a := range m.Data { + if m.referenced(h) || !a.CreatedAt.Before(createdBefore) { + continue + } + delete(m.Data, h) + deleted++ + } + return deleted, nil +} + +func (m *MockArtworkRepo) referenced(hash string) bool { + for _, ia := range m.ItemData { + if ia.Hash == hash { + return true + } + } + return false +} + +func (m *MockArtworkRepo) GetItemArtwork(kind model.Kind, id, imageType string) (*model.ItemArtwork, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return nil, m.Err + } + if ia, ok := m.ItemData[iaKey(kind.Prefix(), id, imageType)]; ok { + return &ia, nil + } + return nil, model.ErrNotFound +} + +func (m *MockArtworkRepo) PutItemArtwork(ia *model.ItemArtwork) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return m.Err + } + if ia.ImageType == "" { + ia.ImageType = model.ImageTypePrimary + } + ia.UpdatedAt = time.Now() + if ia.AttemptedAt.IsZero() { + ia.AttemptedAt = ia.UpdatedAt + } + m.ItemData[iaKey(ia.ItemKind, ia.ItemID, ia.ImageType)] = *ia + return nil +} + +func (m *MockArtworkRepo) DeleteForItems(kind model.Kind, ids []string) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return m.Err + } + idSet := slice.ToSet(ids) + maps.DeleteFunc(m.ItemData, func(_ string, ia model.ItemArtwork) bool { + _, ok := idSet[ia.ItemID] + return ok && ia.ItemKind == kind.Prefix() + }) + return nil +} + +func (m *MockArtworkRepo) GetInfoForItems(kind model.Kind, ids []string) (map[string]model.ItemArtworkInfo, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return nil, m.Err + } + res := map[string]model.ItemArtworkInfo{} + for _, id := range ids { + if ia, ok := m.ItemData[iaKey(kind.Prefix(), id, model.ImageTypePrimary)]; ok { + info := model.ItemArtworkInfo{ItemID: id, Hash: ia.Hash} + if a, ok := m.Data[ia.Hash]; ok { + info.BlurHash, info.ThumbHash = a.BlurHash, a.ThumbHash + info.DominantColor = a.DominantColor + info.Width, info.Height = a.Width, a.Height + } + res[id] = info + } + } + return res, nil +} diff --git a/tests/mock_data_store.go b/tests/mock_data_store.go index e016a28de..32f56a4f0 100644 --- a/tests/mock_data_store.go +++ b/tests/mock_data_store.go @@ -28,6 +28,8 @@ type MockDataStore struct { MockedScrobble model.ScrobbleRepository MockedRadio model.RadioRepository MockedPlugin model.PluginRepository + MockedArtwork model.ArtworkRepository + MockedArtworkQueue model.ArtworkQueueRepository scrobbleBufferMu sync.Mutex repoMu sync.Mutex @@ -37,6 +39,8 @@ type MockDataStore struct { } func (db *MockDataStore) Library(ctx context.Context) model.LibraryRepository { + db.repoMu.Lock() + defer db.repoMu.Unlock() if db.MockedLibrary != nil { return db.MockedLibrary } @@ -48,6 +52,8 @@ func (db *MockDataStore) Library(ctx context.Context) model.LibraryRepository { } func (db *MockDataStore) Folder(ctx context.Context) model.FolderRepository { + db.repoMu.Lock() + defer db.repoMu.Unlock() if db.MockedFolder != nil { return db.MockedFolder } @@ -59,6 +65,8 @@ func (db *MockDataStore) Folder(ctx context.Context) model.FolderRepository { } func (db *MockDataStore) Tag(ctx context.Context) model.TagRepository { + db.repoMu.Lock() + defer db.repoMu.Unlock() if db.MockedTag != nil { return db.MockedTag } @@ -70,6 +78,8 @@ func (db *MockDataStore) Tag(ctx context.Context) model.TagRepository { } func (db *MockDataStore) Album(ctx context.Context) model.AlbumRepository { + db.repoMu.Lock() + defer db.repoMu.Unlock() if db.MockedAlbum != nil { return db.MockedAlbum } @@ -81,6 +91,8 @@ func (db *MockDataStore) Album(ctx context.Context) model.AlbumRepository { } func (db *MockDataStore) Artist(ctx context.Context) model.ArtistRepository { + db.repoMu.Lock() + defer db.repoMu.Unlock() if db.MockedArtist != nil { return db.MockedArtist } @@ -92,11 +104,11 @@ func (db *MockDataStore) Artist(ctx context.Context) model.ArtistRepository { } func (db *MockDataStore) MediaFile(ctx context.Context) model.MediaFileRepository { + db.repoMu.Lock() + defer db.repoMu.Unlock() if db.RealDS != nil && db.MockedMediaFile == nil { return db.RealDS.MediaFile(ctx) } - db.repoMu.Lock() - defer db.repoMu.Unlock() if db.MockedMediaFile == nil { db.MockedMediaFile = CreateMockMediaFileRepo() } @@ -104,6 +116,8 @@ func (db *MockDataStore) MediaFile(ctx context.Context) model.MediaFileRepositor } func (db *MockDataStore) Genre(ctx context.Context) model.GenreRepository { + db.repoMu.Lock() + defer db.repoMu.Unlock() if db.MockedGenre != nil { return db.MockedGenre } @@ -115,6 +129,8 @@ func (db *MockDataStore) Genre(ctx context.Context) model.GenreRepository { } func (db *MockDataStore) Playlist(ctx context.Context) model.PlaylistRepository { + db.repoMu.Lock() + defer db.repoMu.Unlock() if db.MockedPlaylist != nil { return db.MockedPlaylist } @@ -126,6 +142,8 @@ func (db *MockDataStore) Playlist(ctx context.Context) model.PlaylistRepository } func (db *MockDataStore) PlayQueue(ctx context.Context) model.PlayQueueRepository { + db.repoMu.Lock() + defer db.repoMu.Unlock() if db.MockedPlayQueue != nil { return db.MockedPlayQueue } @@ -137,6 +155,8 @@ func (db *MockDataStore) PlayQueue(ctx context.Context) model.PlayQueueRepositor } func (db *MockDataStore) UserProps(ctx context.Context) model.UserPropsRepository { + db.repoMu.Lock() + defer db.repoMu.Unlock() if db.MockedUserProps != nil { return db.MockedUserProps } @@ -148,6 +168,8 @@ func (db *MockDataStore) UserProps(ctx context.Context) model.UserPropsRepositor } func (db *MockDataStore) Property(ctx context.Context) model.PropertyRepository { + db.repoMu.Lock() + defer db.repoMu.Unlock() if db.MockedProperty != nil { return db.MockedProperty } @@ -159,6 +181,8 @@ func (db *MockDataStore) Property(ctx context.Context) model.PropertyRepository } func (db *MockDataStore) Share(ctx context.Context) model.ShareRepository { + db.repoMu.Lock() + defer db.repoMu.Unlock() if db.MockedShare != nil { return db.MockedShare } @@ -170,6 +194,8 @@ func (db *MockDataStore) Share(ctx context.Context) model.ShareRepository { } func (db *MockDataStore) User(ctx context.Context) model.UserRepository { + db.repoMu.Lock() + defer db.repoMu.Unlock() if db.MockedUser != nil { return db.MockedUser } @@ -181,6 +207,8 @@ func (db *MockDataStore) User(ctx context.Context) model.UserRepository { } func (db *MockDataStore) Transcoding(ctx context.Context) model.TranscodingRepository { + db.repoMu.Lock() + defer db.repoMu.Unlock() if db.MockedTranscoding != nil { return db.MockedTranscoding } @@ -192,6 +220,8 @@ func (db *MockDataStore) Transcoding(ctx context.Context) model.TranscodingRepos } func (db *MockDataStore) Player(ctx context.Context) model.PlayerRepository { + db.repoMu.Lock() + defer db.repoMu.Unlock() if db.MockedPlayer != nil { return db.MockedPlayer } @@ -203,6 +233,8 @@ func (db *MockDataStore) Player(ctx context.Context) model.PlayerRepository { } func (db *MockDataStore) ScrobbleBuffer(ctx context.Context) model.ScrobbleBufferRepository { + db.repoMu.Lock() + defer db.repoMu.Unlock() if db.RealDS != nil && db.MockedScrobbleBuffer == nil { return db.RealDS.ScrobbleBuffer(ctx) } @@ -215,6 +247,8 @@ func (db *MockDataStore) ScrobbleBuffer(ctx context.Context) model.ScrobbleBuffe } func (db *MockDataStore) Scrobble(ctx context.Context) model.ScrobbleRepository { + db.repoMu.Lock() + defer db.repoMu.Unlock() if db.MockedScrobble != nil { return db.MockedScrobble } @@ -226,6 +260,8 @@ func (db *MockDataStore) Scrobble(ctx context.Context) model.ScrobbleRepository } func (db *MockDataStore) Radio(ctx context.Context) model.RadioRepository { + db.repoMu.Lock() + defer db.repoMu.Unlock() if db.MockedRadio != nil { return db.MockedRadio } @@ -237,6 +273,8 @@ func (db *MockDataStore) Radio(ctx context.Context) model.RadioRepository { } func (db *MockDataStore) Plugin(ctx context.Context) model.PluginRepository { + db.repoMu.Lock() + defer db.repoMu.Unlock() if db.MockedPlugin != nil { return db.MockedPlugin } @@ -247,6 +285,42 @@ func (db *MockDataStore) Plugin(ctx context.Context) model.PluginRepository { return db.MockedPlugin } +func (db *MockDataStore) Artwork(ctx context.Context) model.ArtworkRepository { + db.repoMu.Lock() + defer db.repoMu.Unlock() + return db.artworkLocked(ctx) +} + +// artworkLocked is the body of Artwork for callers already holding repoMu; repoMu is a plain +// Mutex, so re-entering through the exported method would deadlock. +func (db *MockDataStore) artworkLocked(ctx context.Context) model.ArtworkRepository { + if db.MockedArtwork != nil { + return db.MockedArtwork + } + if db.RealDS != nil { + return db.RealDS.Artwork(ctx) + } + db.MockedArtwork = CreateMockArtworkRepo() + return db.MockedArtwork +} + +func (db *MockDataStore) ArtworkQueue(ctx context.Context) model.ArtworkQueueRepository { + db.repoMu.Lock() + defer db.repoMu.Unlock() + if db.MockedArtworkQueue != nil { + return db.MockedArtworkQueue + } + if db.RealDS != nil { + return db.RealDS.ArtworkQueue(ctx) + } + q := CreateMockArtworkQueueRepo() + if aw, ok := db.artworkLocked(ctx).(*MockArtworkRepo); ok { + q.ItemArtworkSource = aw + } + db.MockedArtworkQueue = q + return db.MockedArtworkQueue +} + func (db *MockDataStore) WithTx(block func(tx model.DataStore) error, label ...string) error { return block(db) } diff --git a/tests/mock_mediafile_repo.go b/tests/mock_mediafile_repo.go index 990b91d7c..d993af1a8 100644 --- a/tests/mock_mediafile_repo.go +++ b/tests/mock_mediafile_repo.go @@ -123,6 +123,18 @@ func (m *MockMediaFileRepo) GetCursor(qo ...model.QueryOptions) (model.MediaFile }, nil } +func (m *MockMediaFileRepo) GetCursorWithArtwork(qo ...model.QueryOptions) (model.MediaFileCursor, error) { + return m.GetCursor(qo...) +} + +func (m *MockMediaFileRepo) GetAllIDs(qo ...model.QueryOptions) ([]string, error) { + all, err := m.GetAll(qo...) + if err != nil { + return nil, err + } + return slice.Map(all, func(mf model.MediaFile) string { return mf.ID }), 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 8f8842c8e..ee645e984 100644 --- a/tests/mock_playlist_repo.go +++ b/tests/mock_playlist_repo.go @@ -7,6 +7,7 @@ import ( "github.com/deluan/rest" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" + "github.com/navidrome/navidrome/utils/slice" ) func CreateMockPlaylistRepo() *MockPlaylistRepo { @@ -52,6 +53,14 @@ func (m *MockPlaylistRepo) GetAll(options ...model.QueryOptions) (model.Playlist return m.All, nil } +func (m *MockPlaylistRepo) GetAllIDs(options ...model.QueryOptions) ([]string, error) { + all, err := m.GetAll(options...) + if err != nil { + return nil, err + } + return slice.Map(all, func(p model.Playlist) string { return p.ID }), nil +} + func (m *MockPlaylistRepo) GetCursor(options ...model.QueryOptions) (model.PlaylistCursor, error) { res, err := m.GetAll(options...) if err != nil { diff --git a/tests/mock_playlist_track_repo.go b/tests/mock_playlist_track_repo.go index 2835baadd..83751bb28 100644 --- a/tests/mock_playlist_track_repo.go +++ b/tests/mock_playlist_track_repo.go @@ -14,6 +14,7 @@ type MockPlaylistTrackRepo struct { Reordered bool AddCount int Err error + AlbumIDs []string // stubbed result for GetAlbumIDs, ignoring options } func (m *MockPlaylistTrackRepo) SetData(tracks model.PlaylistTracks) { @@ -66,6 +67,13 @@ func (m *MockPlaylistTrackRepo) GetCursor(options ...model.QueryOptions) (model. }, nil } +func (m *MockPlaylistTrackRepo) GetAlbumIDs(...model.QueryOptions) ([]string, error) { + if m.Err != nil { + return nil, m.Err + } + return m.AlbumIDs, nil +} + func (m *MockPlaylistTrackRepo) GetMediaFileIDs(options ...model.QueryOptions) ([]string, error) { if m.Err != nil { return nil, m.Err diff --git a/tests/mock_radio_repository.go b/tests/mock_radio_repository.go index c50a529e5..2baeadc5c 100644 --- a/tests/mock_radio_repository.go +++ b/tests/mock_radio_repository.go @@ -5,6 +5,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" + "github.com/navidrome/navidrome/utils/slice" ) type MockedRadioRepo struct { @@ -16,7 +17,7 @@ type MockedRadioRepo struct { } func CreateMockedRadioRepo() *MockedRadioRepo { - return &MockedRadioRepo{} + return &MockedRadioRepo{Data: map[string]*model.Radio{}} } func (m *MockedRadioRepo) SetError(err bool) { @@ -73,6 +74,14 @@ func (m *MockedRadioRepo) GetAll(qo ...model.QueryOptions) (model.Radios, error) return m.All, nil } +func (m *MockedRadioRepo) GetAllIDs(qo ...model.QueryOptions) ([]string, error) { + all, err := m.GetAll(qo...) + if err != nil { + return nil, err + } + return slice.Map(all, func(r model.Radio) string { return r.ID }), nil +} + func (m *MockedRadioRepo) Put(radio *model.Radio, _ ...string) error { if m.Err { return errors.New("error") diff --git a/ui/src/album/AlbumDetails.jsx b/ui/src/album/AlbumDetails.jsx index cec66eb8b..f55d94ffc 100644 --- a/ui/src/album/AlbumDetails.jsx +++ b/ui/src/album/AlbumDetails.jsx @@ -2,7 +2,6 @@ import { useEffect, useState } from 'react' import { Card, CardContent, - CardMedia, Collapse, makeStyles, Typography, @@ -21,6 +20,7 @@ import Lightbox from 'react-image-lightbox' import config from '../config' import 'react-image-lightbox/style.css' import subsonic from '../subsonic' +import { Artwork } from '../common/Artwork' import { ArtistLinkField, CollapsibleComment, @@ -30,7 +30,6 @@ import { RatingField, SizeField, useAlbumsPerPage, - useImageLoadingState, } from '../common' import { formatFullDate, intersperse } from '../utils' import AlbumExternalLinks from './AlbumExternalLinks' @@ -88,9 +87,6 @@ const useStyles = makeStyles( backgroundColor: 'transparent', transition: 'opacity 0.3s ease-in-out', }, - coverLoading: { - opacity: 0.5, - }, loveButton: { top: theme.spacing(-0.2), left: theme.spacing(0.5), @@ -223,15 +219,7 @@ const AlbumDetails = (props) => { const classes = useStyles() const [expanded, setExpanded] = useState(false) const [albumInfo, setAlbumInfo] = useState() - const { - imageLoading, - imageError, - isLightboxOpen, - handleImageLoad, - handleImageError, - handleOpenLightbox, - handleCloseLightbox, - } = useImageLoadingState(record.id) + const [isLightboxOpen, setLightboxOpen] = useState(false) let notes = albumInfo?.notes || record.notes @@ -254,27 +242,18 @@ const AlbumDetails = (props) => { }) }, [record]) - const imageUrl = subsonic.getCoverArtUrl(record, config.uiCoverArtSize) const fullImageUrl = subsonic.getCoverArtUrl(record) return (

- setLightboxOpen(true)} />
@@ -363,13 +342,13 @@ const AlbumDetails = (props) => {
)} - {isLightboxOpen && !imageError && ( + {isLightboxOpen && ( setLightboxOpen(false)} /> )} diff --git a/ui/src/album/AlbumGridView.jsx b/ui/src/album/AlbumGridView.jsx index 33db9e40f..2e4cb10ba 100644 --- a/ui/src/album/AlbumGridView.jsx +++ b/ui/src/album/AlbumGridView.jsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, { useRef } from 'react' import { GridList, GridListTile, @@ -11,19 +11,18 @@ import withWidth from '@material-ui/core/withWidth' import { Link } from 'react-router-dom' import { linkToRecord, useListContext, Loading } from 'react-admin' import { withContentRect } from 'react-measure' +import { useRollChanged } from './useRollChanged' import { useDrag } from 'react-dnd' -import subsonic from '../subsonic' import { AlbumContextMenu, PlayButton, ArtistLinkField, OverflowTooltip, - useImageUrl, } from '../common' -import config from '../config' import { DraggableTypes } from '../consts' import clsx from 'clsx' import { AlbumDatesField } from './AlbumDatesField.jsx' +import { Artwork } from '../common/Artwork' const useStyles = makeStyles( (theme) => ({ @@ -105,12 +104,12 @@ const useCoverStyles = makeStyles({ display: 'inline-block', width: '100%', objectFit: 'contain', + // The image fills this box absolutely, so it lends no height: a remount that has not been + // re-measured yet would collapse the tile and blank the cover for a frame. + aspectRatio: '1', height: (props) => props.height, transition: 'opacity 0.3s ease-in-out', }, - coverLoading: { - opacity: 0, - }, }) const getColsForWidth = (width) => { @@ -138,16 +137,14 @@ const Cover = withContentRect('bounds')(({ [record], ) - const url = subsonic.getCoverArtUrl(record, config.uiCoverArtSize, true) - const { imgUrl, loading: imageLoading } = useImageUrl(url) - return (
- {record.name}
@@ -237,9 +234,21 @@ const LoadedAlbumGrid = ({ ids, data, basePath, width }) => { ) } -const AlbumGridView = ({ albumListType, loaded, loading, ...props }) => { - const hide = - (loading && albumListType === 'random') || !props.data || !props.ids +const AlbumGridView = ({ + albumListType, + loaded, + loading, + seed, + shownSeed, + ...props +}) => { + // ArtistShow renders this grid too, with no roll to track, so own a ref when none is passed. + const ownSeed = useRef(null) + // A re-roll replaces every album, so the previous roll must not linger while it loads. + const rerolling = + useRollChanged(shownSeed ?? ownSeed, seed, loading) && + albumListType === 'random' + const hide = rerolling || !props.data || !props.ids return hide ? : } diff --git a/ui/src/album/AlbumGridView.test.jsx b/ui/src/album/AlbumGridView.test.jsx new file mode 100644 index 000000000..1f06c3fd7 --- /dev/null +++ b/ui/src/album/AlbumGridView.test.jsx @@ -0,0 +1,18 @@ +import { render } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' +import AlbumGridView from './AlbumGridView' + +// react-admin's Link/useListContext need a router and a store; the grid body is not under test here. +vi.mock('react-admin', () => ({ + linkToRecord: () => '/album/1', + useListContext: () => ({}), + Loading: () =>
, +})) +describe('AlbumGridView', () => { + // ArtistShow renders the grid through ReferenceManyField, which passes no seed tracking. + it('renders without a shownSeed ref', () => { + expect(() => + render(), + ).not.toThrow() + }) +}) diff --git a/ui/src/album/AlbumList.jsx b/ui/src/album/AlbumList.jsx index cd73fa0c3..3cb8a648e 100644 --- a/ui/src/album/AlbumList.jsx +++ b/ui/src/album/AlbumList.jsx @@ -1,4 +1,4 @@ -import { cloneElement } from 'react' +import { cloneElement, useRef } from 'react' import { useSelector } from 'react-redux' import { Redirect, useLocation } from 'react-router-dom' import { @@ -30,6 +30,7 @@ import { import AlbumListActions from './AlbumListActions' import AlbumTableView from './AlbumTableView' import AlbumGridView from './AlbumGridView' +import { useRollChanged } from './useRollChanged' import albumLists from './albumLists' import { getStoredDefaultView, @@ -186,9 +187,10 @@ const AlbumListTitle = ({ albumListType }) => { return } -const AlbumListPagination = ({ albumListType, ...rest }) => { +const AlbumListPagination = ({ albumListType, seed, shownSeed, ...rest }) => { const { loading } = useListContext() - if (loading && albumListType === 'random') { + const rerolling = useRollChanged(shownSeed, seed, loading) + if (rerolling && albumListType === 'random') { return null } return <Pagination {...rest} /> @@ -198,6 +200,7 @@ const randomStartingSeed = Math.random().toString() const AlbumList = (props) => { const { width } = props + const shownSeed = useRef(null) const albumView = useSelector((state) => state.albumView) const [perPage, perPageOptions] = useAlbumsPerPage(width) const location = useLocation() @@ -260,13 +263,20 @@ const AlbumList = (props) => { <AlbumListPagination rowsPerPageOptions={perPageOptions} albumListType={albumListType} + seed={seed} + shownSeed={shownSeed} /> } title={<AlbumListTitle albumListType={albumListType} />} > <ScrollRestorer> {albumView.grid ? ( - <AlbumGridView albumListType={albumListType} {...props} /> + <AlbumGridView + albumListType={albumListType} + seed={seed} + shownSeed={shownSeed} + {...props} + /> ) : ( <AlbumTableView {...props} /> )} diff --git a/ui/src/album/AlbumTableView.jsx b/ui/src/album/AlbumTableView.jsx index d1a89d512..051d5988e 100644 --- a/ui/src/album/AlbumTableView.jsx +++ b/ui/src/album/AlbumTableView.jsx @@ -14,7 +14,7 @@ import { makeStyles } from '@material-ui/core/styles' import { useDrag } from 'react-dnd' import { ArtistLinkField, - CoverArtAvatar, + ArtworkAvatar, DurationField, RangeField, SimpleList, @@ -164,7 +164,7 @@ const AlbumTableView = ({ )} leftIcon={(r) => ( <span style={{ marginRight: '8px' }}> - <CoverArtAvatar record={r} variant="square" /> + <ArtworkAvatar record={r} variant="square" /> </span> )} linkType={'show'} @@ -173,7 +173,7 @@ const AlbumTableView = ({ /> ) : ( <AlbumDatagrid rowClick={'show'} classes={{ row: classes.row }} {...rest}> - <CoverArtAvatar source="id" variant="square" /> + <ArtworkAvatar source="id" variant="square" /> <TextField source="name" /> {columns} <AlbumContextMenu diff --git a/ui/src/album/useRollChanged.jsx b/ui/src/album/useRollChanged.jsx new file mode 100644 index 000000000..b364bdc6b --- /dev/null +++ b/ui/src/album/useRollChanged.jsx @@ -0,0 +1,13 @@ +import { useRef } from 'react' + +// Reports that the albums on screen belong to a different roll than the one loading: only a seed +// change is a re-roll. `shown` is owned above the grid, which a refresh remounts under the new seed. +export const useRollChanged = (shown, seed, loading) => { + const wasLoading = useRef(loading) + + if (!loading && (shown.current === null || wasLoading.current)) { + shown.current = seed + } + wasLoading.current = loading + return shown.current !== seed +} diff --git a/ui/src/album/useRollChanged.test.jsx b/ui/src/album/useRollChanged.test.jsx new file mode 100644 index 000000000..0745efb08 --- /dev/null +++ b/ui/src/album/useRollChanged.test.jsx @@ -0,0 +1,73 @@ +import { renderHook } from '@testing-library/react-hooks' +import { describe, it, expect } from 'vitest' +import { useRollChanged } from './useRollChanged' + +describe('useRollChanged', () => { + // Passing `shown` in mimics AlbumList owning it across a remount. + const setup = (props, shown = { current: null }) => ({ + shown, + ...renderHook(({ seed, loading }) => useRollChanged(shown, seed, loading), { + initialProps: props, + }), + }) + + it('reports a change while a load is in flight on a fresh mount', () => { + const { result } = setup({ seed: 's1', loading: true }) + expect(result.current).toBe(true) + }) + + it('reports no change when mounted with data already in hand', () => { + const { result } = setup({ seed: 's1', loading: false }) + expect(result.current).toBe(false) + }) + + it('stays false while loading a filter change on the same roll', () => { + const { result, rerender } = setup({ seed: 's1', loading: false }) + rerender({ seed: 's1', loading: true }) + expect(result.current).toBe(false) + }) + + it('goes true while loading after the seed changes', () => { + const { result, rerender } = setup({ seed: 's1', loading: false }) + rerender({ seed: 's2', loading: true }) + expect(result.current).toBe(true) + }) + + it('clears once the new roll has loaded', () => { + const { result, rerender } = setup({ seed: 's1', loading: false }) + rerender({ seed: 's2', loading: true }) + expect(result.current).toBe(true) + rerender({ seed: 's2', loading: false }) + expect(result.current).toBe(false) + }) + + // The seed can land a render before loading flips; the new roll must not be recorded as shown. + it('still reports a change when the seed arrives before loading starts', () => { + const { result, rerender } = setup({ seed: 's1', loading: false }) + rerender({ seed: 's2', loading: false }) + expect(result.current).toBe(true) + }) + + // Refresh remounts the grid under the new seed a render before the refetch starts. + it('reports a change when a refresh remounts the grid before loading starts', () => { + const { shown, unmount } = setup({ seed: 's1', loading: false }) + expect(shown.current).toBe('s1') + unmount() + + const remounted = setup({ seed: 's2', loading: false }, shown) + expect(remounted.result.current).toBe(true) + + remounted.rerender({ seed: 's2', loading: true }) + expect(remounted.result.current).toBe(true) + remounted.rerender({ seed: 's2', loading: false }) + expect(remounted.result.current).toBe(false) + }) + + it('reports no change when a remount keeps the same roll', () => { + const { shown, unmount } = setup({ seed: 's1', loading: false }) + unmount() + + const remounted = setup({ seed: 's1', loading: false }, shown) + expect(remounted.result.current).toBe(false) + }) +}) diff --git a/ui/src/artist/ArtistList.jsx b/ui/src/artist/ArtistList.jsx index 4559aa8e6..f4147221e 100644 --- a/ui/src/artist/ArtistList.jsx +++ b/ui/src/artist/ArtistList.jsx @@ -22,7 +22,7 @@ import { useDrag } from 'react-dnd' import clsx from 'clsx' import { ArtistContextMenu, - CoverArtAvatar, + ArtworkAvatar, List, useGetHandleArtistClick, RatingField, @@ -173,7 +173,7 @@ const ArtistListView = ({ hasShow, hasEdit, hasList, width, ...rest }) => { /> ) : ( <ArtistDatagrid rowClick={handleArtistLink} classes={{ row: classes.row }}> - <CoverArtAvatar source="id" /> + <ArtworkAvatar source="id" /> <TextField source="name" /> <FunctionField source="albumCount" diff --git a/ui/src/artist/ArtistSimpleList.jsx b/ui/src/artist/ArtistSimpleList.jsx index 55b6b1a0b..9dcae0470 100644 --- a/ui/src/artist/ArtistSimpleList.jsx +++ b/ui/src/artist/ArtistSimpleList.jsx @@ -8,7 +8,7 @@ import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction' import ListItemText from '@material-ui/core/ListItemText' import { makeStyles } from '@material-ui/core/styles' import { sanitizeListRestProps } from 'react-admin' -import { ArtistContextMenu, CoverArtAvatar, RatingField } from '../common' +import { ArtistContextMenu, ArtworkAvatar, RatingField } from '../common' import config from '../config' const useStyles = makeStyles( @@ -49,7 +49,7 @@ const ArtistSimpleList = ({ <span key={id} onClick={() => linkType(id)}> <ListItem className={classes.listItem} button={true}> <ListItemAvatar> - <CoverArtAvatar record={data[id]} /> + <ArtworkAvatar record={data[id]} /> </ListItemAvatar> <ListItemText style={{ marginLeft: '8px' }} diff --git a/ui/src/artist/DesktopArtistDetails.jsx b/ui/src/artist/DesktopArtistDetails.jsx index dda761097..9269dc8d7 100644 --- a/ui/src/artist/DesktopArtistDetails.jsx +++ b/ui/src/artist/DesktopArtistDetails.jsx @@ -3,20 +3,15 @@ import { Typography, Collapse } from '@material-ui/core' import { makeStyles } from '@material-ui/core' import Card from '@material-ui/core/Card' import CardContent from '@material-ui/core/CardContent' -import CardMedia from '@material-ui/core/CardMedia' import ArtistExternalLinks from './ArtistExternalLink' import config from '../config' -import { - LoveButton, - RatingField, - ImageUploadOverlay, - useImageLoadingState, -} from '../common' +import { LoveButton, RatingField, ImageUploadOverlay } from '../common' import Lightbox from 'react-image-lightbox' import ExpandInfoDialog from '../dialogs/ExpandInfoDialog' import AlbumInfo from '../album/AlbumInfo' import subsonic from '../subsonic' import { SafeHTML } from '../common/SafeHTML' +import { Artwork } from '../common/Artwork' const useStyles = makeStyles( (theme) => ({ @@ -49,9 +44,6 @@ const useStyles = makeStyles( transition: 'opacity 0.3s ease-in-out', objectFit: 'cover', }, - coverLoading: { - opacity: 0.5, - }, artistImage: { maxHeight: '12rem', minHeight: '12rem', @@ -91,35 +83,18 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => { const [expanded, setExpanded] = useState(false) const classes = useStyles() const title = record.name - const { - imageLoading, - imageError, - isLightboxOpen, - handleImageLoad, - handleImageError, - handleOpenLightbox, - handleCloseLightbox, - } = useImageLoadingState(record.id) + const [isLightboxOpen, setLightboxOpen] = useState(false) return ( <div className={classes.root}> <Card className={classes.artistDetail}> <Card className={classes.artistImage}> - {artistInfo && ( - <CardMedia - key={record.id} - component="img" - src={subsonic.getCoverArtUrl(record, config.uiCoverArtSize)} - className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`} - onClick={handleOpenLightbox} - onLoad={handleImageLoad} - onError={handleImageError} - title={title} - style={{ - cursor: imageError ? 'default' : 'pointer', - }} - /> - )} + <Artwork + record={record} + className={classes.cover} + title={title} + onClick={() => setLightboxOpen(true)} + /> <ImageUploadOverlay entityType="artist" entityId={record.id} @@ -175,13 +150,13 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => { )} </Typography> </div> - {isLightboxOpen && !imageError && ( + {isLightboxOpen && ( <Lightbox imagePadding={50} animationDuration={200} imageTitle={record.name} mainSrc={subsonic.getCoverArtUrl(record)} - onCloseRequest={handleCloseLightbox} + onCloseRequest={() => setLightboxOpen(false)} /> )} </Card> diff --git a/ui/src/artist/MobileArtistDetails.jsx b/ui/src/artist/MobileArtistDetails.jsx index e82d4c28a..0925b6f10 100644 --- a/ui/src/artist/MobileArtistDetails.jsx +++ b/ui/src/artist/MobileArtistDetails.jsx @@ -2,17 +2,12 @@ import React, { useState } from 'react' import { Typography, Collapse } from '@material-ui/core' import { makeStyles } from '@material-ui/core/styles' import Card from '@material-ui/core/Card' -import CardMedia from '@material-ui/core/CardMedia' import config from '../config' -import { - LoveButton, - RatingField, - ImageUploadOverlay, - useImageLoadingState, -} from '../common' +import { LoveButton, RatingField, ImageUploadOverlay } from '../common' import Lightbox from 'react-image-lightbox' import subsonic from '../subsonic' import { SafeHTML } from '../common/SafeHTML' +import { Artwork } from '../common/Artwork' const useStyles = makeStyles( (theme) => ({ @@ -60,9 +55,6 @@ const useStyles = makeStyles( transition: 'opacity 0.3s ease-in-out', objectFit: 'cover', }, - coverLoading: { - opacity: 0.5, - }, artistImage: { marginLeft: '1em', maxHeight: '7rem', @@ -88,41 +80,24 @@ const useStyles = makeStyles( { name: 'NDMobileArtistDetails' }, ) -const MobileArtistDetails = ({ artistInfo, biography, record }) => { +const MobileArtistDetails = ({ biography, record }) => { const img = subsonic.getCoverArtUrl(record, 800) const [expanded, setExpanded] = useState(false) const classes = useStyles({ img, expanded }) const title = record.name - const { - imageLoading, - imageError, - isLightboxOpen, - handleImageLoad, - handleImageError, - handleOpenLightbox, - handleCloseLightbox, - } = useImageLoadingState(record.id) + const [isLightboxOpen, setLightboxOpen] = useState(false) return ( <> <div className={classes.root}> <div className={classes.bgContainer}> <Card className={classes.artistImage}> - {artistInfo && ( - <CardMedia - key={record.id} - component="img" - src={subsonic.getCoverArtUrl(record, config.uiCoverArtSize)} - className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`} - onClick={handleOpenLightbox} - onLoad={handleImageLoad} - onError={handleImageError} - title={title} - style={{ - cursor: imageError ? 'default' : 'pointer', - }} - /> - )} + <Artwork + record={record} + className={classes.cover} + title={title} + onClick={() => setLightboxOpen(true)} + /> <ImageUploadOverlay entityType="artist" entityId={record.id} @@ -165,13 +140,13 @@ const MobileArtistDetails = ({ artistInfo, biography, record }) => { </Typography> </Collapse> </div> - {isLightboxOpen && !imageError && ( + {isLightboxOpen && ( <Lightbox imagePadding={50} animationDuration={200} imageTitle={record.name} mainSrc={img} - onCloseRequest={handleCloseLightbox} + onCloseRequest={() => setLightboxOpen(false)} /> )} </> diff --git a/ui/src/common/Artwork.jsx b/ui/src/common/Artwork.jsx new file mode 100644 index 000000000..21ac5e07b --- /dev/null +++ b/ui/src/common/Artwork.jsx @@ -0,0 +1,127 @@ +import { useEffect, useRef, useState } from 'react' +import PropTypes from 'prop-types' +import clsx from 'clsx' +import { makeStyles } from '@material-ui/core/styles' +import config from '../config' +import subsonic from '../subsonic' +import { useImageUrl } from './useImageUrl' +import { ThumbHashCanvas } from './ThumbHashCanvas' + +// Drives both the CSS transition and the timer that retires the placeholder, so they cannot drift. +const fadeMs = 150 + +const useStyles = makeStyles({ + // className supplies the size and shape; overflow:hidden clips the fills to it. + root: { + position: 'relative', + display: 'inline-flex', + overflow: 'hidden', + }, + fill: { + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + }, + img: { + opacity: 0, + transition: `opacity ${fadeMs}ms ease-out`, + '@media (prefers-reduced-motion: reduce)': { transition: 'none' }, + }, + imgVisible: { opacity: 1 }, + // Already-decoded blobs appear at once: fading them in would re-animate on every remount. + imgInstant: { opacity: 1, transition: 'none' }, +}) + +// Renders a cover through the shared useImageUrl blob cache, so it survives remounts without +// re-fetching. The image mounts only once its blob is ready, so it never renders broken. +export const Artwork = ({ + record, + size = config.uiCoverArtSize, + square = false, + fit = 'cover', + className, + title, + onClick, +}) => { + const classes = useStyles() + const url = record ? subsonic.getCoverArtUrl(record, size, square) : '' + const { imgUrl, fromCache } = useImageUrl(url) + + const [decoded, setDecoded] = useState(false) + const [faded, setFaded] = useState(false) + const frame = useRef(0) + useEffect(() => { + setDecoded(false) + setFaded(false) + return () => cancelAnimationFrame(frame.current) + }, [url]) + + // Two frames: a blob decodes from memory, so onLoad can beat the first paint of opacity 0, + // leaving the transition no start value and popping some tiles in while others fade. + const handleLoad = () => { + frame.current = requestAnimationFrame(() => { + frame.current = requestAnimationFrame(() => setDecoded(true)) + }) + } + + // Timer, not transitionend: under prefers-reduced-motion there is no transition to end. + useEffect(() => { + if (!decoded || faded) return undefined + const timer = setTimeout(() => setFaded(true), fadeMs) + return () => clearTimeout(timer) + }, [decoded, faded]) + + if (!record) return null + + // A blob already cached paints on the first frame, so it skips the fade. + const instant = fromCache + // Kept mounted until the fade ends; swapping on blob arrival would flash an empty container. + const showPlaceholder = !!record.thumbHash && !instant && !faded + // A square request is padded, not cropped, so `contain` keeps placeholder and image aligned. + const effectiveFit = square ? 'contain' : fit + const ratio = record.imageWidth / record.imageHeight + const handleClick = imgUrl && onClick ? onClick : undefined + return ( + <div + className={clsx(classes.root, className)} + onClick={handleClick} + style={{ cursor: handleClick ? 'pointer' : 'default' }} + > + {showPlaceholder && ( + <ThumbHashCanvas + hash={record.thumbHash} + ratio={ratio} + fit={effectiveFit} + className={classes.fill} + /> + )} + {imgUrl && ( + <img + src={imgUrl} + alt={title} + title={title} + className={clsx( + classes.fill, + classes.img, + instant && classes.imgInstant, + decoded && classes.imgVisible, + )} + style={{ objectFit: effectiveFit }} + onLoad={handleLoad} + /> + )} + </div> + ) +} + +Artwork.propTypes = { + record: PropTypes.object, + size: PropTypes.number, + square: PropTypes.bool, + fit: PropTypes.oneOf(['cover', 'contain']), + className: PropTypes.string, + title: PropTypes.string, + onClick: PropTypes.func, +} diff --git a/ui/src/common/Artwork.test.jsx b/ui/src/common/Artwork.test.jsx new file mode 100644 index 000000000..18b3c916f --- /dev/null +++ b/ui/src/common/Artwork.test.jsx @@ -0,0 +1,232 @@ +import { render, fireEvent, act } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('./useImageUrl', () => ({ useImageUrl: vi.fn() })) +vi.mock('../subsonic', () => ({ + default: { + // Mirrors the real hash suffix, so a refreshed record yields a different URL. + getCoverArtUrl: (record) => + '/rest/getCoverArt?id=al-1' + + (record.imageHash ? `_${record.imageHash}` : ''), + }, +})) +vi.mock('../config', () => ({ default: { uiCoverArtSize: 300 } })) + +import { useImageUrl } from './useImageUrl' +import { Artwork } from './Artwork' + +const withArt = { + id: 'al-1', + name: 'Album', + thumbHash: 'H/gNBxpwh4dwd3eIiHd3iHeHeJ+dcH8I', +} + +describe('Artwork', () => { + beforeEach(() => { + vi.clearAllMocks() + // jsdom has no 2D context; stub it so ThumbHashCanvas bails cleanly without console noise + HTMLCanvasElement.prototype.getContext = vi.fn(() => null) + }) + + it('renders nothing without a record', () => { + useImageUrl.mockReturnValue({ imgUrl: null, loading: false }) + const { container } = render(<Artwork record={null} />) + expect(container.firstChild).toBeNull() + }) + + it('shows the placeholder and no <img> while loading', () => { + useImageUrl.mockReturnValue({ imgUrl: null, loading: true }) + const { container } = render(<Artwork record={withArt} title="Album" />) + expect(container.querySelector('canvas')).not.toBeNull() + expect(container.querySelector('img')).toBeNull() + }) + + it('shows neither a broken <img> nor a canvas while loading a record with no thumbhash', () => { + useImageUrl.mockReturnValue({ imgUrl: null, loading: true }) + const { container } = render(<Artwork record={{ id: 'al-2', name: 'X' }} />) + expect(container.querySelector('img')).toBeNull() + expect(container.querySelector('canvas')).toBeNull() + }) + + // The placeholder has to land exactly where the image will, or it jumps when the image swaps in. + it('shapes the placeholder like the artwork and fits it like the image', () => { + useImageUrl.mockReturnValue({ imgUrl: null, loading: true }) + const nonSquare = { ...withArt, imageWidth: 1200, imageHeight: 800 } + const { container } = render( + <Artwork record={nonSquare} fit="contain" title="Album" />, + ) + const canvas = container.querySelector('canvas') + expect(canvas.width).toBe(32) + expect(canvas.height).toBe(21) + expect(canvas.style.objectFit).toBe('contain') + }) + + // A square request is padded, not cropped, so the placeholder has to letterbox with it. + it('letterboxes the placeholder when the server pads a non-square image to a square', () => { + useImageUrl.mockReturnValue({ imgUrl: null, loading: true }) + const nonSquare = { ...withArt, imageWidth: 1200, imageHeight: 800 } + const { container } = render(<Artwork record={nonSquare} square />) + const canvas = container.querySelector('canvas') + expect(canvas.width).toBe(32) + expect(canvas.height).toBe(21) + expect(canvas.style.objectFit).toBe('contain') + }) + + // The padded square is aspect-fit, so `square` overrides fit="cover" as it does for the canvas. + it('fits the image itself with contain when the server padded to a square', () => { + useImageUrl.mockReturnValue({ + imgUrl: 'blob:abc', + loading: false, + fromCache: true, + }) + const { container } = render( + <Artwork record={withArt} square fit="cover" title="Album" />, + ) + expect(container.querySelector('img').style.objectFit).toBe('contain') + }) + + it('fills the box for square artwork, the overwhelmingly common case', () => { + useImageUrl.mockReturnValue({ imgUrl: null, loading: true }) + const sq = { ...withArt, imageWidth: 600, imageHeight: 600 } + const { container } = render(<Artwork record={sq} square />) + const canvas = container.querySelector('canvas') + expect(canvas.width).toBe(32) + expect(canvas.height).toBe(32) + }) + + // Without dimensions the placeholder falls back to the aspect the hash itself carries. + it('falls back to the hash own aspect when the record has no dimensions', () => { + useImageUrl.mockReturnValue({ imgUrl: null, loading: true }) + const { container } = render(<Artwork record={withArt} />) + const canvas = container.querySelector('canvas') + expect(canvas.width).toBe(32) + expect(canvas.height).toBe(32) + }) + + it('mounts the image only once its blob is ready', () => { + useImageUrl.mockReturnValue({ + imgUrl: 'blob:abc', + loading: false, + fromCache: true, + }) + const { container } = render(<Artwork record={withArt} title="Album" />) + const img = container.querySelector('img') + expect(img).not.toBeNull() + expect(img.getAttribute('src')).toBe('blob:abc') + }) + + it('keeps the placeholder under the image until the fade ends', () => { + vi.useFakeTimers() + try { + useImageUrl.mockReturnValue({ imgUrl: null, loading: true }) + const { container, rerender } = render(<Artwork record={withArt} />) + + useImageUrl.mockReturnValue({ + imgUrl: 'blob:abc', + loading: false, + fromCache: false, + }) + rerender(<Artwork record={withArt} />) + const img = container.querySelector('img') + expect(img).not.toBeNull() + expect(container.querySelector('canvas')).not.toBeNull() + + // The clock ends the fade, not transitionend, which never fires under reduced-motion. + act(() => { + fireEvent.load(img) + }) + expect(container.querySelector('canvas')).not.toBeNull() + act(() => { + fireEvent.transitionEnd(img) + }) + expect(container.querySelector('canvas')).not.toBeNull() + // Two frames pass before the fade starts, so opacity 0 gets painted first. + act(() => { + vi.advanceTimersByTime(32) + }) + expect(container.querySelector('canvas')).not.toBeNull() + act(() => { + vi.advanceTimersByTime(150) + }) + expect(container.querySelector('canvas')).toBeNull() + } finally { + vi.useRealTimers() + } + }) + + it('does not fade an image that was already cached on mount', () => { + useImageUrl.mockReturnValue({ + imgUrl: 'blob:abc', + loading: false, + fromCache: true, + }) + const { container } = render(<Artwork record={withArt} />) + expect(container.querySelector('canvas')).toBeNull() + expect(container.querySelector('img').className).toContain('imgInstant') + }) + + it('keeps the placeholder visible when the image never decodes', () => { + useImageUrl.mockReturnValue({ imgUrl: null, loading: true }) + const { container, rerender } = render(<Artwork record={withArt} />) + useImageUrl.mockReturnValue({ + imgUrl: 'blob:abc', + loading: false, + fromCache: false, + }) + rerender(<Artwork record={withArt} />) + + expect(container.querySelector('canvas')).not.toBeNull() + }) + + it('fires onClick only when the image is loaded', () => { + const onClick = vi.fn() + useImageUrl.mockReturnValue({ imgUrl: null, loading: true }) + const { container, rerender } = render( + <Artwork record={withArt} onClick={onClick} />, + ) + container.firstChild.click() + expect(onClick).not.toHaveBeenCalled() + + useImageUrl.mockReturnValue({ + imgUrl: 'blob:abc', + loading: false, + fromCache: false, + }) + rerender(<Artwork record={withArt} onClick={onClick} />) + container.firstChild.click() + expect(onClick).toHaveBeenCalledTimes(1) + }) + + it('shows the placeholder for a cached fetch failure, which has no blob', () => { + useImageUrl.mockReturnValue({ + imgUrl: null, + loading: false, + error: true, + fromCache: false, + }) + const { container } = render(<Artwork record={withArt} />) + expect(container.querySelector('img')).toBeNull() + expect(container.querySelector('canvas')).not.toBeNull() + }) + + it('falls back to the placeholder when a refresh swaps in an uncached hash', () => { + useImageUrl.mockReturnValue({ + imgUrl: 'blob:old', + loading: false, + fromCache: true, + }) + const cached = { ...withArt, imageHash: 'aaaa' } + const { container, rerender } = render(<Artwork record={cached} />) + expect(container.querySelector('canvas')).toBeNull() + + useImageUrl.mockReturnValue({ + imgUrl: null, + loading: true, + fromCache: false, + }) + rerender(<Artwork record={{ ...withArt, imageHash: 'bbbb' }} />) + + expect(container.querySelector('img')).toBeNull() + expect(container.querySelector('canvas')).not.toBeNull() + }) +}) diff --git a/ui/src/common/ArtworkAvatar.jsx b/ui/src/common/ArtworkAvatar.jsx new file mode 100644 index 000000000..7691050c6 --- /dev/null +++ b/ui/src/common/ArtworkAvatar.jsx @@ -0,0 +1,40 @@ +import { useRecordContext } from 'react-admin' +import { makeStyles } from '@material-ui/core/styles' +import clsx from 'clsx' +import config from '../config' +import { Artwork } from './Artwork' + +const useStyles = makeStyles({ + avatar: { + width: '55px', + height: '55px', + }, + square: { + borderRadius: '4px', + }, + circular: { + borderRadius: '50%', + }, +}) + +export const ArtworkAvatar = ({ record: recordProp, variant = 'circular' }) => { + const classes = useStyles() + const recordContext = useRecordContext() + const record = recordProp || recordContext + if (!record) return null + const square = variant !== 'circular' + return ( + <Artwork + record={record} + size={config.uiCoverArtSize} + square={square} + className={clsx( + classes.avatar, + square ? classes.square : classes.circular, + )} + title={record.name} + /> + ) +} + +ArtworkAvatar.defaultProps = { label: '', sortable: false } diff --git a/ui/src/common/CoverArtAvatar.jsx b/ui/src/common/CoverArtAvatar.jsx deleted file mode 100644 index f70403774..000000000 --- a/ui/src/common/CoverArtAvatar.jsx +++ /dev/null @@ -1,52 +0,0 @@ -import { useRecordContext } from 'react-admin' -import { Avatar } from '@material-ui/core' -import { makeStyles } from '@material-ui/core/styles' -import clsx from 'clsx' -import config from '../config' -import subsonic from '../subsonic' -import { useImageUrl } from './useImageUrl' - -const useStyles = makeStyles({ - avatar: { - width: '55px', - height: '55px', - }, - avatarEmpty: { - backgroundColor: 'transparent', - }, - square: { - borderRadius: '4px', - }, -}) - -export const CoverArtAvatar = ({ - record: recordProp, - variant = 'circular', -}) => { - const classes = useStyles() - const recordContext = useRecordContext() - const record = recordProp || recordContext - const square = variant !== 'circular' - const url = record - ? subsonic.getCoverArtUrl(record, config.uiCoverArtSize, square) - : null - const { imgUrl } = useImageUrl(url) - if (!record) return null - return ( - <Avatar - src={imgUrl || undefined} - variant={variant} - className={clsx( - classes.avatar, - square && classes.square, - !imgUrl && classes.avatarEmpty, - )} - alt={record.name} - > - {/* Empty child prevents default person icon while loading */} - {!imgUrl && <span />} - </Avatar> - ) -} - -CoverArtAvatar.defaultProps = { label: '', sortable: false } diff --git a/ui/src/common/ThumbHashCanvas.jsx b/ui/src/common/ThumbHashCanvas.jsx new file mode 100644 index 000000000..03d349313 --- /dev/null +++ b/ui/src/common/ThumbHashCanvas.jsx @@ -0,0 +1,75 @@ +import { useEffect, useRef } from 'react' +import PropTypes from 'prop-types' +import { decode, naturalSize } from '../utils/thumbhash' + +// A thumbhash carries no detail beyond a few dozen pixels; CSS upscales the canvas. +const DECODE_SIZE = 32 + +// bitmapSize prefers the artwork's true ratio, falling back to the aspect the hash itself carries, +// which is quantised to a ratio of small integers and so only approximates the image. +const bitmapSize = (hash, ratio) => { + if (!(ratio > 0) || !Number.isFinite(ratio)) { + try { + return naturalSize(hash) + } catch { + return { width: DECODE_SIZE, height: DECODE_SIZE } + } + } + return ratio >= 1 + ? { + width: DECODE_SIZE, + height: Math.max(1, Math.round(DECODE_SIZE / ratio)), + } + : { + width: Math.max(1, Math.round(DECODE_SIZE * ratio)), + height: DECODE_SIZE, + } +} + +export const ThumbHashCanvas = ({ hash, ratio, fit, className, style }) => { + const canvasRef = useRef(null) + const { width, height } = bitmapSize(hash, ratio) + + useEffect(() => { + if (!hash || !canvasRef.current) { + return + } + const ctx = canvasRef.current.getContext('2d') + if (!ctx) { + return + } + // Clear first so a hash change that fails to decode never leaves a stale frame. + ctx.clearRect(0, 0, width, height) + try { + const pixels = decode(hash, width, height) + const imageData = ctx.createImageData(width, height) + imageData.data.set(pixels) + ctx.putImageData(imageData, 0, 0) + } catch { + // A malformed hash simply leaves the canvas blank. + } + }, [hash, width, height]) + + if (!hash) { + return null + } + return ( + <canvas + ref={canvasRef} + width={width} + height={height} + className={className} + style={{ ...style, objectFit: fit }} + aria-hidden="true" + /> + ) +} + +ThumbHashCanvas.propTypes = { + hash: PropTypes.string, + // Aspect ratio (width / height) of the image this stands in for; the hash's own when omitted. + ratio: PropTypes.number, + fit: PropTypes.oneOf(['cover', 'contain']), + className: PropTypes.string, + style: PropTypes.object, +} diff --git a/ui/src/common/ThumbHashCanvas.test.jsx b/ui/src/common/ThumbHashCanvas.test.jsx new file mode 100644 index 000000000..b1f53fe5c --- /dev/null +++ b/ui/src/common/ThumbHashCanvas.test.jsx @@ -0,0 +1,95 @@ +import { render } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { ThumbHashCanvas } from './ThumbHashCanvas' + +// Golden hashes from core/artwork/thumbhash/testdata/golden.json. +const SQUARE = 'H/gNBxpwh4dwd3eIiHd3iHeHeJ+dcH8I' +const LANDSCAPE = '3wcOFJpwh4eBh3d4iIePgAj3hw==' + +describe('ThumbHashCanvas', () => { + // jsdom has no real 2D context; stub it (tracked) so specs can assert the draw path ran. + let ctxMock + let getContextSpy + beforeEach(() => { + ctxMock = { + clearRect: vi.fn(), + createImageData: vi.fn((w, h) => ({ + data: new Uint8ClampedArray(w * h * 4), + })), + putImageData: vi.fn(), + } + getContextSpy = vi + .spyOn(HTMLCanvasElement.prototype, 'getContext') + .mockReturnValue(ctxMock) + }) + afterEach(() => { + getContextSpy.mockRestore() + }) + + it('renders nothing without a hash', () => { + const { container } = render(<ThumbHashCanvas hash="" />) + expect(container.querySelector('canvas')).toBeNull() + }) + + it('decodes a valid hash and draws non-trivial pixel data', () => { + const { container } = render(<ThumbHashCanvas hash={SQUARE} />) + expect(container.querySelector('canvas')).not.toBeNull() + expect(ctxMock.putImageData).toHaveBeenCalledTimes(1) + const [imageData] = ctxMock.putImageData.mock.calls[0] + expect(imageData.data.some((byte) => byte !== 0)).toBe(true) + }) + + it('decodes into a bitmap shaped like the image, so the blur is not distorted', () => { + const { container } = render( + <ThumbHashCanvas hash={SQUARE} ratio={1200 / 800} />, + ) + expect(ctxMock.createImageData).toHaveBeenCalledWith(32, 21) + const canvas = container.querySelector('canvas') + expect(canvas.width).toBe(32) + expect(canvas.height).toBe(21) + }) + + it('shapes a portrait ratio the other way round', () => { + render(<ThumbHashCanvas hash={SQUARE} ratio={0.5} />) + expect(ctxMock.createImageData).toHaveBeenCalledWith(16, 32) + }) + + it('never collapses an extreme ratio to a zero-sized bitmap', () => { + render(<ThumbHashCanvas hash={SQUARE} ratio={200} />) + expect(ctxMock.createImageData).toHaveBeenCalledWith(32, 1) + }) + + // Unlike a blurhash, a thumbhash carries its own approximate aspect, so an unknown ratio + // falls back to that rather than to a square. + it('falls back to the hash own aspect when the ratio is unknown', () => { + render(<ThumbHashCanvas hash={LANDSCAPE} ratio={0} />) + expect(ctxMock.createImageData).toHaveBeenCalledWith(32, 18) + }) + + it('applies the object-fit it is given, so it lands where the image will', () => { + const { container } = render( + <ThumbHashCanvas hash={SQUARE} ratio={1.5} fit="contain" />, + ) + expect(container.querySelector('canvas').style.objectFit).toBe('contain') + }) + + it('renders a canvas without throwing on a malformed hash, and draws nothing', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const { container } = render( + <ThumbHashCanvas hash="!!!not-a-thumbhash!!!" />, + ) + expect(container.querySelector('canvas')).not.toBeNull() + expect(ctxMock.putImageData).not.toHaveBeenCalled() + spy.mockRestore() + }) + + it('clears the canvas when a hash change fails to decode', () => { + const { rerender } = render(<ThumbHashCanvas hash={SQUARE} />) + expect(ctxMock.putImageData).toHaveBeenCalledTimes(1) + + rerender(<ThumbHashCanvas hash="!!!not-a-thumbhash!!!" />) + + expect(ctxMock.clearRect).toHaveBeenCalledTimes(2) + expect(ctxMock.putImageData).toHaveBeenCalledTimes(1) + }) +}) diff --git a/ui/src/common/index.js b/ui/src/common/index.js index c88ea3e9c..7b5b376f3 100644 --- a/ui/src/common/index.js +++ b/ui/src/common/index.js @@ -46,6 +46,6 @@ export * from './ParticipantsInfo' export * from './OverflowTooltip' export * from './useSearchRefocus' export * from './ImageUploadOverlay' -export * from './CoverArtAvatar' +export * from './ArtworkAvatar' export * from './useImageLoadingState' export * from './useImageUrl' diff --git a/ui/src/common/useImageUrl.js b/ui/src/common/useImageUrl.js index 9bcc70d74..8a7f2bcc7 100644 --- a/ui/src/common/useImageUrl.js +++ b/ui/src/common/useImageUrl.js @@ -43,6 +43,18 @@ export const useImageUrl = (url) => { const [error, setError] = useState(cached?.error || false) const abortedRef = useRef(false) + // Resync during render: otherwise imgUrl holds the previous url's blob for one render, so only + // fromCache can answer "already cached" - and a remembered failure has no blob to paint. + const [fromCache, setFromCache] = useState(!!cached?.blobUrl) + const [trackedUrl, setTrackedUrl] = useState(url) + if (trackedUrl !== url) { + setTrackedUrl(url) + setImgUrl(cached?.blobUrl || null) + setLoading(!!url && !cached) + setError(cached?.error || false) + setFromCache(!!cached?.blobUrl) + } + useEffect(() => { abortedRef.current = false @@ -140,5 +152,5 @@ export const useImageUrl = (url) => { } }, [url]) - return { imgUrl, loading, error } + return { imgUrl, loading, error, fromCache } } diff --git a/ui/src/common/useImageUrl.test.js b/ui/src/common/useImageUrl.test.js index 976317105..818732d06 100644 --- a/ui/src/common/useImageUrl.test.js +++ b/ui/src/common/useImageUrl.test.js @@ -200,6 +200,7 @@ describe('useImageUrl', () => { expect(global.fetch).toHaveBeenCalledTimes(1) expect(result2.current.imgUrl).toBe('blob:mock-url') expect(result2.current.loading).toBe(false) + expect(result2.current.fromCache).toBe(true) }) it('should cache errors and not re-fetch broken URLs', async () => { @@ -230,5 +231,7 @@ describe('useImageUrl', () => { expect(result2.current.error).toBe(true) expect(result2.current.imgUrl).toBeNull() expect(result2.current.loading).toBe(false) + // A remembered failure has no blob, so callers must not treat it as instantly painted. + expect(result2.current.fromCache).toBe(false) }) }) diff --git a/ui/src/common/useResourceRefresh.jsx b/ui/src/common/useResourceRefresh.jsx index eabff6f92..b59ae8e88 100644 --- a/ui/src/common/useResourceRefresh.jsx +++ b/ui/src/common/useResourceRefresh.jsx @@ -63,6 +63,9 @@ import { useRefresh, useDataProvider } from 'react-admin' * - Global refresh: { '*': '*' } or { someResource: ['*'] } * - Specific resources: { album: ['id1', 'id2'], song: ['id3'] } */ +// Resources whose records are media files, and so inherit their album's artwork. +const trackResources = ['song', 'playlistTrack'] + export const useResourceRefresh = (...visibleResources) => { const [lastTime, setLastTime] = useState(Date.now()) const refresh = useRefresh() @@ -70,6 +73,7 @@ export const useResourceRefresh = (...visibleResources) => { const refreshData = useSelector( (state) => state.activity?.refresh || { lastReceived: lastTime }, ) + const loadedResources = useSelector((state) => state.admin?.resources) const { resources, lastReceived } = refreshData if (lastReceived <= lastTime) { @@ -77,21 +81,49 @@ export const useResourceRefresh = (...visibleResources) => { } setLastTime(lastReceived) - if ( + const isWatched = (r) => + visibleResources.length === 0 || visibleResources.includes(r) + // A wildcard on a resource this component does not show is somebody else's business: reloading + // the page for it throws away the list the user is looking at. + const hasWildcard = resources && (resources['*'] === '*' || - Object.values(resources).find((v) => v.find((v2) => v2 === '*'))) - ) { + Object.entries(resources).some( + ([r, ids]) => isWatched(r) && ids.includes?.('*'), + )) + + if (hasWildcard) { refresh() return } - if (resources) { - Object.keys(resources).forEach((r) => { - if (visibleResources.length === 0 || visibleResources?.includes(r)) { - if (resources[r]?.length > 0) { - dataProvider.getMany(r, { ids: resources[r] }) + if (!resources) { + return + } + Object.keys(resources).forEach((r) => { + if (isWatched(r)) { + if (resources[r]?.length > 0) { + // Only refetch records already in the store; ones the UI never loaded will + // arrive fresh (with the new artwork) when navigated to, so fetching them is wasteful. + const loaded = loadedResources?.[r]?.data || {} + const ids = resources[r].filter((id) => loaded[id] !== undefined) + if (ids.length > 0) { + dataProvider.getMany(r, { ids }) } } + } + }) + + // A track with no art of its own is served its album's, so an album's new coverArt id moves its + // tracks' too. The dependent ids are unbounded server-side, but the store knows which are loaded. + if (resources.album?.length > 0) { + const albumIds = new Set(resources.album) + trackResources.filter(isWatched).forEach((r) => { + const ids = Object.values(loadedResources?.[r]?.data || {}) + .filter((t) => albumIds.has(t?.albumId)) + .map((t) => t.id) + if (ids.length > 0) { + dataProvider.getMany(r, { ids }) + } }) } } diff --git a/ui/src/common/useResourceRefresh.test.js b/ui/src/common/useResourceRefresh.test.js index eabd478db..ccd729836 100644 --- a/ui/src/common/useResourceRefresh.test.js +++ b/ui/src/common/useResourceRefresh.test.js @@ -38,6 +38,27 @@ describe('useResourceRefresh', () => { const useDataProviderMock = () => ({ getMany }) let lastTime + // Applies each real selector against a constructed store, so both useSelector + // calls in the hook (refresh payload + loaded records) resolve correctly. + const mockStore = ({ refresh: refreshPayload, loaded = {} }) => { + const state = { + activity: { refresh: refreshPayload }, + admin: { resources: loaded }, + } + vi.spyOn(Redux, 'useSelector').mockImplementation((selector) => + selector(state), + ) + } + + // Turns id lists into the react-admin store shape: { album: { data: { 'al-1': {...} } } } + const asStore = (byResource) => + Object.fromEntries( + Object.entries(byResource).map(([r, ids]) => [ + r, + { data: Object.fromEntries(ids.map((id) => [id, { id }])) }, + ]), + ) + beforeEach(() => { vi.spyOn(React, 'useState').mockImplementation(useStateMock) vi.spyOn(RA, 'useRefresh').mockImplementation(useRefreshMock) @@ -50,8 +71,7 @@ describe('useResourceRefresh', () => { }) it('stores last time checked, to avoid redundant runs', () => { - const useSelectorMock = () => ({ lastReceived: lastTime }) - vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) + mockStore({ refresh: { lastReceived: lastTime } }) useResourceRefresh() @@ -60,8 +80,7 @@ describe('useResourceRefresh', () => { it("does not run again if lastTime didn't change", () => { vi.spyOn(React, 'useState').mockImplementation(() => [lastTime, setState]) - const useSelectorMock = () => ({ lastReceived: lastTime }) - vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) + mockStore({ refresh: { lastReceived: lastTime } }) useResourceRefresh() @@ -70,11 +89,9 @@ describe('useResourceRefresh', () => { describe('No visible resources specified', () => { it('triggers a UI refresh when received a "any" resource refresh', () => { - const useSelectorMock = () => ({ - lastReceived: lastTime, - resources: { '*': '*' }, + mockStore({ + refresh: { lastReceived: lastTime, resources: { '*': '*' } }, }) - vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) useResourceRefresh() @@ -83,11 +100,9 @@ describe('useResourceRefresh', () => { }) it('triggers a UI refresh when received an "any" id', () => { - const useSelectorMock = () => ({ - lastReceived: lastTime, - resources: { album: ['*'] }, + mockStore({ + refresh: { lastReceived: lastTime, resources: { album: ['*'] } }, }) - vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) useResourceRefresh() @@ -95,12 +110,14 @@ describe('useResourceRefresh', () => { expect(getMany).not.toHaveBeenCalled() }) - it('triggers a refetch of the resources received', () => { - const useSelectorMock = () => ({ - lastReceived: lastTime, - resources: { album: ['al-1', 'al-2'], song: ['sg-1', 'sg-2'] }, + it('refetches only the received resources already loaded in the store', () => { + mockStore({ + refresh: { + lastReceived: lastTime, + resources: { album: ['al-1', 'al-2'], song: ['sg-1', 'sg-2'] }, + }, + loaded: asStore({ album: ['al-1', 'al-2'], song: ['sg-1', 'sg-2'] }), }) - vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) useResourceRefresh() @@ -109,15 +126,42 @@ describe('useResourceRefresh', () => { expect(getMany).toHaveBeenCalledWith('album', { ids: ['al-1', 'al-2'] }) expect(getMany).toHaveBeenCalledWith('song', { ids: ['sg-1', 'sg-2'] }) }) + + it('skips ids that are not loaded in the store', () => { + mockStore({ + refresh: { + lastReceived: lastTime, + resources: { album: ['al-1', 'al-2', 'al-3'] }, + }, + loaded: asStore({ album: ['al-2'] }), + }) + + useResourceRefresh() + + expect(getMany).toHaveBeenCalledTimes(1) + expect(getMany).toHaveBeenCalledWith('album', { ids: ['al-2'] }) + }) + + it('does not fetch when none of the received ids are loaded', () => { + mockStore({ + refresh: { + lastReceived: lastTime, + resources: { artist: ['ar-1', 'ar-2'] }, + }, + loaded: asStore({ artist: ['ar-9'] }), + }) + + useResourceRefresh() + + expect(getMany).not.toHaveBeenCalled() + }) }) describe('Visible resources specified', () => { it('triggers a UI refresh when received a "any" resource refresh', () => { - const useSelectorMock = () => ({ - lastReceived: lastTime, - resources: { '*': '*' }, + mockStore({ + refresh: { lastReceived: lastTime, resources: { '*': '*' } }, }) - vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) useResourceRefresh('album') @@ -125,12 +169,101 @@ describe('useResourceRefresh', () => { expect(getMany).not.toHaveBeenCalled() }) - it('triggers a refetch of the resources received if they are visible', () => { - const useSelectorMock = () => ({ - lastReceived: lastTime, - resources: { album: ['al-1', 'al-2'], song: ['sg-1', 'sg-2'] }, + it('does not refresh the page when the wildcard is on a resource it does not watch', () => { + // Guards other senders: a wildcard on an unwatched resource must not reload this list. + mockStore({ + refresh: { + lastReceived: lastTime, + resources: { album: ['al-1', 'al-2'], song: ['*'] }, + }, + loaded: asStore({ album: ['al-1', 'al-2'] }), + }) + + useResourceRefresh('album') + + expect(refresh).not.toHaveBeenCalled() + expect(getMany).toHaveBeenCalledWith('album', { ids: ['al-1', 'al-2'] }) + }) + + it('refetches the loaded songs of a refreshed album', () => { + // A track with no art of its own is served its album's, so its coverArt id moves with the + // album's. The backend cannot know which tracks are loaded; the store can. + mockStore({ + refresh: { lastReceived: lastTime, resources: { album: ['al-1'] } }, + loaded: { + album: { data: { 'al-1': { id: 'al-1' } } }, + song: { + data: { + 'sg-1': { id: 'sg-1', albumId: 'al-1' }, + 'sg-2': { id: 'sg-2', albumId: 'al-2' }, + }, + }, + }, + }) + + useResourceRefresh('song') + + expect(refresh).not.toHaveBeenCalled() + expect(getMany).toHaveBeenCalledWith('song', { ids: ['sg-1'] }) + }) + + it('fans an album refresh out to loaded playlist tracks, which have their own ids', () => { + mockStore({ + refresh: { lastReceived: lastTime, resources: { album: ['al-1'] } }, + loaded: { + playlistTrack: { + data: { + 'pt-1': { id: 'pt-1', albumId: 'al-1' }, + 'pt-2': { id: 'pt-2', albumId: 'al-2' }, + }, + }, + }, + }) + + useResourceRefresh('playlistTrack', 'song', 'playlist') + + expect(refresh).not.toHaveBeenCalled() + expect(getMany).toHaveBeenCalledWith('playlistTrack', { ids: ['pt-1'] }) + }) + + it('does not fan out to track resources the component does not show', () => { + mockStore({ + refresh: { lastReceived: lastTime, resources: { album: ['al-1'] } }, + loaded: { + album: { data: { 'al-1': { id: 'al-1' } } }, + song: { data: { 'sg-1': { id: 'sg-1', albumId: 'al-1' } } }, + }, + }) + + useResourceRefresh('album') + + expect(refresh).not.toHaveBeenCalled() + expect(getMany).toHaveBeenCalledTimes(1) + expect(getMany).toHaveBeenCalledWith('album', { ids: ['al-1'] }) + }) + + it('does not refetch songs when no loaded song belongs to the refreshed album', () => { + mockStore({ + refresh: { lastReceived: lastTime, resources: { album: ['al-9'] } }, + loaded: { + song: { data: { 'sg-1': { id: 'sg-1', albumId: 'al-1' } } }, + }, + }) + + useResourceRefresh('song') + + expect(refresh).not.toHaveBeenCalled() + expect(getMany).not.toHaveBeenCalled() + }) + + it('refetches the received resources if they are visible and loaded', () => { + mockStore({ + refresh: { + lastReceived: lastTime, + resources: { album: ['al-1', 'al-2'], song: ['sg-1', 'sg-2'] }, + }, + loaded: asStore({ album: ['al-1', 'al-2'], song: ['sg-1', 'sg-2'] }), }) - vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) useResourceRefresh('song') diff --git a/ui/src/playlist/PlaylistDetails.jsx b/ui/src/playlist/PlaylistDetails.jsx index 894809ce0..5d0f6631e 100644 --- a/ui/src/playlist/PlaylistDetails.jsx +++ b/ui/src/playlist/PlaylistDetails.jsx @@ -1,10 +1,5 @@ -import { - Card, - CardContent, - CardMedia, - Typography, - useMediaQuery, -} from '@material-ui/core' +import { useState } from 'react' +import { Card, CardContent, Typography, useMediaQuery } from '@material-ui/core' import { makeStyles } from '@material-ui/core/styles' import { useTranslate } from 'react-admin' import Lightbox from 'react-image-lightbox' @@ -17,10 +12,9 @@ import { SizeField, isWritable, OverflowTooltip, - useImageLoadingState, } from '../common' -import config from '../config' import subsonic from '../subsonic' +import { Artwork } from '../common/Artwork' const useStyles = makeStyles( (theme) => ({ @@ -75,9 +69,6 @@ const useStyles = makeStyles( backgroundColor: 'transparent', transition: 'opacity 0.3s ease-in-out', }, - coverLoading: { - opacity: 0.5, - }, title: { overflow: 'hidden', textOverflow: 'ellipsis', @@ -107,37 +98,21 @@ const PlaylistDetails = (props) => { const translate = useTranslate() const classes = useStyles() const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('lg')) - const { - imageLoading, - imageError, - isLightboxOpen, - handleImageLoad, - handleImageError, - handleOpenLightbox, - handleCloseLightbox, - } = useImageLoadingState(record.id) + const [isLightboxOpen, setLightboxOpen] = useState(false) - const imageUrl = subsonic.getCoverArtUrl(record, config.uiCoverArtSize, true) const fullImageUrl = subsonic.getCoverArtUrl(record) return ( <Card className={classes.root}> <div className={classes.cardContents}> <div className={classes.coverParent}> - <CardMedia - key={record.id} // Force re-render when playlist changes - component={'img'} - src={imageUrl} - width="400" - height="400" - className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`} - onClick={handleOpenLightbox} - onLoad={handleImageLoad} - onError={handleImageError} + <Artwork + record={record} + square + fit="contain" + className={classes.cover} title={record.name} - style={{ - cursor: imageError ? 'default' : 'pointer', - }} + onClick={() => setLightboxOpen(true)} /> {isWritable(record.ownerId) && ( <ImageUploadOverlay @@ -187,13 +162,13 @@ const PlaylistDetails = (props) => { </CardContent> </div> </div> - {isLightboxOpen && !imageError && ( + {isLightboxOpen && ( <Lightbox imagePadding={50} animationDuration={200} imageTitle={record.name} mainSrc={fullImageUrl} - onCloseRequest={handleCloseLightbox} + onCloseRequest={() => setLightboxOpen(false)} /> )} </Card> diff --git a/ui/src/playlist/PlaylistList.jsx b/ui/src/playlist/PlaylistList.jsx index 642d90dd5..d2b17b108 100644 --- a/ui/src/playlist/PlaylistList.jsx +++ b/ui/src/playlist/PlaylistList.jsx @@ -20,7 +20,7 @@ import Switch from '@material-ui/core/Switch' import { makeStyles } from '@material-ui/core/styles' import { useMediaQuery } from '@material-ui/core' import { - CoverArtAvatar, + ArtworkAvatar, DurationField, List, LoveButton, @@ -197,7 +197,7 @@ const PlaylistList = (props) => { bulkActionButtons={!isXsmall && <PlaylistListBulkActions />} > <Datagrid rowClick="show" isRowSelectable={(r) => isWritable(r?.ownerId)}> - <CoverArtAvatar source="id" variant="square" /> + <ArtworkAvatar source="id" variant="square" /> <TextField source="name" /> {columns} <Writable> diff --git a/ui/src/playlist/PlaylistShow.jsx b/ui/src/playlist/PlaylistShow.jsx index 4e269be18..4c2fea315 100644 --- a/ui/src/playlist/PlaylistShow.jsx +++ b/ui/src/playlist/PlaylistShow.jsx @@ -35,7 +35,7 @@ const PlaylistShowLayout = (props) => { const { loading, ...context } = useShowContext(props) const { record } = context const classes = useStyles() - useResourceRefresh('song') + useResourceRefresh('playlistTrack', 'song') return ( <> diff --git a/ui/src/playlist/PlaylistSongs.jsx b/ui/src/playlist/PlaylistSongs.jsx index bbe38b4d5..4718a7cf4 100644 --- a/ui/src/playlist/PlaylistSongs.jsx +++ b/ui/src/playlist/PlaylistSongs.jsx @@ -100,7 +100,8 @@ const PlaylistSongs = ({ playlistId, readOnly, actions, ...props }) => { const dataProvider = useDataProvider() const notify = useNotify() const version = useVersion() - useResourceRefresh('song', 'playlist') + // The rows are stored as playlistTrack, not song: their ids are playlist entries. + useResourceRefresh('playlistTrack', 'song', 'playlist') useEffect(() => { setPage(1) diff --git a/ui/src/radio/RadioList.jsx b/ui/src/radio/RadioList.jsx index ccdb9f1ef..0cfb2cd68 100644 --- a/ui/src/radio/RadioList.jsx +++ b/ui/src/radio/RadioList.jsx @@ -18,11 +18,10 @@ import { List, defaultRowsPerPageOptions, getStoredPerPage, - useImageUrl, ToggleFieldsMenu, useSelectedFields, } from '../common' -import subsonic from '../subsonic' +import { Artwork } from '../common/Artwork' import { StreamField } from './StreamField' import { setTrack } from '../actions' import { songFromRadio } from './helper' @@ -83,15 +82,32 @@ const RadioListActions = ({ const avatarStyle = { width: 40, height: 40 } +const useCoverStyles = makeStyles({ + cover: { width: 40, height: 40, borderRadius: '4px' }, +}) + const CoverArtField = ({ record }) => { - const directUrl = record?.uploadedImage - ? subsonic.getCoverArtUrl(record, 40, true) - : null - const { imgUrl } = useImageUrl(directUrl) + const classes = useCoverStyles() if (!record) return null - const src = imgUrl || RADIO_PLACEHOLDER_IMAGE + // Radios resolve art only from an uploaded image; otherwise show the generic radio icon. + if (record.uploadedImage) { + return ( + <Artwork + record={record} + size={40} + square + className={classes.cover} + title={record.name} + /> + ) + } return ( - <Avatar src={src} variant="rounded" style={avatarStyle} alt={record.name} /> + <Avatar + src={RADIO_PLACEHOLDER_IMAGE} + variant="rounded" + style={avatarStyle} + alt={record.name} + /> ) } CoverArtField.defaultProps = { label: '' } diff --git a/ui/src/subsonic/index.js b/ui/src/subsonic/index.js index 7d93972e0..9f0ad28d7 100644 --- a/ui/src/subsonic/index.js +++ b/ui/src/subsonic/index.js @@ -81,25 +81,27 @@ const getAvatarUrl = (username, size) => ) const getCoverArtUrl = (record, size, square) => { + const suffix = record.imageHash ? '_' + record.imageHash : '' const options = { - ...(record.updatedAt && { _: record.updatedAt }), + // A hash-suffixed url is already pixel-versioned; the buster would defeat immutable caching. + ...(!record.imageHash && record.updatedAt && { _: record.updatedAt }), ...(size && { size }), ...(square && { square }), } // TODO Move this logic to server if (record.album) { - return baseUrl(url('getCoverArt', 'mf-' + record.id, options)) + return baseUrl(url('getCoverArt', 'mf-' + record.id + suffix, options)) } else if (record.albumArtist) { - return baseUrl(url('getCoverArt', 'al-' + record.id, options)) + return baseUrl(url('getCoverArt', 'al-' + record.id + suffix, options)) } else if (record.sync !== undefined) { // This is a playlist - return baseUrl(url('getCoverArt', 'pl-' + record.id, options)) + return baseUrl(url('getCoverArt', 'pl-' + record.id + suffix, options)) } else if (record.streamUrl !== undefined) { // This is a radio station - return baseUrl(url('getCoverArt', 'ra-' + record.id, options)) + return baseUrl(url('getCoverArt', 'ra-' + record.id + suffix, options)) } else { - return baseUrl(url('getCoverArt', 'ar-' + record.id, options)) + return baseUrl(url('getCoverArt', 'ar-' + record.id + suffix, options)) } } diff --git a/ui/src/subsonic/index.test.js b/ui/src/subsonic/index.test.js index ad4764c24..32c699a41 100644 --- a/ui/src/subsonic/index.test.js +++ b/ui/src/subsonic/index.test.js @@ -120,6 +120,47 @@ describe('getCoverArtUrl', () => { expect(url).toContain('ar-test-123') expect(url).not.toContain('_=') }) + + it('appends the content hash to the artwork id', () => { + const url = subsonic.getCoverArtUrl({ + id: 'album-123', + albumArtist: 'AA', + imageHash: '0123456789abcdef', + }) + expect(url).toContain('al-album-123_0123456789abcdef') + }) + + it('drops the updatedAt buster once the url is hash-versioned', () => { + const url = subsonic.getCoverArtUrl({ + id: 'album-123', + albumArtist: 'AA', + updatedAt: '2023-01-01T00:00:00Z', + imageHash: '0123456789abcdef', + }) + expect(url).toContain('al-album-123_0123456789abcdef') + expect(url).not.toContain('_=') + }) + + it('keeps the updatedAt buster while artwork is unresolved', () => { + const url = subsonic.getCoverArtUrl({ + id: 'album-123', + albumArtist: 'AA', + updatedAt: '2023-01-01T00:00:00Z', + }) + expect(url).toContain('al-album-123') + expect(url).toContain('_=') + }) + + it('still builds a url for known-absent artwork so the server placeholder renders', () => { + // Returning '' here made <img src=undefined> render as a broken icon; the server serves a + // proper placeholder for absent art, so the client must still request it. + const url = subsonic.getCoverArtUrl({ + id: 'album-123', + albumArtist: 'AA', + imageAbsent: true, + }) + expect(url).toContain('al-album-123') + }) }) describe('getDiscCoverArtUrl', () => { diff --git a/ui/src/utils/thumbhash.js b/ui/src/utils/thumbhash.js new file mode 100644 index 000000000..f4ec673d2 --- /dev/null +++ b/ui/src/utils/thumbhash.js @@ -0,0 +1,138 @@ +// ThumbHash decoder (https://github.com/evanw/thumbhash), kept byte-compatible with the reference: +// core/artwork/thumbhash/testdata/thumbhash.js pins the pixels the specs assert on. + +const toBytes = (hash) => { + if (typeof hash !== 'string' || hash === '') { + throw new Error('thumbhash: empty hash') + } + const binary = atob(hash) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i) + } + if (bytes.length < 5) { + throw new Error('thumbhash: hash too short') + } + return bytes +} + +// header unpacks the fixed 5-byte prefix plus the optional alpha byte. +const header = (bytes) => { + const header24 = bytes[0] | (bytes[1] << 8) | (bytes[2] << 16) + const header16 = bytes[3] | (bytes[4] << 8) + const hasAlpha = header24 >> 23 !== 0 + if (hasAlpha && bytes.length < 6) { + throw new Error('thumbhash: hash too short for alpha') + } + const isLandscape = header16 >> 15 !== 0 + const alphaLimit = hasAlpha ? 5 : 7 + return { + lDC: (header24 & 63) / 63, + pDC: ((header24 >> 6) & 63) / 31.5 - 1, + qDC: ((header24 >> 12) & 63) / 31.5 - 1, + lScale: ((header24 >> 18) & 31) / 31, + hasAlpha, + pScale: ((header16 >> 3) & 63) / 63, + qScale: ((header16 >> 9) & 63) / 63, + lx: Math.max(3, isLandscape ? alphaLimit : header16 & 7), + ly: Math.max(3, isLandscape ? header16 & 7 : alphaLimit), + aDC: hasAlpha ? (bytes[5] & 15) / 15 : 1, + aScale: hasAlpha ? (bytes[5] >> 4) / 15 : 0, + } +} + +// naturalSize is the reference decoder's own output size: 32 on the long edge, at the aspect the +// hash approximates. Callers with the true dimensions should decode at those instead. +export const naturalSize = (hash) => { + const { lx, ly } = header(toBytes(hash)) + const ratio = lx / ly + return ratio > 1 + ? { width: 32, height: Math.round(32 / ratio) } + : { width: Math.round(32 * ratio), height: 32 } +} + +const cosTable = (n, size) => { + const table = new Float64Array(n * size) + for (let c = 0; c < n; c++) { + for (let i = 0; i < size; i++) { + table[c * size + i] = Math.cos((Math.PI / size) * (i + 0.5) * c) + } + } + return table +} + +export const decode = (hash, width, height) => { + const bytes = toBytes(hash) + const h = header(bytes) + if (!(width > 0) || !(height > 0)) { + throw new Error('thumbhash: width and height must be positive') + } + + const acStart = h.hasAlpha ? 6 : 5 + let acIndex = 0 + const channel = (nx, ny, scale) => { + const ac = [] + for (let cy = 0; cy < ny; cy++) { + for (let cx = cy ? 0 : 1; cx * ny < nx * (ny - cy); cx++) { + const byte = bytes[acStart + (acIndex >> 1)] ?? 0 + ac.push((((byte >> ((acIndex++ & 1) << 2)) & 15) / 7.5 - 1) * scale) + } + } + return ac + } + // The 1.25x chroma boost is the reference's quantisation compensation, not a free parameter. + const lAC = channel(h.lx, h.ly, h.lScale) + const pAC = channel(3, 3, h.pScale * 1.25) + const qAC = channel(3, 3, h.qScale * 1.25) + const aAC = h.hasAlpha ? channel(5, 5, h.aScale) : [] + + const nx = Math.max(h.lx, h.hasAlpha ? 5 : 3) + const ny = Math.max(h.ly, h.hasAlpha ? 5 : 3) + const fx = cosTable(nx, width) + const fy = cosTable(ny, height) + + const pixels = new Uint8ClampedArray(width * height * 4) + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + let l = h.lDC + let p = h.pDC + let q = h.qDC + let a = h.aDC + + for (let cy = 0, j = 0; cy < h.ly; cy++) { + const fy2 = fy[cy * height + y] * 2 + for (let cx = cy ? 0 : 1; cx * h.ly < h.lx * (h.ly - cy); cx++, j++) { + l += lAC[j] * fx[cx * width + x] * fy2 + } + } + for (let cy = 0, j = 0; cy < 3; cy++) { + const fy2 = fy[cy * height + y] * 2 + for (let cx = cy ? 0 : 1; cx < 3 - cy; cx++, j++) { + const f = fx[cx * width + x] * fy2 + p += pAC[j] * f + q += qAC[j] * f + } + } + if (h.hasAlpha) { + for (let cy = 0, j = 0; cy < 5; cy++) { + const fy2 = fy[cy * height + y] * 2 + for (let cx = cy ? 0 : 1; cx < 5 - cy; cx++, j++) { + a += aAC[j] * fx[cx * width + x] * fy2 + } + } + } + + const b = l - (2 / 3) * p + const r = (3 * l - b + q) / 2 + const g = r - q + const idx = 4 * x + y * width * 4 + // Explicit floor: the reference writes into a Uint8Array, which truncates, while the + // Uint8ClampedArray that createImageData needs would round. + pixels[idx] = Math.floor(Math.max(0, 255 * Math.min(1, r))) + pixels[idx + 1] = Math.floor(Math.max(0, 255 * Math.min(1, g))) + pixels[idx + 2] = Math.floor(Math.max(0, 255 * Math.min(1, b))) + pixels[idx + 3] = Math.floor(Math.max(0, 255 * Math.min(1, a))) + } + } + return pixels +} diff --git a/ui/src/utils/thumbhash.test.js b/ui/src/utils/thumbhash.test.js new file mode 100644 index 000000000..6abb4d7e4 --- /dev/null +++ b/ui/src/utils/thumbhash.test.js @@ -0,0 +1,66 @@ +import { describe, it, expect } from 'vitest' +import { decode, naturalSize } from './thumbhash' + +// Digests pinned from evanw/thumbhash's reference decoder (vendored at +// core/artwork/thumbhash/testdata/thumbhash.js), so any drift from it fails here. +const fnv = (bytes) => { + let h = 2166136261 >>> 0 + for (const b of bytes) { + h ^= b + h = Math.imul(h, 16777619) >>> 0 + } + return h.toString(16) +} + +const REFERENCE = [ + ['alpha', 'JOiFBQ4nkIexh3p4iA8uB+lYhIeAh3d4dw==', 32, 32, '500141bc'], + ['landscape', '3wcOFJpwh4eBh3d4iIePgAj3hw==', 32, 18, '18465aed'], + ['portrait', '3/cNFBqBB4iId4d3d4iAjwj4hw==', 18, 32, '56500cc1'], + ['solid', 'HoUBBwB4eHeHd3hweId3h3h4B2+Ih4gA', 32, 32, '39cc5c5'], + ['square', 'H/gNBxpwh4dwd3eIiHd3iHeHeJ+dcH8I', 32, 32, '740d8afc'], + ['tiny', 'HoU9tx4I9wiIh4hwj3CI+AiIcH/494cP', 32, 32, 'f2555987'], +] + +describe('thumbhash decode', () => { + it.each(REFERENCE)( + 'reproduces the reference pixels for %s', + (_name, hash, w, h, digest) => { + expect(fnv(decode(hash, w, h))).toEqual(digest) + }, + ) + + it.each(REFERENCE)( + 'reports the reference natural size for %s', + (_name, hash, w, h) => { + expect(naturalSize(hash)).toEqual({ width: w, height: h }) + }, + ) + + it('decodes to any requested grid, not just the natural one', () => { + const [, hash] = REFERENCE[4] + const pixels = decode(hash, 8, 5) + expect(pixels).toHaveLength(8 * 5 * 4) + // Opaque hash: every alpha byte is saturated. + for (let i = 3; i < pixels.length; i += 4) { + expect(pixels[i]).toBe(255) + } + }) + + it('carries alpha through for a hash that has it', () => { + const [, hash, w, h] = REFERENCE[0] + const pixels = decode(hash, w, h) + const alphas = new Set() + for (let i = 3; i < pixels.length; i += 4) { + alphas.add(pixels[i]) + } + expect(alphas.size).toBeGreaterThan(1) + }) + + it.each([ + ['empty', ''], + ['too short to hold a header', 'AAAA'], + ['not base64', '!!!not-a-thumbhash!!!'], + ])('throws on a hash that is %s', (_name, hash) => { + expect(() => decode(hash, 32, 32)).toThrow() + }) +}) diff --git a/utils/cache/file_caches.go b/utils/cache/file_caches.go index ed2374696..33fa046df 100644 --- a/utils/cache/file_caches.go +++ b/utils/cache/file_caches.go @@ -2,8 +2,10 @@ package cache import ( "context" + "errors" "fmt" "io" + "io/fs" "path/filepath" "sync" "sync/atomic" @@ -158,6 +160,13 @@ func (fc *fileCache) Get(ctx context.Context, arg Item) (*CachedStream, error) { key := arg.Key() r, w, err := fc.cache.Get(key) + if errors.Is(err, fs.ErrNotExist) { + // The entry outlived its data file. Drop it and retry, or every future Get + // for this key fails for the rest of the process's life. + log.Debug(ctx, "Cache entry lost its data file. Re-fetching", "cache", fc.name, "key", key) + _ = fc.invalidate(ctx, key) + r, w, err = fc.cache.Get(key) + } if err != nil { return nil, err } diff --git a/utils/cache/file_caches_test.go b/utils/cache/file_caches_test.go index edcfbc6b9..7ce1b0393 100644 --- a/utils/cache/file_caches_test.go +++ b/utils/cache/file_caches_test.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "strings" + "sync/atomic" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" @@ -278,6 +279,92 @@ var _ = Describe("File Caches", func() { }).Should(BeTrue()) }) }) + + Context("entry outliving its data file", func() { + It("re-fetches when the data file vanished behind the cache's back", func() { + var calls atomic.Int32 + fc := callNewFileCache("test", "10MB", "test", 0, func(ctx context.Context, arg Item) (io.Reader, error) { + calls.Add(1) + return strings.NewReader("payload"), nil + }) + + s, err := fc.Get(context.Background(), &testArg{"vanish"}) + Expect(err).To(BeNil()) + Expect(io.ReadAll(s)).To(Equal([]byte("payload"))) + Expect(s.Close()).To(Succeed()) + + dataPath := fcSpreadFS(fc).KeyMapper((&testArg{"vanish"}).Key()) + Eventually(func() error { _, e := os.Stat(dataPath); return e }).Should(Succeed()) + Expect(os.Remove(dataPath)).To(Succeed()) + + s2, err := fc.Get(context.Background(), &testArg{"vanish"}) + Expect(err).ToNot(HaveOccurred()) + Expect(io.ReadAll(s2)).To(Equal([]byte("payload"))) + _ = s2.Close() + Expect(calls.Load()).To(BeNumerically("==", 2)) + }) + + It("survives an invalidated entry's deferred file removal", func() { + // invalidate() drops the map entry but defers the unlink until readers close; + // a Get in that window re-creates the file, which the deferred unlink then eats. + var n atomic.Int32 + fc := callNewFileCache("test", "10MB", "test", 0, func(ctx context.Context, arg Item) (io.Reader, error) { + if n.Add(1) == 1 { + return &partialThenErrReader{data: []byte("PARTIAL"), err: errors.New("died")}, nil + } + return strings.NewReader("GOOD"), nil + }) + + key := (&testArg{"deferred"}).Key() + s1, err := fc.Get(context.Background(), &testArg{"deferred"}) + Expect(err).To(BeNil()) + + // The failed write invalidates the entry; the removal now waits on s1. + Eventually(func() bool { return fc.cache.Exists(key) }).Should(BeFalse()) + + s2, err := fc.Get(context.Background(), &testArg{"deferred"}) + Expect(err).To(BeNil()) + Expect(io.ReadAll(s2)).To(Equal([]byte("GOOD"))) + Expect(s2.Close()).To(Succeed()) + + Expect(s1.Close()).To(Succeed()) + + dataPath := fcSpreadFS(fc).KeyMapper(key) + Eventually(func() bool { + _, e := os.Stat(dataPath) + return os.IsNotExist(e) + }).Should(BeTrue(), "expected the deferred removal to take the re-created file") + + s3, err := fc.Get(context.Background(), &testArg{"deferred"}) + Expect(err).ToNot(HaveOccurred()) + Expect(io.ReadAll(s3)).To(Equal([]byte("GOOD"))) + _ = s3.Close() + }) + + It("re-fetches when an adopted entry's data file vanished", func() { + // Entries adopted on startup take a different code path than in-process ones. + var calls atomic.Int32 + fc := callNewFileCache("test", "10MB", "test", 0, func(ctx context.Context, arg Item) (io.Reader, error) { + calls.Add(1) + return strings.NewReader("payload"), nil + }) + dataPath := fcSpreadFS(fc).KeyMapper((&testArg{"adopted"}).Key()) + Expect(os.MkdirAll(filepath.Dir(dataPath), 0755)).To(Succeed()) + Expect(os.WriteFile(dataPath, []byte("payload"), 0600)).To(Succeed()) + Expect(fcSpreadFS(fc).MarkComplete(dataPath)).To(Succeed()) + + adopted := callNewFileCache("test2", "10MB", "test", 0, func(ctx context.Context, arg Item) (io.Reader, error) { + calls.Add(1) + return strings.NewReader("payload"), nil + }) + Expect(os.Remove(dataPath)).To(Succeed()) + + s, err := adopted.Get(context.Background(), &testArg{"adopted"}) + Expect(err).ToNot(HaveOccurred()) + Expect(io.ReadAll(s)).To(Equal([]byte("payload"))) + _ = s.Close() + }) + }) }) }) diff --git a/utils/cache/spread_fs.go b/utils/cache/spread_fs.go index 647439790..11801b324 100644 --- a/utils/cache/spread_fs.go +++ b/utils/cache/spread_fs.go @@ -128,7 +128,7 @@ func (sfs *spreadFS) Create(name string) (stream.File, error) { if err != nil { return nil, err } - return os.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) + return createDataFile(name) } func (sfs *spreadFS) Open(name string) (stream.File, error) { diff --git a/utils/cache/spread_fs_test.go b/utils/cache/spread_fs_test.go index 0f88d3a58..d0b54567d 100644 --- a/utils/cache/spread_fs_test.go +++ b/utils/cache/spread_fs_test.go @@ -1,10 +1,13 @@ package cache import ( + "io" "os" "path/filepath" + "runtime" "strings" + "github.com/djherbis/stream" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -39,6 +42,39 @@ var _ = Describe("Spread FS", func() { }) }) + Describe("Create", func() { + It("leaves an already-open reader's bytes intact", func() { + // Shrinking a file an older stream still serves spins its reader at a premature EOF. + if runtime.GOOS == "windows" { + Skip("Windows cannot unlink a file with open handles, so Create reuses the inode") + } + name := filepath.Join(rootDir, "aa", "bb", "data") + s, err := stream.NewStream(name, fs) + Expect(err).To(BeNil()) + _, err = s.Write([]byte("PARTIAL")) + Expect(err).To(BeNil()) + r, err := s.NextReader() + Expect(err).To(BeNil()) + Expect(s.Close()).To(Succeed()) + + f, err := fs.Create(name) + Expect(err).To(BeNil()) + _, err = f.Write([]byte("GOOD")) + Expect(err).To(BeNil()) + Expect(f.Close()).To(Succeed()) + + done := make(chan []byte, 1) + go func() { + b, _ := io.ReadAll(r) + done <- b + }() + Eventually(done).Should(Receive(Equal([]byte("PARTIAL")))) + Expect(r.Close()).To(Succeed()) + + Expect(os.ReadFile(name)).To(Equal([]byte("GOOD"))) + }) + }) + Describe("MarkComplete / Remove markers", func() { It("creates a .complete marker for a data file", func() { data := fs.KeyMapper("song1") diff --git a/utils/cache/spread_fs_unix.go b/utils/cache/spread_fs_unix.go new file mode 100644 index 000000000..c7cb19cf8 --- /dev/null +++ b/utils/cache/spread_fs_unix.go @@ -0,0 +1,18 @@ +//go:build !windows + +package cache + +import ( + "os" + + "github.com/djherbis/stream" +) + +// createDataFile unlinks instead of truncating, so a re-created entry gets a fresh +// inode and readers still holding the old one see its full contents. +func createDataFile(name string) (stream.File, error) { + if err := os.Remove(name); err != nil && !os.IsNotExist(err) { + return nil, err + } + return os.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0600) +} diff --git a/utils/cache/spread_fs_windows.go b/utils/cache/spread_fs_windows.go new file mode 100644 index 000000000..ca4186d65 --- /dev/null +++ b/utils/cache/spread_fs_windows.go @@ -0,0 +1,15 @@ +//go:build windows + +package cache + +import ( + "os" + + "github.com/djherbis/stream" +) + +// createDataFile truncates in place: Windows refuses to unlink a file another handle +// has open, and failing the create would break every miss on a path with a live reader. +func createDataFile(name string) (stream.File, error) { + return os.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) +} From 7993fb9158196c0ee33136ce54dee07a26dfd215 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Mon, 10 Aug 2026 11:42:27 -0400 Subject: [PATCH 59/71] perf(persistence): use *_artists join tables for artist participant filters (#5930) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(persistence): use *_artists join tables for artist participant filters The artist_id/artists_id, role_<role>_id and role_total_id filters, plus the AlbumsByArtistID/AlbumsByContributingArtistID/SongsByArtistID helpers, scanned every album or media_file row through json_tree(participants, ...), which no index can serve — the cause of multi-second artist pages on large libraries (discussion #5929). Rewrite them to semi-join the album_artists and media_file_artists tables via a shared ParticipantIDFilter helper. The join tables are written in the same transaction as the participants JSON, so results are unchanged. album_artists' unique constraint led with album_id, so artist-driven lookups had no usable index. Rebuild the table with the constraint reordered to (artist_id, album_id, role, sub_role), mirroring media_file_artists, instead of adding a fourth index: measured within 4% of a dedicated covering index (geomean -92.5% vs json_tree on a 96k-track production copy) while saving ~7MiB and per-scan write amplification. Album-side consumers (participant rewrites, FK cascades, markMissing) keep using album_artists_album_id, and updateParticipants' ON CONFLICT target already names artist_id first. The rebuild is linear work: 1.1s on a 113k-row production copy. * chore(gitignore): add temp benchmark files to ignore list * fix(persistence): clear album_artists when an album is saved without participants albumRepository.Put skipped updateParticipants when the Participants map was empty, so a hypothetical save with no participants would write {} to the JSON column but leave stale album_artists rows behind, now visible through the semi-join filters. No current caller can hit this (albums built by MediaFiles.ToAlbum always have participants), but make Put unconditional anyway, matching mediaFileRepository.Put, so the join table always moves with the JSON. Raised by Codex review on #5930. --- .gitignore | 3 + ...eorder_album_artists_unique_constraint.sql | 59 +++++++++++++ persistence/album_repository.go | 19 ++-- persistence/album_repository_test.go | 87 ++++++++++++++++--- persistence/mediafile_repository.go | 6 +- persistence/sql_participations.go | 24 +++++ server/filter/filters.go | 22 ++--- server/jellyfin/items_test.go | 6 +- 8 files changed, 184 insertions(+), 42 deletions(-) create mode 100644 db/migrations/20260810143445_reorder_album_artists_unique_constraint.sql diff --git a/.gitignore b/.gitignore index 3567a7d90..6459ded9b 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,6 @@ openspec/ go.work* .worktrees/ .playwright-mcp/ + +# Temp benchmark files +zz_*_test.go \ No newline at end of file diff --git a/db/migrations/20260810143445_reorder_album_artists_unique_constraint.sql b/db/migrations/20260810143445_reorder_album_artists_unique_constraint.sql new file mode 100644 index 000000000..fd1598e93 --- /dev/null +++ b/db/migrations/20260810143445_reorder_album_artists_unique_constraint.sql @@ -0,0 +1,59 @@ +-- +goose Up +-- +goose StatementBegin +-- Lead the unique constraint with artist_id (mirroring media_file_artists) so artist-driven +-- filters and the artist-delete cascade get an index seek; album_id lookups keep album_artists_album_id. +CREATE TABLE album_artists_tmp +( + album_id varchar not null + REFERENCES album (id) + ON DELETE CASCADE, + artist_id varchar not null + REFERENCES artist (id) + ON DELETE CASCADE, + role varchar default '' not null, + sub_role varchar default '' not null, + CONSTRAINT album_artists + UNIQUE (artist_id, album_id, role, sub_role) +); + +INSERT INTO album_artists_tmp(album_id, artist_id, role, sub_role) +SELECT album_id, artist_id, role, sub_role +FROM album_artists; + +DROP TABLE album_artists; +ALTER TABLE album_artists_tmp RENAME TO album_artists; + +CREATE INDEX album_artists_album_id + ON album_artists (album_id); +CREATE INDEX album_artists_role + ON album_artists (role); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +CREATE TABLE album_artists_tmp +( + album_id varchar not null + REFERENCES album (id) + ON DELETE CASCADE, + artist_id varchar not null + REFERENCES artist (id) + ON DELETE CASCADE, + role varchar default '' not null, + sub_role varchar default '' not null, + CONSTRAINT album_artists + UNIQUE (album_id, artist_id, role, sub_role) +); + +INSERT INTO album_artists_tmp(album_id, artist_id, role, sub_role) +SELECT album_id, artist_id, role, sub_role +FROM album_artists; + +DROP TABLE album_artists; +ALTER TABLE album_artists_tmp RENAME TO album_artists; + +CREATE INDEX album_artists_album_id + ON album_artists (album_id); +CREATE INDEX album_artists_role + ON album_artists (role); +-- +goose StatementEnd diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 1ebb09751..91e90127c 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -172,24 +172,22 @@ func yearFilter(_ string, value any) Sqlizer { } func artistFilter(_ string, value any) Sqlizer { - return Or{ - Exists("json_tree(participants, '$.albumartist')", Eq{"value": value}), - Exists("json_tree(participants, '$.artist')", Eq{"value": value}), - } + return ParticipantIDFilter("album", value, model.RoleAlbumArtist, model.RoleArtist) } func artistRoleFilter(name string, value any) Sqlizer { roleName := strings.TrimSuffix(strings.TrimPrefix(name, "role_"), "_id") // Check if the role name is valid. If not, return an invalid filter - if _, ok := model.AllRoles[roleName]; !ok { + role, ok := model.AllRoles[roleName] + if !ok { return Gt{"": nil} } - return Exists(fmt.Sprintf("json_tree(participants, '$.%s')", roleName), Eq{"value": value}) + return ParticipantIDFilter("album", value, role) } func allRolesFilter(_ string, value any) Sqlizer { - return Like{"participants": fmt.Sprintf(`%%"%s"%%`, value)} + return ParticipantIDFilter("album", value) } func (r *albumRepository) CountAll(options ...model.QueryOptions) (int64, error) { @@ -214,12 +212,7 @@ func (r *albumRepository) Put(al *model.Album) error { return err } al.ID = id - if len(al.Participants) > 0 { - if err = r.updateParticipants(al.ID, al.Participants); err != nil { - return err - } - } - return nil + return r.updateParticipants(al.ID, al.Participants) } // TODO Move external metadata to a separated table diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index 1ead89529..061083949 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -527,19 +527,16 @@ var _ = Describe("AlbumRepository", func() { Describe("artistRoleFilter", func() { DescribeTable("creates correct SQL expressions for artist roles", - func(filterName, artistID, expectedSQL string) { + func(filterName, artistID, expectedRole string) { sqlizer := artistRoleFilter(filterName, artistID) sql, args, err := sqlizer.ToSql() Expect(err).ToNot(HaveOccurred()) - Expect(sql).To(Equal(expectedSQL)) - Expect(args).To(Equal([]any{artistID})) + Expect(sql).To(Equal("album.id IN (SELECT album_id FROM album_artists WHERE artist_id = ? AND role IN (?))")) + Expect(args).To(Equal([]any{artistID, expectedRole})) }, - Entry("artist role", "role_artist_id", "123", - "exists (select 1 from json_tree(participants, '$.artist') where value = ?)"), - Entry("albumartist role", "role_albumartist_id", "456", - "exists (select 1 from json_tree(participants, '$.albumartist') where value = ?)"), - Entry("composer role", "role_composer_id", "789", - "exists (select 1 from json_tree(participants, '$.composer') where value = ?)"), + Entry("artist role", "role_artist_id", "123", "artist"), + Entry("albumartist role", "role_albumartist_id", "456", "albumartist"), + Entry("composer role", "role_composer_id", "789", "composer"), ) It("works with the actual filter map", func() { @@ -553,8 +550,8 @@ var _ = Describe("AlbumRepository", func() { sqlizer := filterFunc(filterName, "test-id") sql, args, err := sqlizer.ToSql() Expect(err).ToNot(HaveOccurred()) - Expect(sql).To(Equal(fmt.Sprintf("exists (select 1 from json_tree(participants, '$.%s') where value = ?)", roleName))) - Expect(args).To(Equal([]any{"test-id"})) + Expect(sql).To(Equal("album.id IN (SELECT album_id FROM album_artists WHERE artist_id = ? AND role IN (?))")) + Expect(args).To(Equal([]any{"test-id", roleName})) } }) @@ -644,6 +641,74 @@ var _ = Describe("AlbumRepository", func() { _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID})) }) + It("finds albums through the participant-based filters", func() { + artist := &model.Artist{ID: "filter-artist-1", Name: "Filter Artist", OrderArtistName: "filter artist"} + Expect(createArtistWithLibrary(artistRepo, artist, 1)).To(Succeed()) + + album := &model.Album{ + LibraryID: 1, + ID: "filter-album-1", + Name: "Filter Album", + AlbumArtistID: artist.ID, + AlbumArtist: artist.Name, + Participants: model.Participants{ + model.RoleAlbumArtist: {{Artist: model.Artist{ID: artist.ID, Name: artist.Name}}}, + model.RoleComposer: {{Artist: model.Artist{ID: artist.ID, Name: artist.Name}}}, + }, + } + Expect(albumRepo.Put(album)).To(Succeed()) + + byArtist, err := albumRepo.GetAll(model.QueryOptions{Filters: artistFilter("artist_id", artist.ID)}) + Expect(err).ToNot(HaveOccurred()) + Expect(byArtist).To(HaveLen(1)) + Expect(byArtist[0].ID).To(Equal(album.ID)) + + byComposer, err := albumRepo.GetAll(model.QueryOptions{Filters: artistRoleFilter("role_composer_id", artist.ID)}) + Expect(err).ToNot(HaveOccurred()) + Expect(byComposer).To(HaveLen(1)) + + byLyricist, err := albumRepo.GetAll(model.QueryOptions{Filters: artistRoleFilter("role_lyricist_id", artist.ID)}) + Expect(err).ToNot(HaveOccurred()) + Expect(byLyricist).To(BeEmpty()) + + byAnyRole, err := albumRepo.GetAll(model.QueryOptions{Filters: allRolesFilter("role_total_id", artist.ID)}) + Expect(err).ToNot(HaveOccurred()) + Expect(byAnyRole).To(HaveLen(1)) + + count, err := albumRepo.CountAll(model.QueryOptions{Filters: artistFilter("artist_id", artist.ID)}) + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(Equal(int64(1))) + + _, _ = artistRepo.executeSQL(squirrel.Delete("artist").Where(squirrel.Eq{"id": artist.ID})) + _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID})) + }) + + It("clears album_artists rows when saved with empty participants", func() { + artist := &model.Artist{ID: "clear-artist-1", Name: "Clear Artist", OrderArtistName: "clear artist"} + Expect(createArtistWithLibrary(artistRepo, artist, 1)).To(Succeed()) + + album := &model.Album{ + LibraryID: 1, + ID: "clear-album-1", + Name: "Clear Album", + AlbumArtistID: artist.ID, + AlbumArtist: artist.Name, + Participants: model.Participants{ + model.RoleAlbumArtist: {{Artist: model.Artist{ID: artist.ID, Name: artist.Name}}}, + }, + } + DeferCleanup(func() { + _, _ = artistRepo.executeSQL(squirrel.Delete("artist").Where(squirrel.Eq{"id": artist.ID})) + _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID})) + }) + Expect(albumRepo.Put(album)).To(Succeed()) + verifyAlbumArtists(album.ID, []albumArtistRecord{{ArtistID: artist.ID, Role: "albumartist", SubRole: ""}}) + + album.Participants = model.Participants{} + Expect(albumRepo.Put(album)).To(Succeed()) + verifyAlbumArtists(album.ID, []albumArtistRecord{}) + }) + It("filters out invalid artist IDs leaving only valid participants in database", func() { // Create two real artists in the database artist1 := &model.Artist{ diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index df1472c2a..f372b30f5 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -113,7 +113,7 @@ var mediaFileFilter = sync.OnceValue(func() map[string]filterFunc { "has_rating": annotationBoolFilter("rating"), "genre_id": tagIDFilter, "missing": booleanFilter, - "artists_id": artistFilter, + "artists_id": mediaFileArtistFilter, "library_id": libraryIdFilter, "path": startsWithFilter("media_file.path"), } @@ -126,6 +126,10 @@ var mediaFileFilter = sync.OnceValue(func() map[string]filterFunc { return filters }) +func mediaFileArtistFilter(_ string, value any) Sqlizer { + return ParticipantIDFilter("media_file", value, model.RoleAlbumArtist, model.RoleArtist) +} + func mediaFileRecentlyAddedSort() string { if conf.Server.RecentlyAddedByModTime { return "media_file.updated_at, media_file.id" diff --git a/persistence/sql_participations.go b/persistence/sql_participations.go index 38b0203fa..746abed01 100644 --- a/persistence/sql_participations.go +++ b/persistence/sql_participations.go @@ -22,6 +22,30 @@ type flatParticipant struct { SubRole string `json:"sub_role,omitempty"` } +// ParticipantIDFilter matches rows of table where the artist participates in any of the given roles +// (any role when empty). Semi-joins <table>_artists; json_tree over the JSON is far slower at scale. +func ParticipantIDFilter(table string, artistID any, roles ...model.Role) Sqlizer { + return participantIDFilter(table, artistID, false, roles) +} + +// NotParticipantIDFilter is the negation of ParticipantIDFilter. +func NotParticipantIDFilter(table string, artistID any, roles ...model.Role) Sqlizer { + return participantIDFilter(table, artistID, true, roles) +} + +func participantIDFilter(table string, artistID any, negate bool, roles []model.Role) Sqlizer { + sel := Select(table + "_id").From(table + "_artists").Where(Eq{"artist_id": artistID}) + if len(roles) > 0 { + sel = sel.Where(Eq{"role": slice.Map(roles, func(r model.Role) string { return r.String() })}) + } + sql, args, _ := sel.ToSql() + op := " IN (" + if negate { + op = " NOT IN (" + } + return Expr(table+".id"+op+sql+")", args...) +} + func marshalParticipants(participants model.Participants) string { dbParticipants := make(map[model.Role][]participant) for role, artists := range participants { diff --git a/server/filter/filters.go b/server/filter/filters.go index 067f7b1f9..62d8f0523 100644 --- a/server/filter/filters.go +++ b/server/filter/filters.go @@ -47,17 +47,13 @@ func AlbumsByArtist() Options { } func AlbumsByArtistID(artistId string) Options { - filters := []Sqlizer{ - persistence.Exists("json_tree(participants, '$.albumartist')", Eq{"value": artistId}), - } + roles := []model.Role{model.RoleAlbumArtist} if conf.Server.Subsonic.ArtistParticipations { - filters = append(filters, - persistence.Exists("json_tree(participants, '$.artist')", Eq{"value": artistId}), - ) + roles = append(roles, model.RoleArtist) } return addDefaultFilters(Options{ Sort: "max_year", - Filters: Or(filters), + Filters: persistence.ParticipantIDFilter("album", artistId, roles...), }) } @@ -68,8 +64,8 @@ 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}), + persistence.ParticipantIDFilter("album", artistId, model.RoleArtist), + persistence.NotParticipantIDFilter("album", artistId, model.RoleAlbumArtist), }, }) } @@ -104,13 +100,11 @@ 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. +// album order. 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()), + Sort: "album", + Filters: persistence.ParticipantIDFilter("media_file", artistId, model.RoleArtist, model.RoleAlbumArtist), }) } diff --git a/server/jellyfin/items_test.go b/server/jellyfin/items_test.go index 401145461..4eddcc654 100644 --- a/server/jellyfin/items_test.go +++ b/server/jellyfin/items_test.go @@ -219,7 +219,7 @@ var _ = Describe("Items", func() { albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) sql, _, err := albumRepo.Options.Filters.ToSql() Expect(err).NotTo(HaveOccurred()) - Expect(sql).To(ContainSubstring("json_tree")) + Expect(sql).To(ContainSubstring("album_artists")) }) It("lists artists when IncludeItemTypes=MusicArtist", func() { @@ -708,7 +708,7 @@ var _ = Describe("Items", func() { 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).NotTo(ContainSubstring("album_artists")) // not treated as an artist-parent filter Expect(sql).To(ContainSubstring("library_id")) Expect(args).To(ContainElement(2)) }) @@ -724,7 +724,7 @@ var _ = Describe("Items", func() { 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")) + Expect(sql).To(ContainSubstring("album_artists")) // ...while still scoping to the user's own accessible libraries. Expect(sql).To(ContainSubstring("library_id")) Expect(args).To(ContainElement(1)) From 7736bbb545da8c3fdfb3dadcd68cb7b1714baf86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Mon, 10 Aug 2026 14:10:05 -0400 Subject: [PATCH 60/71] fix(cache): write the completion marker before closing the cache writer (#5927) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cache): write the completion marker before closing the cache writer Readers of an in-progress cache write see EOF the moment the writer closes, but the .complete marker was created after the close, on the background goroutine — so a fully-read stream did not mean the cache was done touching disk. The new artwork precache spec ends right at EOF, and its GinkgoT().TempDir() cleanup raced the marker creation, failing the Windows CI job with 'unlinkat ...: The directory is not empty' (the race also reproduces on macOS, 2 of 3 runs, with the tightened test). Writing the marker after a clean copy but before Close makes reader-EOF imply every on-disk write for the entry is finished. A failed writer Close still invalidates the entry, which removes both the marker and the data file. The existing marker test now asserts the marker exists immediately at EOF instead of Eventually. * fix(artwork): never dispatch queue items after the drain context is cancelled The 10x Windows stress run for the previous commit surfaced a second flake in the same package: 'leaves undispatched items queued when cancelled mid-batch' lost row alc7 in 4 of 10 runs. In drain, when a semaphore slot is free and the context is already cancelled, both cases of the blocking select are ready and Go picks one at random — so a cancelled drain could still dispatch items. A non-blocking Done check before the select gives cancellation priority. The race was invisible on Linux/macOS only by accident: the spec seeded the album repo with a single album (each SetData overwrote the last), so only the final row (alc7) resolved to absent and got deleted when dispatched; the others fell on the retry path and survived. Nanosecond enqueue timestamps made alc0 always first out of the mock dequeue, masking the race, while Windows' coarse clock ties the timestamps and randomizes the order. The spec now seeds all eight albums, which made the race reproduce locally on the first try (row alc0) and now guards the fix on every platform. * test: give cache-init waits a 10s timeout for loaded CI runners A 10x parallel Windows stress run timed out one artwork spec in BeforeEach: the FileCache init goroutine (mkdir + reload walk) took over Gomega's default 1s Eventually timeout under shared-runner disk contention. Bump the three identical init waits (two artwork suites and the utils/cache helper) to 10s. * test(scanner): widen watcher debounce margins for loaded CI runners The watcher debouncing spec asserts 'no scan yet' inside 20ms Consistently windows while the debounce wait was only 50ms — a 2.5x margin that a loaded Windows runner blows through by delaying the timer-reset notification, firing the scan early (failed all three FlakeAttempts in a 10x stress run). Raise the test debounce wait to 200ms (10x the observation windows) and the scan-fired Eventually timeouts to 2s to match. * refactor(artwork): collapse drain cancellation into a single exit path Replace the non-blocking ctx pre-check plus duplicated select exit with one select and a ctx.Err() check after it. Besides removing the duplication, this closes the residual race: a cancellation landing between the two selects could still let the blocking select randomly pick the free semaphore slot and dispatch the item. Now a dispatch is only possible when the context was live after slot acquisition. * style: trim flaky-test fix comments to single lines Compress each two-line comment added by this PR to the one line that carries the invariant; drop the narration around it. --- core/artwork/artwork_test.go | 2 +- core/artwork/worker.go | 5 ++++- core/artwork/worker_test.go | 8 ++++++-- scanner/watcher_test.go | 19 ++++++++++--------- utils/cache/file_caches.go | 10 ++++++---- utils/cache/file_caches_test.go | 10 +++++----- 6 files changed, 32 insertions(+), 22 deletions(-) diff --git a/core/artwork/artwork_test.go b/core/artwork/artwork_test.go index c6b848101..7de5475d6 100644 --- a/core/artwork/artwork_test.go +++ b/core/artwork/artwork_test.go @@ -106,7 +106,7 @@ var _ = Describe("Artwork", func() { func(ctx context.Context, arg cache.Item) (io.Reader, error) { return arg.(artworkReader).Reader(ctx) }) - Eventually(func() bool { return imgCache.Available(ctx) }).Should(BeTrue()) + Eventually(func() bool { return imgCache.Available(ctx) }, 10*time.Second).Should(BeTrue()) svc = NewArtwork(ds, imgCache, store, ffm) }) diff --git a/core/artwork/worker.go b/core/artwork/worker.go index 3042c47b0..3ded52629 100644 --- a/core/artwork/worker.go +++ b/core/artwork/worker.go @@ -170,8 +170,11 @@ func (w *Worker) drain(ctx context.Context, concurrency int, kinds ...string) (i select { case sem <- struct{}{}: case <-ctx.Done(): + } + // select picks randomly when both cases are ready, so re-check to never dispatch after cancellation. + if ctx.Err() != nil { wg.Wait() - return len(items), nil + return len(items), nil //nolint:nilerr // a cancelled drain is a clean stop, not an error } wg.Go(func() { defer func() { <-sem }() diff --git a/core/artwork/worker_test.go b/core/artwork/worker_test.go index 1f3b52840..5b6e42885 100644 --- a/core/artwork/worker_test.go +++ b/core/artwork/worker_test.go @@ -151,7 +151,8 @@ var _ = Describe("Worker", func() { func(ctx context.Context, arg cache.Item) (io.Reader, error) { return arg.(artworkReader).Reader(ctx) })} - Eventually(func() bool { return imgCache.Available(ctx) }).Should(BeTrue()) + // Init walks the cache dir on a goroutine; loaded CI runners can take >1s. + Eventually(func() bool { return imgCache.Available(ctx) }, 10*time.Second).Should(BeTrue()) w = NewWorker(ds, store, ag, ffm, broker, imgCache) }) @@ -706,13 +707,16 @@ var _ = Describe("Worker", func() { Describe("batching", func() { It("leaves undispatched items queued when cancelled mid-batch", func() { + // Every album must resolve, so any dispatched item deletes its row regardless of dequeue order. + albums := model.Albums{} for i := range 8 { id := fmt.Sprintf("alc%d", i) - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: id, Name: "Album"}}) + albums = append(albums, model.Album{ID: id, Name: "Album"}) Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ ItemKind: "al", ItemID: id, Priority: model.ArtworkPriorityScan, })).To(Succeed()) } + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(albums) cancelledCtx, cancel := context.WithCancel(ctx) cancel() diff --git a/scanner/watcher_test.go b/scanner/watcher_test.go index 15e49e195..12c94602c 100644 --- a/scanner/watcher_test.go +++ b/scanner/watcher_test.go @@ -26,7 +26,8 @@ var _ = Describe("Watcher", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) - conf.Server.Scanner.WatcherWait = 50 * time.Millisecond // Short wait for tests + // Must dwarf the 20ms Consistently windows below, or a loaded runner's delayed timer reset flakes the debouncing spec. + conf.Server.Scanner.WatcherWait = 200 * time.Millisecond ctx, cancel = context.WithCancel(GinkgoT().Context()) DeferCleanup(cancel) @@ -91,7 +92,7 @@ var _ = Describe("Watcher", func() { return nil } return calls[0].Targets - }, 500*time.Millisecond, 10*time.Millisecond).Should(HaveLen(2)) + }, 2*time.Second, 10*time.Millisecond).Should(HaveLen(2)) // Verify targets calls := mockScanner.GetScanFoldersCalls() @@ -111,7 +112,7 @@ var _ = Describe("Watcher", func() { // Wait for watcher to process and trigger scan Eventually(func() int { return mockScanner.GetScanFoldersCallCount() - }, 500*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) + }, 2*time.Second, 10*time.Millisecond).Should(Equal(1)) // Verify the target calls := mockScanner.GetScanFoldersCalls() @@ -129,7 +130,7 @@ var _ = Describe("Watcher", func() { // Wait for watcher to process and trigger scan Eventually(func() int { return mockScanner.GetScanFoldersCallCount() - }, 500*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) + }, 2*time.Second, 10*time.Millisecond).Should(Equal(1)) // Verify only one target despite multiple file/folder changes calls := mockScanner.GetScanFoldersCalls() @@ -170,7 +171,7 @@ var _ = Describe("Watcher", func() { // Now wait for the debounce timer to expire and trigger scan Eventually(func() int { return mockScanner.GetScanFoldersCallCount() - }, 500*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) + }, 2*time.Second, 10*time.Millisecond).Should(Equal(1)) }) It("triggers scan after quiet period", func() { @@ -183,7 +184,7 @@ var _ = Describe("Watcher", func() { // Wait for quiet period Eventually(func() int { return mockScanner.GetScanFoldersCallCount() - }, 500*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) + }, 2*time.Second, 10*time.Millisecond).Should(Equal(1)) }) }) @@ -205,7 +206,7 @@ var _ = Describe("Watcher", func() { // Wait for scan Eventually(func() int { return mockScanner.GetScanFoldersCallCount() - }, 500*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) + }, 2*time.Second, 10*time.Millisecond).Should(Equal(1)) // Should scan the library root calls := mockScanner.GetScanFoldersCalls() @@ -222,7 +223,7 @@ var _ = Describe("Watcher", func() { // Wait for scan Eventually(func() int { return mockScanner.GetScanFoldersCallCount() - }, 500*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) + }, 2*time.Second, 10*time.Millisecond).Should(Equal(1)) // Should have only one target calls := mockScanner.GetScanFoldersCalls() @@ -266,7 +267,7 @@ var _ = Describe("Watcher", func() { return nil } return calls[0].Targets - }, 500*time.Millisecond, 10*time.Millisecond).Should(HaveLen(2)) + }, 2*time.Second, 10*time.Millisecond).Should(HaveLen(2)) // Verify library IDs are different calls := mockScanner.GetScanFoldersCalls() diff --git a/utils/cache/file_caches.go b/utils/cache/file_caches.go index 33fa046df..dff9e4e7a 100644 --- a/utils/cache/file_caches.go +++ b/utils/cache/file_caches.go @@ -183,12 +183,11 @@ func (fc *fileCache) Get(ctx context.Context, arg Item) (*CachedStream, error) { return nil, err } go func() { - if err := copyAndClose(w, reader); err != nil { + if err := fc.copyAndClose(ctx, key, w, reader); err != nil { log.Debug(ctx, "Error storing file in cache", "cache", fc.name, "key", key, err) _ = fc.invalidate(ctx, key) } else { log.Trace(ctx, "File successfully stored in cache", "cache", fc.name, "key", key) - fc.markComplete(ctx, key) } }() } @@ -243,7 +242,8 @@ func getFinalCachedSize(r fscache.ReadAtCloser) int64 { return -1 } -func copyAndClose(w io.WriteCloser, r io.Reader) error { +// copyAndClose marks the entry complete before closing w, so EOF implies the entry is settled on disk. +func (fc *fileCache) copyAndClose(ctx context.Context, key string, w io.WriteCloser, r io.Reader) error { _, err := io.Copy(w, r) if err != nil { err = fmt.Errorf("copying data to cache: %w", err) @@ -253,7 +253,9 @@ func copyAndClose(w io.WriteCloser, r io.Reader) error { err = multierror.Append(err, fmt.Errorf("closing source stream: %w", cErr)) } } - + if err == nil { + fc.markComplete(ctx, key) + } if cErr := w.Close(); cErr != nil { err = multierror.Append(err, fmt.Errorf("closing cache writer: %w", cErr)) } diff --git a/utils/cache/file_caches_test.go b/utils/cache/file_caches_test.go index 7ce1b0393..974200656 100644 --- a/utils/cache/file_caches_test.go +++ b/utils/cache/file_caches_test.go @@ -8,6 +8,7 @@ import ( "path/filepath" "strings" "sync/atomic" + "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" @@ -18,7 +19,7 @@ import ( // Call NewFileCache and wait for it to be ready func callNewFileCache(name, cacheSize, cacheFolder string, maxItems int, getReader ReadFunc) *fileCache { fc := NewFileCache(name, cacheSize, cacheFolder, maxItems, getReader).(*fileCache) - Eventually(func() bool { return fc.ready.Load() }).Should(BeTrue()) + Eventually(func() bool { return fc.ready.Load() }, 10*time.Second).Should(BeTrue()) return fc } @@ -114,11 +115,10 @@ var _ = Describe("File Caches", func() { _, _ = io.ReadAll(s) _ = s.Close() + // EOF must imply the entry is settled on disk (Windows temp-dir cleanups rely on it). dataPath := fcSpreadFS(fc).KeyMapper((&testArg{"markme"}).Key()) - Eventually(func() bool { - _, statErr := os.Stat(dataPath + ".complete") - return statErr == nil - }).Should(BeTrue()) + _, statErr := os.Stat(dataPath + ".complete") + Expect(statErr).ToNot(HaveOccurred()) }) It("serves a concurrent reader from an in-progress write and marks complete once", func() { From d0807070606ebb331839aa6c54a61853d857cfdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Mon, 10 Aug 2026 21:21:44 -0400 Subject: [PATCH 61/71] fix(jellyfin): return a mixed, globally-limited list for multi-type /Items requests (#5935) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(jellyfin): allow random sort for artist/genre/playlist item types * feat(jellyfin): add round-robin interleave helper for merged item types * fix(jellyfin): mix and globally limit multi-type Items requests Run per-type queries in parallel and round-robin interleave the results so a request for multiple IncludeItemTypes (e.g. Finamp's random favorite) returns a mixed, globally-limited page instead of one type's rows followed by the next. * fix(jellyfin): dedupe repeated IncludeItemTypes to avoid duplicate items and redundant queries * refactor(jellyfin): dedupe via slice.Unique and extract queryTypeWindow helper * perf(jellyfin): serve random multi-type pages from offset 0 A random merge reshuffles every request, so paginating it is meaningless — page N is just another fresh draw (as in real Jellyfin). Serving from offset 0 caps the per-type fetch at limit instead of offset+limit, avoiding deep-offset blow-up for the random case (Finamp's random-favorite quick action). * refactor(jellyfin): resolve random-merge via applySort; simplify merge signatures Detect the random-page shortcut by resolving each type's sort through applySort (matching how the sort is actually chosen) instead of string-matching SortBy, and only when every type is random. Drop the always-zero window param from mergeTypesStreaming and derive the window inside mergeTypesPaged. --- server/jellyfin/items.go | 131 ++++++++++++++++++++++++++-------- server/jellyfin/items_test.go | 109 +++++++++++++++++++++++++++- 2 files changed, 206 insertions(+), 34 deletions(-) diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go index 631530b6d..b14a272bb 100644 --- a/server/jellyfin/items.go +++ b/server/jellyfin/items.go @@ -18,6 +18,7 @@ import ( "github.com/navidrome/navidrome/server/jellyfin/dto" "github.com/navidrome/navidrome/utils/req" "github.com/navidrome/navidrome/utils/slice" + "golang.org/x/sync/errgroup" ) // notMissing excludes items whose backing files are all gone ("missing" is a real column on @@ -330,52 +331,97 @@ func (api *Router) playlistTracksRepo(ctx context.Context, q itemsQuery) (model. } 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 + if q.limit == 0 { + return api.mergeTypesStreaming(ctx, q) } - // 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) + // A random page doesn't stack on the previous one (the order reshuffles each request), so serving + // from 0 is an equivalent fresh draw and avoids materializing offset+limit rows per type. + offset := q.offset + if randomlySorted(q) { + offset = 0 } + return api.mergeTypesPaged(ctx, q, offset) +} + +// randomlySorted reports whether every merged type resolves to a random sort — the case where a page +// is an independent draw, so the offset can be collapsed to 0. Resolving via applySort (rather than +// matching the raw SortBy) keeps this in step with how each type's sort is actually chosen. +func randomlySorted(q itemsQuery) bool { + for _, itemType := range q.types { + var opts model.QueryOptions + applySort(&opts, itemType, q.sortBy, q.sortOrder) + if opts.Sort != "random" { + return false + } + } + return true +} + +// mergeTypesStreaming keeps the unbounded path lazy: chaining the per-type cursors yields their rows +// in order minus the first offset, without pulling every row into memory. +func (api *Router) mergeTypesStreaming(ctx context.Context, q itemsQuery) (itemsResult, error) { var results []itemsResult total := 0 for _, itemType := range q.types { - var opts model.QueryOptions - opts.Max = window - applySort(&opts, itemType, q.sortBy, q.sortOrder) - res, err := api.queryItemsOfType(ctx, itemType, opts, q) + res, err := api.queryTypeWindow(ctx, itemType, 0, 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 + return chained(results, total, q.offset), nil +} + +// queryTypeWindow queries one type for the merge paths, capping it to window rows with the sort applied. +func (api *Router) queryTypeWindow(ctx context.Context, itemType string, window int, q itemsQuery) (itemsResult, error) { + var opts model.QueryOptions + opts.Max = window + applySort(&opts, itemType, q.sortBy, q.sortOrder) + return api.queryItemsOfType(ctx, itemType, opts, q) +} + +// mergeTypesPaged runs each type's query concurrently, then round-robins the per-type rows so the limited page +// is a mix rather than one type's rows followed by the next. +func (api *Router) mergeTypesPaged(ctx context.Context, q itemsQuery, offset int) (itemsResult, error) { + // Each per-type query needs at most offset+limit rows (worst case: one type fills the whole window). + window := offset + q.limit + if q.search != "" { + window = min(window, maxSearchLimit) } - var items []dto.BaseItemDto - for _, res := range results { - typeItems, err := res.collect() - if err != nil { - return itemsResult{}, err - } - items = append(items, typeItems...) + lists := make([][]dto.BaseItemDto, len(q.types)) + totals := make([]int, len(q.types)) + g, ctx := errgroup.WithContext(ctx) + for i, itemType := range q.types { + g.Go(func() error { + res, err := api.queryTypeWindow(ctx, itemType, window, q) + if err != nil { + return err + } + items, err := res.collect() + if err != nil { + return err + } + lists[i] = items + totals[i] = res.total + return nil + }) } + if err := g.Wait(); err != nil { + return itemsResult{}, err + } + total := 0 + for _, t := range totals { + total += t + } + items := interleave(lists) 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. + // type's rows. The total is what's pageable overall, so a client paging on it won't stop early. items = items[:min(window, len(items))] total = min(total, maxSearchLimit) } - return materialized(result(paginate(items, q.offset, q.limit), total, q.offset)), nil + return materialized(result(paginate(items, offset, q.limit), total, q.offset)), nil } func (api *Router) queryItemsOfType(ctx context.Context, itemType string, opts model.QueryOptions, q itemsQuery) (itemsResult, error) { @@ -440,6 +486,8 @@ func parseTypes(types string) []string { recognized = append(recognized, t) } } + // Dedupe: a repeated type would duplicate items in the merge and spawn a redundant query. + recognized = slice.Unique(recognized) if len(recognized) == 0 { return []string{"MusicAlbum"} } @@ -459,6 +507,25 @@ func paginate(items []dto.BaseItemDto, offset, limit int) []dto.BaseItemDto { return items } +// interleave merges per-type item lists round-robin: one item from each list in turn, preserving +// each list's own order, so no single type dominates the head of a mixed-type result. +func interleave(lists [][]dto.BaseItemDto) []dto.BaseItemDto { + total, maxLen := 0, 0 + for _, l := range lists { + total += len(l) + maxLen = max(maxLen, len(l)) + } + out := make([]dto.BaseItemDto, 0, total) + for i := 0; i < maxLen; i++ { + for _, l := range lists { + if i < len(l) { + out = append(out, l[i]) + } + } + } + return out +} + // 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 ( @@ -818,9 +885,8 @@ func applySort(opts *model.QueryOptions, itemType, sortBy, order string) { } } -// 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"). +// sortColumnsByType maps lowercased-SortBy -> repo-sort-key per item type (repos map logical fields +// to different real columns, e.g. media_file has "title" not "name"). var sortColumnsByType = map[string]map[string]string{ "Audio": { "sortname": "title", "name": "title", @@ -848,6 +914,7 @@ var sortColumnsByType = map[string]map[string]string{ "playcount": "play_count", "dateplayed": "play_date", "communityrating": "rating", + "random": "random", }, "MusicAlbum": { "sortname": "name", "name": "name", "album": "name", @@ -862,10 +929,12 @@ var sortColumnsByType = map[string]map[string]string{ }, "MusicGenre": { "sortname": "name", "name": "name", + "random": "random", }, "Playlist": { "sortname": "name", "name": "name", "datecreated": "created_at", + "random": "random", }, } diff --git a/server/jellyfin/items_test.go b/server/jellyfin/items_test.go index 4eddcc654..d8b9e60e8 100644 --- a/server/jellyfin/items_test.go +++ b/server/jellyfin/items_test.go @@ -462,9 +462,9 @@ var _ = Describe("Items", func() { 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. + // Clipped to the window; the interleaved album takes one slot, shifting this song in by one. Expect(res.Items).To(HaveLen(1)) - Expect(res.Items[0].Id).To(Equal(dto.EncodeID(songs[maxSearchLimit-1].ID))) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID(songs[maxSearchLimit-2].ID))) }) It("bounds an unbounded multi-type search to the default in total, not per type", func() { @@ -500,7 +500,8 @@ var _ = Describe("Items", func() { 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))) + // The interleaved album takes one slot ahead of it, shifting this song in by one. + Expect(res.Items[0].Id).To(Equal(dto.EncodeID(songs[defaultSearchLimit+49].ID))) }) It("reports a search total beyond the fetched page instead of the page length", func() { @@ -748,6 +749,68 @@ var _ = Describe("Items", func() { Expect(sql).NotTo(ContainSubstring("library_id")) }) }) + + Describe("mixed IncludeItemTypes merge", func() { + BeforeEach(func() { + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}, {ID: "a2", Name: "Two"}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "S1"}, {ID: "s2", Title: "S2"}}) + }) + + It("returns a mix of both types, not all of one", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&Recursive=true&Limit=4", 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(4)) + Expect(res.TotalRecordCount).To(Equal(4)) + types := map[string]int{} + for _, it := range res.Items { + types[it.Type]++ + } + Expect(types["Audio"]).To(Equal(2)) + Expect(types["MusicAlbum"]).To(Equal(2)) + }) + + It("interleaves types round-robin (Audio first, per IncludeItemTypes order)", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&Recursive=true&Limit=4", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + got := []string{res.Items[0].Type, res.Items[1].Type, res.Items[2].Type, res.Items[3].Type} + Expect(got).To(Equal([]string{"Audio", "MusicAlbum", "Audio", "MusicAlbum"})) + }) + + It("honors Limit across the merged set", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&Recursive=true&Limit=1", 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.TotalRecordCount).To(Equal(4)) + }) + + It("serves a full random page from offset 0 regardless of StartIndex", func() { + // A deep StartIndex on a random merge must not materialize offset+limit rows; since random + // reshuffles per request, offset 0 is an equivalent fresh draw. Old behavior returned empty. + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&SortBy=Random&Recursive=true&StartIndex=1000&Limit=4", 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(4)) + }) + + It("propagates a per-type query error", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetError(true) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&Recursive=true&Limit=4", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + }) + }) }) Describe("getItem", func() { @@ -896,4 +959,44 @@ var _ = Describe("Items", func() { Expect(args).To(ContainElements(1, 2)) }) }) + + Describe("applySort random for all merge types", func() { + DescribeTable("maps Random -> random", + func(itemType string) { + var opts model.QueryOptions + applySort(&opts, itemType, "Random", "") + Expect(opts.Sort).To(Equal("random")) + }, + Entry("Audio", "Audio"), + Entry("MusicAlbum", "MusicAlbum"), + Entry("MusicArtist", "MusicArtist"), + Entry("MusicGenre", "MusicGenre"), + Entry("Playlist", "Playlist"), + ) + }) + + Describe("interleave", func() { + It("round-robins one item per list in turn", func() { + lists := [][]dto.BaseItemDto{ + {{Id: "a0"}, {Id: "a1"}, {Id: "a2"}}, + {{Id: "b0"}, {Id: "b1"}}, + } + got := interleave(lists) + ids := make([]string, len(got)) + for i, it := range got { + ids[i] = it.Id + } + Expect(ids).To(Equal([]string{"a0", "b0", "a1", "b1", "a2"})) + }) + + It("returns empty for no lists", func() { + Expect(interleave(nil)).To(BeEmpty()) + }) + }) + + Describe("parseTypes", func() { + It("dedupes repeated types, preserving first-seen order", func() { + Expect(parseTypes("Audio,MusicAlbum,Audio")).To(Equal([]string{"Audio", "MusicAlbum"})) + }) + }) }) From 8e0ff1a23504252d8d9b0ef35656ef7eedb74258 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Mon, 10 Aug 2026 21:30:14 -0400 Subject: [PATCH 62/71] fix(jellyfin): resolve genre id as a MusicGenre item The Jellyfin /Items/{id} endpoint resolved albums, artists, songs and playlists by id but not genres, so a genre id returned 404. Finamp's genre "See all" fetches the genre as the track list's parent item, and that 404 crashed its screen to a blank page after the tracks flashed in. Add a Genre lookup to resolveItemByID (backed by a new GenreRepository.Get) so a genre id returns its MusicGenre BaseItemDto, matching real Jellyfin. --- model/genre.go | 1 + persistence/genre_repository.go | 10 +++++++--- server/jellyfin/items.go | 5 ++++- server/jellyfin/items_test.go | 15 +++++++++++++++ tests/mock_genre_repo.go | 11 +++++++++++ 5 files changed, 38 insertions(+), 4 deletions(-) diff --git a/model/genre.go b/model/genre.go index bb05e747e..fa5b6ec62 100644 --- a/model/genre.go +++ b/model/genre.go @@ -11,4 +11,5 @@ type Genres []Genre type GenreRepository interface { GetAll(...QueryOptions) (Genres, error) + Get(id string) (*Genre, error) } diff --git a/persistence/genre_repository.go b/persistence/genre_repository.go index 22443284f..0bb22c21b 100644 --- a/persistence/genre_repository.go +++ b/persistence/genre_repository.go @@ -30,15 +30,19 @@ func (r *genreRepository) GetAll(opt ...model.QueryOptions) (model.Genres, error return res, err } -// Override ResourceRepository methods to return Genre objects instead of Tag objects - -func (r *genreRepository) Read(id string) (any, error) { +func (r *genreRepository) Get(id string) (*model.Genre, error) { sel := r.selectGenre().Where(Eq{"tag.id": id}) var res model.Genre err := r.queryOne(sel, &res) return &res, err } +// Override ResourceRepository methods to return Genre objects instead of Tag objects + +func (r *genreRepository) Read(id string) (any, error) { + return r.Get(id) +} + func (r *genreRepository) ReadAll(options ...rest.QueryOptions) (any, error) { return r.GetAll(r.parseRestOptions(r.ctx, options...)) } diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go index b14a272bb..9b85b8a62 100644 --- a/server/jellyfin/items.go +++ b/server/jellyfin/items.go @@ -736,7 +736,7 @@ func (api *Router) listPlaylists(ctx context.Context, opts model.QueryOptions, q } // 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 +// artist, song, playlist and genre 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. @@ -778,6 +778,9 @@ func (api *Router) resolveItemByID(ctx context.Context, id string, fields dto.Fi if pl, err := api.playlists.Get(ctx, id); err == nil { return dto.PlaylistToBaseItem(*pl, fields), true } + if g, err := api.ds.Genre(ctx).Get(id); err == nil { + return dto.GenreToBaseItem(*g), true + } return dto.BaseItemDto{}, false } diff --git a/server/jellyfin/items_test.go b/server/jellyfin/items_test.go index d8b9e60e8..3213379e5 100644 --- a/server/jellyfin/items_test.go +++ b/server/jellyfin/items_test.go @@ -917,6 +917,21 @@ var _ = Describe("Items", func() { Expect(w.Code).To(Equal(http.StatusNotFound)) }) + // Finamp's genre "See all" fetches the genre by id; a 404 white-screens it (see resolveItemByID). + It("resolves a genre id as a MusicGenre item", func() { + Expect(ds.Genre(context.Background()).(*tests.MockedGenreRepo).Put(&model.Genre{ID: "g1", Name: "Rock"})).To(Succeed()) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("g1"), nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID("g1")) + 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("g1"))) + Expect(item.Name).To(Equal("Rock")) + Expect(item.Type).To(Equal("MusicGenre")) + }) + 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() diff --git a/tests/mock_genre_repo.go b/tests/mock_genre_repo.go index 50796efc0..ad3ee1a6a 100644 --- a/tests/mock_genre_repo.go +++ b/tests/mock_genre_repo.go @@ -32,6 +32,17 @@ func (r *MockedGenreRepo) GetAll(options ...model.QueryOptions) (model.Genres, e return all, nil } +func (r *MockedGenreRepo) Get(id string) (*model.Genre, error) { + if r.Error != nil { + return nil, r.Error + } + r.init() + if g, ok := r.Data[id]; ok { + return &g, nil + } + return nil, model.ErrNotFound +} + func (r *MockedGenreRepo) Put(g *model.Genre) error { if r.Error != nil { return r.Error From 95b8d9dd041f53935f82eb9bca3f485d5b5f6a0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Tue, 11 Aug 2026 08:00:50 -0400 Subject: [PATCH 63/71] perf(genre): index genre filtering via join tables across all APIs (#5940) Filtering by genre scanned every media_file/album row and JSON-parsed its `tags` column (a per-row json_tree(tags) EXISTS) with no usable index, so Finamp's genre screen took 1.9-6.5s per tap against a ~97k-track library. Album and album-artist genre queries had the same unindexed shape. Add normalized media_file_tags and album_tags join tables (genre only for now, via an indexedTagNames allowlist), populated by a new updateTags in Put (mirroring updateParticipants) and backfilled in the migration. Genre filtering across the Jellyfin, Subsonic and native APIs now runs as an index-backed semi-join through shared TagIDSemiJoin/TagNameSemiJoin helpers instead of a full scan. On a copy of the production DB the per-request cost for a typical genre drops from ~330ms to sub-millisecond, adding ~10MB. --- ...60811023338_add_item_genre_tag_indexes.sql | 34 ++++++ persistence/album_repository.go | 7 +- persistence/item_tags_test.go | 108 ++++++++++++++++++ persistence/mediafile_repository.go | 7 +- persistence/sql_tags.go | 71 ++++++++++++ server/filter/filters.go | 33 ++---- server/jellyfin/items.go | 4 +- server/subsonic/album_lists.go | 4 +- 8 files changed, 238 insertions(+), 30 deletions(-) create mode 100644 db/migrations/20260811023338_add_item_genre_tag_indexes.sql create mode 100644 persistence/item_tags_test.go diff --git a/db/migrations/20260811023338_add_item_genre_tag_indexes.sql b/db/migrations/20260811023338_add_item_genre_tag_indexes.sql new file mode 100644 index 000000000..1a7d75190 --- /dev/null +++ b/db/migrations/20260811023338_add_item_genre_tag_indexes.sql @@ -0,0 +1,34 @@ +-- +goose Up +create table if not exists media_file_tags( + media_file_id varchar not null + references media_file (id) on delete cascade, + tag_id varchar not null + references tag (id) on delete cascade, + constraint media_file_tags unique (media_file_id, tag_id) +); +create index if not exists media_file_tags_tag_id on media_file_tags (tag_id); + +create table if not exists album_tags( + album_id varchar not null + references album (id) on delete cascade, + tag_id varchar not null + references tag (id) on delete cascade, + constraint album_tags unique (album_id, tag_id) +); +create index if not exists album_tags_tag_id on album_tags (tag_id); + +-- Backfill genre rows from the per-row `tags` JSON. json_tree over the "$.genre" subtree yields one +-- row per node; the "id" key nodes carry the tag ids. Single scan per table, no correlated subquery. +insert or ignore into media_file_tags (media_file_id, tag_id) +select mf.id, jt.value +from media_file mf, json_tree(mf.tags, '$.genre') jt +where jt.key = 'id' and jt.atom is not null; + +insert or ignore into album_tags (album_id, tag_id) +select al.id, jt.value +from album al, json_tree(al.tags, '$.genre') jt +where jt.key = 'id' and jt.atom is not null; + +-- +goose Down +drop table if exists media_file_tags; +drop table if exists album_tags; diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 91e90127c..486099fc5 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -133,7 +133,7 @@ var albumFilters = sync.OnceValue(func() map[string]filterFunc { "starred": annotationBoolFilter("starred"), "has_rating": annotationBoolFilter("rating"), "missing": booleanFilter, - "genre_id": tagIDFilter, + "genre_id": genreFilter(AlbumGenres), "role_total_id": allRolesFilter, "library_id": libraryIdFilter, } @@ -212,7 +212,10 @@ func (r *albumRepository) Put(al *model.Album) error { return err } al.ID = id - return r.updateParticipants(al.ID, al.Participants) + if err := r.updateParticipants(al.ID, al.Participants); err != nil { + return err + } + return r.updateTags(al.ID, al.Tags) } // TODO Move external metadata to a separated table diff --git a/persistence/item_tags_test.go b/persistence/item_tags_test.go new file mode 100644 index 000000000..a2317a1a5 --- /dev/null +++ b/persistence/item_tags_test.go @@ -0,0 +1,108 @@ +package persistence + +import ( + "github.com/deluan/rest" + "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("item genre tag indexes", func() { + var conn *dbx.DB + var mr model.MediaFileRepository + var ar model.AlbumRepository + var rock, jazz model.Tag + + BeforeEach(func() { + ctx := request.WithUser(GinkgoT().Context(), model.User{ID: "userid"}) + conn = GetDBXBuilder() + mr = NewMediaFileRepository(ctx, conn) + ar = NewAlbumRepository(ctx, conn) + // Test-only genre values, so they can't collide with the golden fixtures. + rock = model.NewTag(model.TagGenre, "GenreIdxRock") + jazz = model.NewTag(model.TagGenre, "GenreIdxJazz") + // The join tables FK to tag(id); the scanner adds tags before saving items. + Expect(NewTagRepository(ctx, conn).Add(1, rock, jazz)).To(Succeed()) + // The suite shares one golden DB with no per-test restore, so undo the rows we add + // (media_file/album deletes cascade to the *_tags join rows; tag deletes cascade too). + DeferCleanup(func() { + _, _ = conn.NewQuery("DELETE FROM media_file WHERE id LIKE 'mf-%'").Execute() + _, _ = conn.NewQuery("DELETE FROM album WHERE id LIKE 'al-%'").Execute() + _, _ = conn.NewQuery("DELETE FROM tag WHERE id={:r} OR id={:j}"). + Bind(dbx.Params{"r": rock.ID, "j": jazz.ID}).Execute() + }) + }) + + tagIDsFor := func(table, col, id string) []string { + var rows []struct { + TagID string `db:"tag_id"` + } + err := conn.NewQuery("SELECT tag_id FROM " + table + " WHERE " + col + "={:id}"). + Bind(dbx.Params{"id": id}).All(&rows) + Expect(err).ToNot(HaveOccurred()) + ids := make([]string, len(rows)) + for i, r := range rows { + ids[i] = r.TagID + } + return ids + } + + Describe("media files", func() { + It("writes a media_file_tags row for each genre when the track is saved", func() { + mf := model.MediaFile{ID: "mf-g1", LibraryID: 1, Path: "/m/g1.mp3", Title: "G1", + Tags: model.Tags{model.TagGenre: []string{rock.TagValue, jazz.TagValue}}} + Expect(mr.Put(&mf)).To(Succeed()) + Expect(tagIDsFor("media_file_tags", "media_file_id", "mf-g1")).To(ConsistOf(rock.ID, jazz.ID)) + }) + + It("replaces the rows when the genres change", func() { + mf := model.MediaFile{ID: "mf-g2", LibraryID: 1, Path: "/m/g2.mp3", Title: "G2", + Tags: model.Tags{model.TagGenre: []string{rock.TagValue}}} + Expect(mr.Put(&mf)).To(Succeed()) + mf.Tags = model.Tags{model.TagGenre: []string{jazz.TagValue}} + Expect(mr.Put(&mf)).To(Succeed()) + Expect(tagIDsFor("media_file_tags", "media_file_id", "mf-g2")).To(ConsistOf(jazz.ID)) + }) + + It("clears the rows when all genres are removed", func() { + mf := model.MediaFile{ID: "mf-g3", LibraryID: 1, Path: "/m/g3.mp3", Title: "G3", + Tags: model.Tags{model.TagGenre: []string{rock.TagValue}}} + Expect(mr.Put(&mf)).To(Succeed()) + mf.Tags = model.Tags{} + Expect(mr.Put(&mf)).To(Succeed()) + Expect(tagIDsFor("media_file_tags", "media_file_id", "mf-g3")).To(BeEmpty()) + }) + }) + + Describe("albums", func() { + It("writes an album_tags row for each genre when the album is saved", func() { + al := model.Album{ID: "al-g1", LibraryID: 1, Name: "AG1", + Tags: model.Tags{model.TagGenre: []string{rock.TagValue, jazz.TagValue}}} + Expect(ar.Put(&al)).To(Succeed()) + Expect(tagIDsFor("album_tags", "album_id", "al-g1")).To(ConsistOf(rock.ID, jazz.ID)) + }) + }) + + // The native (REST) API filters by genre_id; it must resolve through the join table too. + Describe("native genre_id filter", func() { + It("filters media files by genre_id", func() { + mf := model.MediaFile{ID: "mf-nat1", LibraryID: 1, Path: "/m/nat1.mp3", Title: "Nat1", + Tags: model.Tags{model.TagGenre: []string{rock.TagValue}}} + Expect(mr.Put(&mf)).To(Succeed()) + res, err := mr.(model.ResourceRepository).ReadAll(rest.QueryOptions{Filters: map[string]any{"genre_id": rock.ID}}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.(model.MediaFiles)).To(ContainElement(HaveField("ID", "mf-nat1"))) + }) + + It("filters albums by genre_id", func() { + al := model.Album{ID: "al-nat1", LibraryID: 1, Name: "ANat1", + Tags: model.Tags{model.TagGenre: []string{rock.TagValue}}} + Expect(ar.Put(&al)).To(Succeed()) + res, err := ar.(model.ResourceRepository).ReadAll(rest.QueryOptions{Filters: map[string]any{"genre_id": rock.ID}}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.(model.Albums)).To(ContainElement(HaveField("ID", "al-nat1"))) + }) + }) +}) diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index f372b30f5..f37e394cc 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -111,7 +111,7 @@ var mediaFileFilter = sync.OnceValue(func() map[string]filterFunc { "title": fullTextFilter("media_file", "mbz_recording_id", "mbz_release_track_id"), "starred": annotationBoolFilter("starred"), "has_rating": annotationBoolFilter("rating"), - "genre_id": tagIDFilter, + "genre_id": genreFilter(SongGenres), "missing": booleanFilter, "artists_id": mediaFileArtistFilter, "library_id": libraryIdFilter, @@ -181,7 +181,10 @@ func (r *mediaFileRepository) Put(m *model.MediaFile) error { return err } m.ID = id - return r.updateParticipants(m.ID, m.Participants) + if err := r.updateParticipants(m.ID, m.Participants); err != nil { + return err + } + return r.updateTags(m.ID, m.Tags) } func (r *mediaFileRepository) UpdateProbeData(id string, data string) error { diff --git a/persistence/sql_tags.go b/persistence/sql_tags.go index 5177bc8e4..11f9fe00e 100644 --- a/persistence/sql_tags.go +++ b/persistence/sql_tags.go @@ -48,6 +48,77 @@ func marshalTags(tags model.Tags) string { return string(res) } +// indexedTagNames are the tag types materialized into the <table>_tags join tables, so filtering by +// them is an index-backed semi-join instead of a per-row json_tree(tags) scan. Genre only for now. +var indexedTagNames = []model.TagName{model.TagGenre} + +// updateTags rewrites this item's <table>_tags rows from its in-memory tags, mirroring +// updateParticipants (delete-then-insert in the same Put; JOIN to tag skips not-yet-saved ids). +func (r sqlRepository) updateTags(itemID string, tags model.Tags) error { + del := Delete(r.tableName + "_tags").Where(Eq{r.tableName + "_id": itemID}) + if _, err := r.executeSQL(del); err != nil { + return err + } + var tagIDs []string + for _, name := range indexedTagNames { + for _, value := range tags.Values(name) { + tagIDs = append(tagIDs, model.NewTag(name, value).ID) + } + } + if len(tagIDs) == 0 { + return nil + } + idsJSON, err := json.Marshal(tagIDs) + if err != nil { + return fmt.Errorf("marshaling tag ids: %w", err) + } + query := fmt.Sprintf(` + INSERT INTO %[1]s_tags (%[1]s_id, tag_id) + SELECT ?, value FROM json_each(?) + JOIN tag ON tag.id = value + ON CONFLICT (%[1]s_id, tag_id) DO NOTHING`, r.tableName) + _, err = r.executeSQL(Expr(query, itemID, string(idsJSON))) + return err +} + +// genreFilterDef builds indexed genre filters for one item type. Callers use the exported SongGenres / AlbumGenres instances. +type genreFilterDef struct{ idCol, table, joinCol string } + +var ( + SongGenres = genreFilterDef{"media_file.id", "media_file_tags", "media_file_id"} + AlbumGenres = genreFilterDef{"album.id", "album_tags", "album_id"} +) + +// ByID matches items tagged with any of the given genre tag ids (scalar or slice). +func (g genreFilterDef) ByID(tagIDs any) Sqlizer { + sub, args, _ := Select(g.joinCol).From(g.table).Where(Eq{"tag_id": tagIDs}).ToSql() + return Expr(g.idCol+" IN ("+sub+")", args...) +} + +// ByName matches by genre name (Subsonic passes a name, not an id), resolved through the tag +// dictionary, which is uniquely indexed on (tag_name, tag_value). +func (g genreFilterDef) ByName(genre string) Sqlizer { + sub, args, _ := Select("jt." + g.joinCol).From(g.table + " jt"). + Join("tag on tag.id = jt.tag_id"). + Where(And{Eq{"tag.tag_name": "genre"}, Like{"tag.tag_value": genre}}).ToSql() + return Expr(g.idCol+" IN ("+sub+")", args...) +} + +// AlbumArtistsByGenreID matches album artists of albums tagged with any of the genre ids. It's a +// two-table join (album_artists ⨝ album_tags), so it doesn't fit the single-table genreFilterDef. +func AlbumArtistsByGenreID(tagIDs []string) Sqlizer { + sub, args, _ := Select("aa.artist_id").From("album_artists aa"). + Join("album_tags at on at.album_id = aa.album_id"). + Where(And{Eq{"aa.role": "albumartist"}, Eq{"at.tag_id": tagIDs}}).ToSql() + return Expr("artist.id IN ("+sub+")", args...) +} + +func genreFilter(filter genreFilterDef) func(_ string, v any) Sqlizer { + return func(_ string, v any) Sqlizer { + return filter.ByID(v) + } +} + // tagIDFilter matches rows whose tags JSON contains the tag id(s); a "<name>_id" key maps to "$.<name>". func tagIDFilter(name string, idValue any) Sqlizer { name = strings.TrimSuffix(name, "_id") diff --git a/server/filter/filters.go b/server/filter/filters.go index 62d8f0523..9014bb799 100644 --- a/server/filter/filters.go +++ b/server/filter/filters.go @@ -112,7 +112,7 @@ func SongsByGenreAndYearRange(genre string, fromYear, toYear int) Options { options := Options{} ff := And{} if genre != "" { - ff = append(ff, filterByGenre(genre)) + ff = append(ff, persistence.SongGenres.ByName(genre)) } if fromYear != 0 { ff = append(ff, GtOrEq{"year": fromYear}) @@ -171,16 +171,17 @@ func ArtistsByRole(opts Options, role model.Role) Options { return opts } -func ByGenre(genre string) Options { - return addDefaultFilters(Options{ - Sort: "name", - Filters: filterByGenre(genre), - }) +// SongsByGenreID / AlbumsByGenreID (by tag id) and AlbumsByGenre / SongsByGenre (by name, wrapped +// as Options for Subsonic) delegate to the persistence genre filters, which own the join schema. +func SongsByGenreID(genreIds []string) Sqlizer { return persistence.SongGenres.ByID(genreIds) } +func AlbumsByGenreID(genreIds []string) Sqlizer { return persistence.AlbumGenres.ByID(genreIds) } + +func AlbumsByGenre(genre string) Options { + return addDefaultFilters(Options{Sort: "name", Filters: persistence.AlbumGenres.ByName(genre)}) } -// 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}) +func SongsByGenre(genre string) Options { + return addDefaultFilters(Options{Sort: "name", Filters: persistence.SongGenres.ByName(genre)}) } // ByAlbumID matches media files belonging to any of the given albums. @@ -198,14 +199,8 @@ func SongsByYears(years []int) Sqlizer { return Eq{"year": years} } -// 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}), - ) + return persistence.AlbumArtistsByGenreID(genreIds) } // tagIDFilter builds an EXISTS over the given tag role's entries in the tags JSON, matching each @@ -214,17 +209,11 @@ func tagIDFilter(tagName string, cond Sqlizer) Sqlizer { return persistence.Exists(`json_tree(tags, "$.`+tagName+`")`, And{NotEq{"atom": nil}, cond}) } -func genreTagFilter(cond Sqlizer) Sqlizer { return tagIDFilter("genre", cond) } - // ByStudioID matches items (albums or songs) whose record-label tag id is in ids. func ByStudioID(ids []string) Sqlizer { return tagIDFilter("recordlabel", Eq{"value": ids}) } -func filterByGenre(genre string) Sqlizer { - return genreTagFilter(Like{"value": genre}) -} - func ByRating() Options { return addDefaultFilters(Options{Sort: "rating", Order: "desc", Filters: Gt{"rating": 0}}) } diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go index 9b85b8a62..fed5bbbc8 100644 --- a/server/jellyfin/items.go +++ b/server/jellyfin/items.go @@ -577,7 +577,7 @@ func (api *Router) listAlbums(ctx context.Context, opts model.QueryOptions, q it filters = append(filters, notMissing) } if len(q.genreIds) > 0 { - filters = append(filters, filter.ByGenreID(q.genreIds)) + filters = append(filters, filter.AlbumsByGenreID(q.genreIds)) } if len(q.years) > 0 { filters = append(filters, filter.AlbumsByYears(q.years)) @@ -624,7 +624,7 @@ func (api *Router) listSongs(ctx context.Context, opts model.QueryOptions, q ite filters = append(filters, filter.ByAlbumID(q.albumIds)) } if len(q.genreIds) > 0 { - filters = append(filters, filter.ByGenreID(q.genreIds)) + filters = append(filters, filter.SongsByGenreID(q.genreIds)) } if len(q.years) > 0 { filters = append(filters, filter.SongsByYears(q.years)) diff --git a/server/subsonic/album_lists.go b/server/subsonic/album_lists.go index 041a3b8f2..ba5f88a68 100644 --- a/server/subsonic/album_lists.go +++ b/server/subsonic/album_lists.go @@ -46,7 +46,7 @@ func (api *Router) getAlbumList(r *http.Request) (model.Albums, int64, error) { if err != nil { return nil, 0, err } - opts = filter.ByGenre(genre) + opts = filter.AlbumsByGenre(genre) case "byYear": fromYear, err := p.Int("fromYear") if err != nil { @@ -267,7 +267,7 @@ func (api *Router) GetSongsByGenre(r *http.Request) (*responses.Subsonic, error) if err != nil { return nil, err } - opts := filter.ByGenre(genre) + opts := filter.SongsByGenre(genre) opts = filter.ApplyLibraryFilter(opts, musicFolderIds) ctx := r.Context() From c4126fa674a0ac48731207ff395c08d41644833f Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Tue, 11 Aug 2026 09:14:17 -0400 Subject: [PATCH 64/71] fix(jellyfin): emit SortName for artists and albums, honoring PreferSortTags Finamp's A-Z fast scroll re-derives each item's sort key client-side from SortName, paging until it reaches the tapped letter. We only emitted SortName for songs, so Finamp fell back to reconstructing a key from the display name, which diverges from the order_* key the list is actually sorted by (curly quotes, non-English articles). The scan then believed it had passed the letter and scrolled back to the top instead of loading further pages. Emit SortName for artists and albums using the same key the persistence layer sorts by, via a sortName helper that mirrors Subsonic's PreferSortTags handling: order_* names by default, sort tags when the config is enabled. The song mapper now honors the config too, instead of always preferring the sort tag. Also parse Fields in the /Artists* handlers, which ignored the parameter entirely, so no field-gated data could ever be returned there. --- server/jellyfin/browsing.go | 1 + server/jellyfin/dto/mappers.go | 17 +++++++++- server/jellyfin/dto/mappers_test.go | 49 +++++++++++++++++++++++++--- server/jellyfin/e2e/browsing_test.go | 19 +++++++++++ 4 files changed, 81 insertions(+), 5 deletions(-) diff --git a/server/jellyfin/browsing.go b/server/jellyfin/browsing.go index 29fe48c2b..faf43d352 100644 --- a/server/jellyfin/browsing.go +++ b/server/jellyfin/browsing.go @@ -35,6 +35,7 @@ func (api *Router) listArtistsByRole(w http.ResponseWriter, r *http.Request, rol scopeIDs: scopeIDs, genreIds: decodedQueryIDs(r, "genreids"), search: searchTerm(p), + fields: dto.ParseFields(p.Strings("fields")...), } if q.search != "" { opts.Max = clampLimit(opts.Max, defaultSearchLimit, maxSearchLimit) diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go index 352fce11d..31cdba1dd 100644 --- a/server/jellyfin/dto/mappers.go +++ b/server/jellyfin/dto/mappers.go @@ -10,6 +10,15 @@ import ( "github.com/navidrome/navidrome/utils/slice" ) +// sortName must match the persistence ORDER BY key (see setSortMappings): Finamp's A-Z jump +// scans SortName client-side, and any mismatch with the server's sort order scrolls to the top. +func sortName(sortTag, orderName, displayName string) string { + if conf.Server.PreferSortTags { + return cmp.Or(sortTag, orderName, displayName) + } + return cmp.Or(orderName, displayName) +} + // Jellyfin wire times are ticks: 100ns units, i.e. 10,000 per millisecond. const ticksPerMillis = 10_000 @@ -152,7 +161,7 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto { item.MediaSources = []MediaSourceInfo{MediaSourceFromMediaFile(mf)} } if fields.Has("SortName") { - item.SortName = cmp.Or(mf.SortTitle, mf.OrderTitle, mf.Title) + item.SortName = sortName(mf.SortTitle, mf.OrderTitle, mf.Title) } // Real Jellyfin splits Artists/ArtistItems per track artist (AlbumArtists stays a single credit). // Participants holds the per-artist list; fall back to the flattened display fields when absent. @@ -281,6 +290,9 @@ func AlbumToBaseItem(al model.Album, fields Fields) BaseItemDto { // The album's own ReplayGain gain (dB at the RG2 -18 LUFS reference) — same // convention as tracks; clients read it off the album item as NormalizationGain. item.NormalizationGain = al.RGAlbumGain + if fields.Has("SortName") { + item.SortName = sortName(al.SortAlbumName, al.OrderAlbumName, al.Name) + } return item } @@ -302,6 +314,9 @@ func ArtistToBaseItem(ar model.Artist, fields Fields) BaseItemDto { if tag != "" { item.ImageTags = map[string]string{"Primary": tag} } + if fields.Has("SortName") { + item.SortName = sortName(ar.SortArtistName, ar.OrderArtistName, ar.Name) + } return item } diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go index a7b381c5c..77b78e55f 100644 --- a/server/jellyfin/dto/mappers_test.go +++ b/server/jellyfin/dto/mappers_test.go @@ -46,10 +46,9 @@ var _ = Describe("mappers", func() { mf := model.MediaFile{ID: "s1", Title: "Song", Size: 2_500_000, Suffix: "mp3", Duration: 60, SortTitle: "sort song", Lyrics: `[{"line":[{"value":"la"}]}]`} - It("omits MediaSources and SortName when Fields does not ask for them", func() { + It("omits MediaSources 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() { @@ -58,8 +57,50 @@ var _ = Describe("mappers", func() { 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")) + // SortName must match the server sort order — see the sortName helper. + Describe("SortName", func() { + song := model.MediaFile{ID: "s1", Title: "The Song", SortTitle: "Song, The", OrderTitle: "song"} + ar := model.Artist{ID: "art-1", Name: "The B-52's", SortArtistName: "B-52's, The", OrderArtistName: "b-52's"} + al := model.Album{ID: "alb-1", Name: "The Wall", SortAlbumName: "Wall, The", OrderAlbumName: "wall"} + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + + It("is omitted unless Fields=SortName", func() { + Expect(SongToBaseItem(song, nil).SortName).To(BeEmpty()) + Expect(ArtistToBaseItem(ar, nil).SortName).To(BeEmpty()) + Expect(AlbumToBaseItem(al, nil).SortName).To(BeEmpty()) + }) + + It("uses the order names by default, ignoring sort tags", func() { + Expect(SongToBaseItem(song, ParseFields("SortName")).SortName).To(Equal("song")) + Expect(ArtistToBaseItem(ar, ParseFields("SortName")).SortName).To(Equal("b-52's")) + Expect(AlbumToBaseItem(al, ParseFields("SortName")).SortName).To(Equal("wall")) + }) + + Context("with PreferSortTags", func() { + BeforeEach(func() { + conf.Server.PreferSortTags = true + }) + + It("prefers the sort tags", func() { + Expect(SongToBaseItem(song, ParseFields("SortName")).SortName).To(Equal("Song, The")) + Expect(ArtistToBaseItem(ar, ParseFields("SortName")).SortName).To(Equal("B-52's, The")) + Expect(AlbumToBaseItem(al, ParseFields("SortName")).SortName).To(Equal("Wall, The")) + }) + + It("falls back to the order name when there is no sort tag", func() { + Expect(ArtistToBaseItem(model.Artist{ID: "a", Name: "The X", OrderArtistName: "x"}, + ParseFields("SortName")).SortName).To(Equal("x")) + }) + }) + + It("falls back to the display name when order name and sort tag are empty", func() { + Expect(SongToBaseItem(model.MediaFile{ID: "s", Title: "T"}, ParseFields("SortName")).SortName).To(Equal("T")) + Expect(ArtistToBaseItem(model.Artist{ID: "a", Name: "N"}, ParseFields("SortName")).SortName).To(Equal("N")) + Expect(AlbumToBaseItem(model.Album{ID: "al", Name: "A"}, ParseFields("SortName")).SortName).To(Equal("A")) + }) }) It("sets HasLyrics from the media file's lyrics", func() { diff --git a/server/jellyfin/e2e/browsing_test.go b/server/jellyfin/e2e/browsing_test.go index be6d6bb5d..14ab8b62b 100644 --- a/server/jellyfin/e2e/browsing_test.go +++ b/server/jellyfin/e2e/browsing_test.go @@ -2,6 +2,7 @@ package e2e import ( "net/http" + "slices" "sort" "time" @@ -79,6 +80,24 @@ var _ = Describe("Browsing", func() { Expect(names(q.Items)).To(ConsistOf("The Beatles", "Led Zeppelin", "Miles Davis", "Solo Artist")) }) + // Finamp's A-Z jump scans SortName client-side, so it must follow the response order. + It("returns artists' SortName matching the server sort order when Fields=SortName", func() { + plain := queryResult(get("/Artists/AlbumArtists?Recursive=true&SortBy=SortName")) + for _, it := range plain.Items { + Expect(it.SortName).To(BeEmpty()) + } + q := queryResult(get("/Artists/AlbumArtists?Recursive=true&SortBy=SortName&Fields=SortName")) + Expect(q.Items).ToNot(BeEmpty()) + sortNames := make([]string, 0, len(q.Items)) + for _, it := range q.Items { + Expect(it.SortName).ToNot(BeEmpty()) + sortNames = append(sortNames, it.SortName) + } + Expect(slices.IsSorted(sortNames)).To(BeTrue(), "SortName values must follow the response order: %v", sortNames) + // "The Beatles" must be filed under B, exposing the article-stripped key to clients. + Expect(sortNames).To(ContainElement("beatles")) + }) + It("lists all genres", func() { q := queryResult(get("/Items?IncludeItemTypes=MusicGenre&Recursive=true")) Expect(q.TotalRecordCount).To(Equal(3)) From 9e95b19a4faa325d106630a7c19cd71f31db43b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Tue, 11 Aug 2026 10:42:39 -0400 Subject: [PATCH 65/71] feat(artwork): make the artwork image size cap configurable (#5931) * feat(artwork): make the artwork image size cap configurable Replace the hardcoded 20MB cap on resolved image reads with a new MaxImageSize config option. Load floors it at MaxImageUploadSize so an accepted upload can never be too large for the resolver to read back. * fix(conf): reject zero-valued byte-size options at startup ParseBytes accepts "0", but parseSize silently substitutes the default for it, so the accepted config would differ from the effective limit. * fix(conf): reject byte-size options that overflow int64 A raw value above math.MaxInt64 parses as a valid uint64 but wraps to a negative int64 in parseSize, giving readCapped a non-positive LimitReader bound so every artwork read comes back empty. --- conf/configuration.go | 31 +++++++++++++++++++++---- conf/configuration_test.go | 42 +++++++++++++++++++++++----------- conf/export_test.go | 2 +- consts/consts.go | 1 + core/artwork/processor.go | 13 +++++++---- core/artwork/processor_test.go | 13 ++++++++++- core/artwork/resolve_test.go | 2 +- core/artwork/uploader.go | 8 +++++-- 8 files changed, 85 insertions(+), 27 deletions(-) diff --git a/conf/configuration.go b/conf/configuration.go index 5122b15eb..fbbaaf252 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -5,6 +5,7 @@ import ( "encoding" "encoding/json" "fmt" + "math" "net/url" "os" "path/filepath" @@ -90,6 +91,7 @@ type configOptions struct { EnableUserEditing bool EnableArtworkUpload bool MaxImageUploadSize string + MaxImageSize string EnableSharing bool ShareURL string DefaultShareExpiration time.Duration @@ -421,7 +423,8 @@ func Load(noConfigDump bool) { validateBackupSchedule, validatePlaylistsPath, validatePurgeMissingOption, - validateMaxImageUploadSize, + validateByteSize("MaxImageUploadSize", Server.MaxImageUploadSize), + validateByteSize("MaxImageSize", Server.MaxImageSize), validateURL("ExtAuth.LogoutURL", Server.ExtAuth.LogoutURL), ) if err != nil { @@ -481,6 +484,14 @@ func Load(noConfigDump bool) { Server.UICoverArtSize = newValue } + // Floor MaxImageSize at MaxImageUploadSize so accepted uploads can always be read back. + imgSize, _ := humanize.ParseBytes(Server.MaxImageSize) + uploadSize, _ := humanize.ParseBytes(Server.MaxImageUploadSize) + if imgSize < uploadSize { + log.Warn("MaxImageSize must be at least MaxImageUploadSize, raising", "value", Server.MaxImageSize, "newValue", Server.MaxImageUploadSize) + Server.MaxImageSize = Server.MaxImageUploadSize + } + // Call init hooks for _, hook := range hooks { hook() @@ -806,11 +817,20 @@ func validatePurgeMissingOption() error { return nil } -func validateMaxImageUploadSize() error { - if _, err := humanize.ParseBytes(Server.MaxImageUploadSize); err != nil { - return fmt.Errorf("invalid MaxImageUploadSize %q: use values like '10MB', '1GB', or raw bytes like '10485760': %w", Server.MaxImageUploadSize, err) +func validateByteSize(name, value string) func() error { + return func() error { + size, err := humanize.ParseBytes(value) + if err != nil { + return fmt.Errorf("invalid %s %q: use values like '10MB', '1GB', or raw bytes like '10485760': %w", name, value, err) + } + if size == 0 { + return fmt.Errorf("invalid %s %q: must be greater than zero", name, value) + } + if size > math.MaxInt64 { + return fmt.Errorf("invalid %s %q: value is too large", name, value) + } + return nil } - return nil } func validateEnforceNonRootUser() error { @@ -980,6 +1000,7 @@ func setViperDefaults() { viper.SetDefault("uiplaybackreportinterval", consts.DefaultUIPlaybackReportInterval) viper.SetDefault("enableartworkupload", true) viper.SetDefault("maximageuploadsize", consts.DefaultMaxImageUploadSize) + viper.SetDefault("maximagesize", consts.DefaultMaxImageSize) viper.SetDefault("enablesharing", true) viper.SetDefault("shareurl", "") viper.SetDefault("defaultshareexpiration", 8760*time.Hour) diff --git a/conf/configuration_test.go b/conf/configuration_test.go index 4eaa8e3d8..ac6477572 100644 --- a/conf/configuration_test.go +++ b/conf/configuration_test.go @@ -336,19 +336,10 @@ var _ = Describe("Configuration", func() { }) - Describe("ValidateMaxImageUploadSize", func() { - BeforeEach(func() { - viper.Reset() - conf.SetViperDefaults() - viper.SetDefault("datafolder", GinkgoT().TempDir()) - viper.SetDefault("loglevel", "error") - conf.ResetConf() - }) - + Describe("ValidateByteSize", func() { DescribeTable("accepts valid size values", func(input string) { - conf.Server.MaxImageUploadSize = input - Expect(conf.ValidateMaxImageUploadSize()).To(Succeed()) + Expect(conf.ValidateByteSize("MaxImageSize", input)()).To(Succeed()) }, Entry("megabytes", "10MB"), Entry("gigabytes", "1GB"), @@ -359,14 +350,39 @@ var _ = Describe("Configuration", func() { DescribeTable("rejects invalid size values", func(input string) { - conf.Server.MaxImageUploadSize = input - Expect(conf.ValidateMaxImageUploadSize()).To(MatchError(ContainSubstring("invalid MaxImageUploadSize"))) + Expect(conf.ValidateByteSize("MaxImageSize", input)()).To(MatchError(ContainSubstring("invalid MaxImageSize"))) }, Entry("garbage string", "not-a-size"), Entry("negative-looking", "-10MB"), + Entry("zero", "0"), + Entry("zero with unit", "0MB"), + Entry("overflows int64", "9223372036854775808"), ) }) + Describe("MaxImageSize floor", func() { + BeforeEach(func() { + viper.Reset() + conf.SetViperDefaults() + viper.SetDefault("datafolder", GinkgoT().TempDir()) + viper.SetDefault("loglevel", "error") + conf.ResetConf() + }) + + It("is raised to MaxImageUploadSize when configured lower", func() { + viper.SetDefault("maximagesize", "5MB") + viper.SetDefault("maximageuploadsize", "50MB") + conf.Load(true) + Expect(conf.Server.MaxImageSize).To(Equal("50MB")) + }) + + It("keeps a larger MaxImageSize unchanged", func() { + viper.SetDefault("maximagesize", "30MB") + conf.Load(true) + Expect(conf.Server.MaxImageSize).To(Equal("30MB")) + }) + }) + Describe("EnforceNonRootUser", func() { It("defaults to false", func() { conf.Load(true) diff --git a/conf/export_test.go b/conf/export_test.go index cbb64b3d0..d1e1a6f99 100644 --- a/conf/export_test.go +++ b/conf/export_test.go @@ -14,7 +14,7 @@ var NormalizeSearchBackend = normalizeSearchBackend var ToPascalCase = toPascalCase -var ValidateMaxImageUploadSize = validateMaxImageUploadSize +var ValidateByteSize = validateByteSize func SetRuntimeInfoForTest(goos string, euid int) func() { oldGOOS := currentGOOS diff --git a/consts/consts.go b/consts/consts.go index 46906b8b5..aed8ecf66 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -112,6 +112,7 @@ const ( const ( DefaultUICoverArtSize = 300 DefaultMaxImageUploadSize = "10MB" + DefaultMaxImageSize = "20MB" ) // Prometheus options diff --git a/core/artwork/processor.go b/core/artwork/processor.go index c3cf15b71..6ce76bf45 100644 --- a/core/artwork/processor.go +++ b/core/artwork/processor.go @@ -13,6 +13,8 @@ import ( "sync" "time" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/artwork/blurhash" "github.com/navidrome/navidrome/core/artwork/dominant" "github.com/navidrome/navidrome/core/artwork/thumbhash" @@ -51,7 +53,9 @@ const thumbnailSize = 100 // maxImageBytes caps a resolved image read: a user-editable ExternalImageURL could point at // an arbitrarily large endpoint. -const maxImageBytes = 20 << 20 +func maxImageBytes() int64 { + return parseSize(conf.Server.MaxImageSize, consts.DefaultMaxImageSize) +} // maxImagePixels guards against decompression bombs: a tiny file can declare a canvas that // image.Decode would expand into gigabytes. @@ -202,12 +206,13 @@ func writeAbsent(ctx context.Context, repo model.ArtworkRepository, item model.A } func readCapped(r io.Reader) ([]byte, error) { - data, err := io.ReadAll(io.LimitReader(r, maxImageBytes+1)) + limit := maxImageBytes() + data, err := io.ReadAll(io.LimitReader(r, limit+1)) if err != nil { return nil, err } - if len(data) > maxImageBytes { - return nil, fmt.Errorf("image exceeds size cap %d", maxImageBytes) + if int64(len(data)) > limit { + return nil, fmt.Errorf("image exceeds size cap %d", limit) } return data, nil } diff --git a/core/artwork/processor_test.go b/core/artwork/processor_test.go index d326dde09..8d30975cd 100644 --- a/core/artwork/processor_test.go +++ b/core/artwork/processor_test.go @@ -394,7 +394,7 @@ var _ = Describe("processor.acquire", func() { imgPath := filepath.Join(tmpDir, "artwork", "radio", "big_test.jpg") f, err := os.Create(imgPath) Expect(err).ToNot(HaveOccurred()) - Expect(f.Truncate(maxImageBytes + 1)).To(Succeed()) + Expect(f.Truncate(maxImageBytes() + 1)).To(Succeed()) Expect(f.Close()).To(Succeed()) radioRepo := tests.CreateMockedRadioRepo() @@ -493,3 +493,14 @@ var _ = Describe("makeThumbnail", func() { Expect(thumb.Pix[3]).To(BeNumerically("==", 128)) }) }) + +var _ = Describe("maxImageBytes", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + + It("reads MaxImageSize from config", func() { + conf.Server.MaxImageSize = "30MB" + Expect(maxImageBytes()).To(Equal(int64(30_000_000))) + }) +}) diff --git a/core/artwork/resolve_test.go b/core/artwork/resolve_test.go index fc1a6a3ff..9a0962cf2 100644 --- a/core/artwork/resolve_test.go +++ b/core/artwork/resolve_test.go @@ -625,7 +625,7 @@ var _ = Describe("decodeTile", func() { }) It("rejects a tile larger than the size cap", func() { - data := bytes.Repeat([]byte{0}, maxImageBytes+1) + data := bytes.Repeat([]byte{0}, int(maxImageBytes())+1) _, err := decodeTile(io.NopCloser(bytes.NewReader(data))) Expect(err).To(HaveOccurred()) }) diff --git a/core/artwork/uploader.go b/core/artwork/uploader.go index 9c38ae2e5..76526af3c 100644 --- a/core/artwork/uploader.go +++ b/core/artwork/uploader.go @@ -17,10 +17,14 @@ import ( // MaxImageUploadSize returns the configured max upload size in bytes, or the built-in default. func MaxImageUploadSize() int64 { - if size, err := humanize.ParseBytes(conf.Server.MaxImageUploadSize); err == nil && size > 0 { + return parseSize(conf.Server.MaxImageUploadSize, consts.DefaultMaxImageUploadSize) +} + +func parseSize(value, fallback string) int64 { + if size, err := humanize.ParseBytes(value); err == nil && size > 0 { return int64(size) } - size, _ := humanize.ParseBytes(consts.DefaultMaxImageUploadSize) + size, _ := humanize.ParseBytes(fallback) return int64(size) } From 036c9cab9671505c83fce6523f55d97b152b3b32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Tue, 11 Aug 2026 18:34:27 -0400 Subject: [PATCH 66/71] feat(jellyfin): send a synthetic placeholder blurhash for unresolved artwork (#5941) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(artwork): let callers pin the blurhash component counts * feat(artwork): synthesize a unique placeholder blurhash from a seed * fix(artwork): base the synthetic-hash no-collision guarantee on the prefix, not length The prior comment and test claimed a synthetic value could never collide with a real one because it's always shorter. That's false: components() targets ~16 tiles by scaling one axis down as the other hits the 9 cap, so an extreme aspect ratio (e.g. 10x200) collapses to 1x9 = 9 components, which encodes to the same 22 characters as a 3x3 synthetic hash. The old test only exercised a square 64x64 gradient, so it never caught this. The real guarantee is structural, not length-based: the first character encodes shape as (xComp-1)+(yComp-1)*9, and components() derives xf*yf = 16 exactly before flooring/capping (xf = sqrt(16w/h), yf = xf*h/w = sqrt(16h/w), so xf*yf = sqrt(256) = 16). Two factors both in [2,3) can't multiply to 16, so Encode can never derive 3x3 - the synthetic prefix 'K' is structurally exclusive to Synthetic. Replaced the length-based test with one that sweeps extreme aspect ratios and asserts no real encode ever produces prefix 'K', alongside the assertion that Synthetic always does. * feat(artwork): tint a synthetic blurhash from a base colour Parses baseColor into HSL and clamps saturation/lightness so the tint stays muted; per-cell hashing (unchanged) is what keeps a shared tint across an album's tracks from colliding, as the new 1M-seed spec proves under a single fixed colour. * fix(artwork): trim over-long comment in synthetic blurhash test Review flagged the collision spec's comment for exceeding the 2-line budget; the Finamp rationale it restated already lives in the design doc and commit history. * feat(jellyfin): send a synthetic blurhash for unresolved artwork Clients render nothing where a placeholder belongs when ImageBlurHashes is omitted for pending artwork. primaryImage now synthesizes a value (seeded on the tag, so a cover swap re-keys it) whenever a tag is present but no blurhash has been computed yet. Known-absent artwork (ImageAbsent) is unaffected: it still emits neither tag nor blurhash, since GetOrPlaceholder would otherwise pin a shared placeholder under a distinct cache key for a year. * fix(jellyfin): avoid computing a synthetic blurhash when a real one exists cmp.Or evaluates both arguments before choosing between them, so blurhash.Synthetic ran (and was discarded) on every call even when img.BlurHash was already set. That's the common case in production: artwork_repository.go populates BlurHash from persisted values once a scan resolves it, so a healthy library paid the synthesis cost (xxh3 hashing, HSL conversion, image alloc, DCT encode) on every mapped item for a value it never used. Branch on emptiness first instead. * feat(jellyfin): tint a pending track's placeholder with its album's colour primaryImage never reaches the embeddedArtPending branch of SongToBaseItem, since there is no resolved image yet to feed it. Seed the synthetic blurhash on mf.ID (so the value stays unique and the client still issues the read-through request) but tint it with the album's DominantColor, which is already hydrated on MediaFile at no extra cost. * style(artwork): trim synthetic blurhash comments to the budget * docs(jellyfin): fix stale blurhash README bullet + two review nits The "Blurhashes are synthetic" bullet under Known limitations described dto/blurhash.go, which was deleted when the real core/artwork-computed blurhash + synthetic-fallback pipeline landed; every claim in it was false. Replaced it with an accurate paragraph in the Images section, since the described behaviour is now the finished design, not a gap. Also: fix a doc/body comment mismatch in blurhash.go (component counts are 2..9, not 1..9), and deduplicate the inline DC-extraction logic in synthetic_test.go by reusing the existing dcOf helper. * refactor(artwork): slice the synthetic cell jitter on byte boundaries The three perturbations came off one hash with mismatched masks and shifts (0xFF at 0, 0x3F at 8, 0x3F at 14), so a reader had to do the arithmetic to confirm the fields did not overlap. Only 20 of the 64 bits were in use either way, so the narrower fields bought nothing. Uniform byte slices at 0/8/16 are non-overlapping by inspection and give each field the full 8 bits. Both 1,000,000-seed collision specs still measure 1,000,000 distinct values. Also drops the local `n` alias, which was a second name for synthComponents inside a 15-line function. * docs(jellyfin): fix the blurhash paragraph's opening sentence It opened with "follow the same principle", pointing back at the preceding paragraph on admin-context artwork resolution — an unrelated subject, so the reader looks for a connection that is not there. * fix(artwork): render the synthetic grid larger than its component count The 3x3 cell grid was handed straight to the encoder as a 3x3 image, so the source had exactly as many samples as basis functions. Blurhash normalises its coefficients by 1/(w*h) and 2/(w*h), which assumes many samples per component, so the AC terms came out far too large. At the bottom-right corner the x and y bases are both [1, -0.5, -0.5], everything lines up negative, and the result clamped to black — a dark blob on every synthetic placeholder. Rendering the same nine colours bilinearly at 8x8 first removes it: measured over three seeds, the darkest corner goes from 13 to 74 and the darkest pixel from 1 to 63. 8px is the smallest size that clears the artefact; 12 and 16 are visually indistinguishable and cost 1.7x and 2.7x more. The interpolation is hand-rolled rather than x/image's scaler, which allocated 528 times per call against 14 for this. Both 1,000,000-seed collision specs still measure 1,000,000 distinct values. * refactor(artwork): tidy the synthetic upscale helpers cellWeight nudged its upper bound with a 1e-9 epsilon so int() could never land on the last cell. Clamping the coordinate and then the index says the same thing without a magic constant, and makes the clamp-don't-extrapolate intent explicit — edge pixels map outside the cell centres, so the fraction would otherwise run past 1. The grid type is spelled once as colorGrid rather than repeated in the local and the upscale signature, and encodeAt's doc now states the source-size contract that Synthetic depends on, so the next caller sees it at the function rather than only in synthetic.go. Output is unchanged: all seven sample hashes match byte for byte. * perf(artwork): make the synthetic upscale separable Both axes are square and constant-sized, so the per-pixel cell index and weight were the same 8 values recomputed 64 times per call. They are now built once by sync.OnceValue, matching the srgbToLinearTable pattern. The interpolation is also separable: stretching each of the 3 grid rows horizontally once and then blending rows vertically does 264 lerps where the per-pixel form did 576. upscale drops from 347ns to 260ns, Synthetic from 1780ns to 1669ns. Output is byte-identical across all seven sample hashes — same operations in the same order, only hoisted. * refactor(artwork): let a caller supply the cosine basis encodeAt built its cosine tables inline, so Synthetic rebuilt bit-identical ones on every call — its shape is always 3 components over 8 pixels. Splitting the table construction into cosBasis and the encoder proper into encodePixels lets Synthetic build the basis once via sync.OnceValue, and leaves encodeAt's signature and behaviour untouched. Synthetic drops from 14 allocations to 6 (1669ns to 1508ns). The time saving is small and this path only runs while artwork is still unresolved; the allocation cut is the point, alongside a shorter encodeAt. Every hash is unchanged: nine real Encode outputs spanning square, 10x200, 200x10, 1x50 and a real JPEG, plus all seven synthetic samples, all byte for byte identical before and after. --- core/artwork/blurhash/blurhash.go | 41 +++--- core/artwork/blurhash/synthetic.go | 172 ++++++++++++++++++++++++ core/artwork/blurhash/synthetic_test.go | 103 ++++++++++++++ server/jellyfin/README.md | 18 ++- server/jellyfin/dto/mappers.go | 15 ++- server/jellyfin/dto/mappers_test.go | 54 ++++++-- 6 files changed, 364 insertions(+), 39 deletions(-) create mode 100644 core/artwork/blurhash/synthetic.go create mode 100644 core/artwork/blurhash/synthetic_test.go diff --git a/core/artwork/blurhash/blurhash.go b/core/artwork/blurhash/blurhash.go index ca5bf7026..28d3409f3 100644 --- a/core/artwork/blurhash/blurhash.go +++ b/core/artwork/blurhash/blurhash.go @@ -32,23 +32,32 @@ func Encode(img image.Image) (string, error) { } // Pre-downscale: its rounding can flip a component count, and the hash is a client cache key. xComp, yComp := components(img.Bounds().Dx(), img.Bounds().Dy()) - src := pixelsOf(downscale(img)) - w, h := src.w, src.h + return encodeAt(img, xComp, yComp), nil +} - cosX := make([][]float64, xComp) - for i := range cosX { - cosX[i] = make([]float64, w) - for x := range cosX[i] { - cosX[i][x] = math.Cos(math.Pi * float64(i) * float64(x) / float64(w)) - } - } - cosY := make([][]float64, yComp) - for j := range cosY { - cosY[j] = make([]float64, h) - for y := range cosY[j] { - cosY[j][y] = math.Cos(math.Pi * float64(j) * float64(y) / float64(h)) +// encodeAt encodes img at the given component counts (each 2..9). Callers guarantee non-empty +// bounds, and a source far larger than the counts: too few samples overshoot the AC terms. +func encodeAt(img image.Image, xComp, yComp int) string { + src := pixelsOf(downscale(img)) + return encodePixels(src, xComp, yComp, cosBasis(xComp, src.w), cosBasis(yComp, src.h)) +} + +// cosBasis is the cosine basis for comp components sampled across n pixels. It depends only +// on its arguments, so a caller with a fixed shape can build it once and reuse it. +func cosBasis(comp, n int) [][]float64 { + basis := make([][]float64, comp) + for i := range basis { + basis[i] = make([]float64, n) + for x := range basis[i] { + basis[i][x] = math.Cos(math.Pi * float64(i) * float64(x) / float64(n)) } } + return basis +} + +// encodePixels is the encoder proper; cosX and cosY must match src's dimensions. +func encodePixels(src pixels, xComp, yComp int, cosX, cosY [][]float64) string { + w, h := src.w, src.h lin := srgbToLinearTable() factors := make([][3]float64, xComp*yComp) @@ -86,7 +95,7 @@ func Encode(img image.Image) (string, error) { var sb strings.Builder sb.WriteString(encode83((xComp-1)+(yComp-1)*9, 1)) - // Derived counts are at least 1x9, so there is always at least one AC factor. + // Every caller passes at least 2 components, so there is always at least one AC factor. ac := factors[1:] actualMax := 0.0 for _, f := range ac { @@ -101,7 +110,7 @@ func Encode(img image.Image) (string, error) { for _, f := range ac { sb.WriteString(encode83(quantAC(f[0], maxVal)*19*19+quantAC(f[1], maxVal)*19+quantAC(f[2], maxVal), 2)) } - return sb.String(), nil + return sb.String() } // pixels is direct Pix access for the pixel loop, avoiding a per-pixel allocation via image.At. diff --git a/core/artwork/blurhash/synthetic.go b/core/artwork/blurhash/synthetic.go new file mode 100644 index 000000000..1e15507d8 --- /dev/null +++ b/core/artwork/blurhash/synthetic.go @@ -0,0 +1,172 @@ +package blurhash + +import ( + "image" + "math" + "strconv" + "sync" + + "github.com/zeebo/xxh3" +) + +// A real hash never has 3x3 components: components() always targets ~16 tiles. +const synthComponents = 3 + +// Encoding a source no bigger than the component grid overshoots the AC coefficients, +// clamping the corners to black; the encoder assumes many samples per component. +const synthSourceSize = 8 + +type colorGrid = [synthComponents * synthComponents][3]float64 + +// Synthetic returns a blurhash unique to seed, for artwork whose real hash does not exist +// yet. baseColor ("#rrggbb") sets the hue family; "" derives it from the seed. +func Synthetic(seed, baseColor string) string { + hue, sat, light := baseTone(baseColor, xxh3.HashStringSeed(seed, 0)) + + var grid colorGrid + for i := range grid { + // Each cell hashes the seed separately, so one tint shared by many items still + // yields one value per item. + bits := xxh3.HashStringSeed(seed, uint64(i)+1) + dh := (byteFrac(bits, 0) - 0.5) * 60 + ds := (byteFrac(bits, 8) - 0.5) * 0.16 + dl := (byteFrac(bits, 16) - 0.5) * 0.30 + r, g, b := hslToRGB(hue+dh, clamp01(sat+ds), clamp01(light+dl)) + grid[i] = [3]float64{float64(r), float64(g), float64(b)} + } + // The shape is fixed, so the cosine basis is reused instead of rebuilt per call. + basis := synthBasis() + return encodePixels(pixelsOf(upscale(&grid)), synthComponents, synthComponents, basis, basis) +} + +// synthBasis is the cosine basis for the fixed synthetic shape. Square, so one serves both axes. +var synthBasis = sync.OnceValue(func() [][]float64 { + return cosBasis(synthComponents, synthSourceSize) +}) + +// upscale renders the cell grid bilinearly at synthSourceSize. Hand-rolled because +// x/image's scaler allocates per call, and this runs once per mapped item. +func upscale(grid *colorGrid) *image.NRGBA { + const n, size = synthComponents, synthSourceSize + axis := axisWeights() + + // Separable: each grid row is stretched horizontally once, then rows blend vertically. + // Doing it per pixel instead would repeat every horizontal lerp `size` times. + var rows [n][size][3]float64 + for r := range n { + for x, a := range axis { + for c := range 3 { + rows[r][x][c] = grid[r*n+a.cell][c]*(1-a.frac) + grid[r*n+a.cell+1][c]*a.frac + } + } + } + + img := image.NewNRGBA(image.Rect(0, 0, size, size)) + for y, a := range axis { + top, bot := &rows[a.cell], &rows[a.cell+1] + for x := range size { + p := img.Pix[y*img.Stride+x*4:] + for c := range 3 { + p[c] = uint8(top[x][c]*(1-a.frac) + bot[x][c]*a.frac + 0.5) + } + p[3] = 255 + } + } + return img +} + +// axisWeights maps each source pixel to its lower cell and the fraction toward the next. +// Both axes are square and constant-sized, so one table serves both and outlives the call. +var axisWeights = sync.OnceValue(func() *[synthSourceSize]struct { + cell int + frac float64 +} { + var t [synthSourceSize]struct { + cell int + frac float64 + } + for i := range t { + // Edge pixels fall outside the cell centres, so both ends clamp rather than extrapolate. + f := min(max((float64(i)+0.5)*synthComponents/synthSourceSize-0.5, 0), synthComponents-1) + t[i].cell = min(int(f), synthComponents-2) + t[i].frac = f - float64(t[i].cell) + } + return &t +}) + +func byteFrac(bits uint64, shift int) float64 { + return float64(bits>>shift&0xFF) / 255 +} + +// baseTone clamps saturation and lightness away from the extremes, so a placeholder +// never reads as neon or as solid black. +func baseTone(baseColor string, hueBits uint64) (hue, sat, light float64) { + if r, g, b, ok := parseHex(baseColor); ok { + h, s, l := rgbToHSL(r, g, b) + return h, min(max(s, 0.10), 0.35), min(max(l, 0.15), 0.75) + } + return float64(hueBits&0x1FF) / 512 * 360, 0.22, 0.40 +} + +func parseHex(s string) (r, g, b uint8, ok bool) { + if len(s) != 7 || s[0] != '#' { + return 0, 0, 0, false + } + v, err := strconv.ParseUint(s[1:], 16, 32) + if err != nil { + return 0, 0, 0, false + } + return uint8(v >> 16), uint8(v >> 8), uint8(v), true +} + +func rgbToHSL(r, g, b uint8) (h, s, l float64) { + rf, gf, bf := float64(r)/255, float64(g)/255, float64(b)/255 + mx, mn := max(rf, gf, bf), min(rf, gf, bf) + l = (mx + mn) / 2 + if mx == mn { + return 0, 0, l + } + d := mx - mn + s = d / (1 - math.Abs(2*l-1)) + switch mx { + case rf: + h = math.Mod((gf-bf)/d, 6) + case gf: + h = (bf-rf)/d + 2 + default: + h = (rf-gf)/d + 4 + } + h *= 60 + if h < 0 { + h += 360 + } + return h, s, l +} + +func hslToRGB(h, s, l float64) (uint8, uint8, uint8) { + h = math.Mod(math.Mod(h, 360)+360, 360) + c := (1 - math.Abs(2*l-1)) * s + hp := h / 60 + x := c * (1 - math.Abs(math.Mod(hp, 2)-1)) + var r, g, b float64 + switch int(hp) { + case 0: + r, g, b = c, x, 0 + case 1: + r, g, b = x, c, 0 + case 2: + r, g, b = 0, c, x + case 3: + r, g, b = 0, x, c + case 4: + r, g, b = x, 0, c + default: + r, g, b = c, 0, x + } + m := l - c/2 + return to8(r + m), to8(g + m), to8(b + m) +} + +func clamp01(v float64) float64 { return min(max(v, 0), 1) } + +func to8(v float64) uint8 { return uint8(math.Round(clamp01(v) * 255)) } diff --git a/core/artwork/blurhash/synthetic_test.go b/core/artwork/blurhash/synthetic_test.go new file mode 100644 index 000000000..671c1cd30 --- /dev/null +++ b/core/artwork/blurhash/synthetic_test.go @@ -0,0 +1,103 @@ +package blurhash_test + +import ( + "fmt" + "strings" + + "github.com/navidrome/navidrome/core/artwork/blurhash" + "github.com/zeebo/xxh3" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Synthetic", func() { + It("returns a well-formed 3x3 blurhash", func() { + h := blurhash.Synthetic("alb-1", "") + // 3x3 encodes as (3-1)+(3-1)*9 = 20, which is base83 'K'; length is 1+1+4+8*2. + Expect(h).To(HaveLen(22)) + Expect(h).To(HavePrefix("K")) + for _, c := range h { + Expect(strings.ContainsRune(alphabet, c)).To(BeTrue(), "unexpected char %q", c) + } + }) + + It("keeps its prefix exclusive to real encodes, across aspect ratios", func() { + Expect(blurhash.Synthetic("alb-1", "")).To(HavePrefix("K")) + + ratios := []struct{ w, h int }{ + {10, 200}, {200, 10}, {1, 50}, {50, 1}, {64, 64}, {100, 300}, {300, 100}, + } + for _, r := range ratios { + encoded, err := blurhash.Encode(gradientImage(r.w, r.h)) + Expect(err).ToNot(HaveOccurred()) + Expect(encoded).ToNot(HavePrefix("K"), "real encode at %dx%d produced the synthetic prefix", r.w, r.h) + } + }) + + It("is deterministic for one seed", func() { + Expect(blurhash.Synthetic("alb-1", "")).To(Equal(blurhash.Synthetic("alb-1", ""))) + }) + + It("differs across seeds", func() { + Expect(blurhash.Synthetic("alb-1", "")).ToNot(Equal(blurhash.Synthetic("alb-2", ""))) + }) + + dcOf := func(hash string) (int, int, int) { + dc := decode83(hash[2:6]) + return dc >> 16 & 0xFF, dc >> 8 & 0xFF, dc & 0xFF + } + + It("decodes to a muted DC colour", func() { + r, g, b := dcOf(blurhash.Synthetic("alb-1", "")) + spread := max(r, g, b) - min(r, g, b) + Expect(spread).To(BeNumerically("<", 120), "saturation is capped, so no channel should run away") + Expect(max(r, g, b)).To(BeNumerically("<", 240), "lightness is capped below white") + Expect(min(r, g, b)).To(BeNumerically(">", 10), "lightness is capped above black") + }) + + It("keeps 1,000,000 seeds effectively distinct", func() { + const count = 1_000_000 + seen := make(map[uint64]struct{}, count) + for i := range count { + seen[xxh3.HashString(blurhash.Synthetic(fmt.Sprintf("item-%d", i), ""))] = struct{}{} + } + Expect(len(seen)).To(BeNumerically(">=", 999_900)) + }) + + It("leans the DC towards a red tint", func() { + r, g, b := dcOf(blurhash.Synthetic("alb-1", "#c04040")) + Expect(r).To(BeNumerically(">", g)) + Expect(r).To(BeNumerically(">", b)) + }) + + It("leans the DC towards a blue tint", func() { + r, g, b := dcOf(blurhash.Synthetic("alb-1", "#4040c0")) + Expect(b).To(BeNumerically(">", r)) + Expect(b).To(BeNumerically(">", g)) + }) + + It("changes the value when only the tint changes", func() { + Expect(blurhash.Synthetic("alb-1", "#c04040")).ToNot(Equal(blurhash.Synthetic("alb-1", "#4040c0"))) + }) + + It("falls back to the seed hue for an unparseable tint", func() { + Expect(blurhash.Synthetic("alb-1", "not-a-colour")).To(Equal(blurhash.Synthetic("alb-1", ""))) + }) + + It("tracks a dark tint's lightness", func() { + dark, _, _ := dcOf(blurhash.Synthetic("alb-1", "#101820")) + light, _, _ := dcOf(blurhash.Synthetic("alb-1", "#e8f0f8")) + Expect(dark).To(BeNumerically("<", light)) + }) + + // A shared tint must not become a shared value: the seed alone carries uniqueness. + It("keeps 1,000,000 seeds distinct under a single fixed tint", func() { + const count = 1_000_000 + seen := make(map[uint64]struct{}, count) + for i := range count { + seen[xxh3.HashString(blurhash.Synthetic(fmt.Sprintf("track-%d", i), "#3a5f7d"))] = struct{}{} + } + Expect(len(seen)).To(BeNumerically(">=", 999_900)) + }) +}) diff --git a/server/jellyfin/README.md b/server/jellyfin/README.md index 13ec79aa8..b07aaee8d 100644 --- a/server/jellyfin/README.md +++ b/server/jellyfin/README.md @@ -180,6 +180,14 @@ warmer uses — so user-scoped items like private playlists still resolve their falling back to the placeholder. Album, artist, media-file and playlist ids are all resolved to their Navidrome `ArtworkID`. +Blurhashes come in three tiers. A real blurhash is computed once in +`core/artwork` from the decoded image and stored per artwork row; the mappers read it whenever it +exists. While artwork is still unresolved, `dto/mappers.go` instead emits a synthetic 3x3 blurhash +(`core/artwork/blurhash.Synthetic`), seeded on the image tag so it's effectively unique per image +and can never collide with a real hash's leading byte, giving clients a valid cache key while art +loads. A track awaiting embedded-art extraction has its synthetic hash tinted from the parent +album's dominant colour. Known-absent artwork emits no tag and no blurhash at all. + ## Finamp saved-queue id truncation Real Jellyfin item ids are GUIDs — 128-bit values, always 32 hex characters. Finamp relies on that @@ -330,16 +338,6 @@ make test PKG=./server/jellyfin/... 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. -- **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 diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go index 31cdba1dd..d2bdb1b2e 100644 --- a/server/jellyfin/dto/mappers.go +++ b/server/jellyfin/dto/mappers.go @@ -6,6 +6,7 @@ import ( "time" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core/artwork/blurhash" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/slice" ) @@ -212,6 +213,8 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto { // Nothing enqueues media files: advertising the id is what makes a client ask, and that // request is what extracts the embedded art and queues the track. item.ImageTags = map[string]string{"Primary": mf.ID} + hash := blurhash.Synthetic(mf.ID, mf.AlbumImage.DominantColor) + item.ImageBlurHashes = map[string]map[string]string{"Primary": {mf.ID: hash}} } else if mf.AlbumID != "" { if tag, blurs, ratio := primaryImage(mf.AlbumImage, mf.AlbumID, fields); tag != "" { item.AlbumPrimaryImageTag = tag @@ -227,15 +230,19 @@ func embeddedArtPending(mf model.MediaFile) bool { mf.ImageHash == "" && !mf.ItemImage.ImageAbsent } -// primaryImage never fakes a blurhash: clients key their cover cache on the value, which would -// pin a stale cover forever. +// primaryImage synthesizes a blurhash when none was computed yet: clients key their cover +// cache on the value, so it must be unique per image, never shared or reused. func primaryImage(img model.ItemImage, fallback string, fields Fields) (tag string, blurs map[string]map[string]string, ratio *float64) { if img.ImageAbsent { return "", nil, nil } tag = cmp.Or(img.ImageHash, fallback) - if img.BlurHash != "" { - blurs = map[string]map[string]string{"Primary": {tag: img.BlurHash}} + if tag != "" { + hash := img.BlurHash + if hash == "" { + hash = blurhash.Synthetic(tag, "") + } + blurs = map[string]map[string]string{"Primary": {tag: hash}} } if fields.Has("PrimaryImageAspectRatio") { ratio = img.AspectRatio() diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go index 77b78e55f..451e1e08b 100644 --- a/server/jellyfin/dto/mappers_test.go +++ b/server/jellyfin/dto/mappers_test.go @@ -6,6 +6,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/artwork/blurhash" "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -37,7 +38,7 @@ var _ = Describe("mappers", func() { Expect(item.UserData.Key).To(Equal(EncodeID("song-1"))) Expect(item.UserData.ItemId).To(Equal(EncodeID("song-1"))) Expect(item.AlbumPrimaryImageTag).To(Equal("alb-1")) - Expect(item.ImageBlurHashes).To(BeNil()) + Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("alb-1", blurhash.Synthetic("alb-1", ""))) Expect(item.Genres).To(Equal([]string{"genre 1", "genre 2"})) Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID("1"), Name: "genre 1"}, {Id: EncodeID("2"), Name: "genre 2"}})) }) @@ -298,7 +299,7 @@ var _ = Describe("mappers", func() { Expect(*item.ProductionYear).To(Equal(1999)) Expect(*item.ChildCount).To(Equal(10)) Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "alb-1")) - Expect(item.ImageBlurHashes).To(BeNil()) + Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("alb-1", blurhash.Synthetic("alb-1", ""))) Expect(item.Genres).To(Equal([]string{"genre 1", "genre 2"})) Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID("1"), Name: "genre 1"}, {Id: EncodeID("2"), Name: "genre 2"}})) }) @@ -472,7 +473,7 @@ var _ = Describe("mappers", func() { Expect(item.UserData.PlayCount).To(Equal(2)) Expect(*item.UserData.Rating).To(Equal(8.0)) Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "pl-1")) - Expect(item.ImageBlurHashes).To(BeNil()) + Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("pl-1", blurhash.Synthetic("pl-1", ""))) }) It("changes the playlist image tag when the cover content changes", func() { @@ -507,13 +508,37 @@ var _ = Describe("mappers", func() { It("advertises the track id so the client triggers the read-through", func() { mf := model.MediaFile{ID: "mf-1", AlbumID: "alb-1", HasCoverArt: true} mf.AlbumImage.ImageHash = "0123456789abcdef" + mf.AlbumImage.DominantColor = "#3a5f7d" item := SongToBaseItem(mf, nil) Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "mf-1")) - Expect(item.ImageBlurHashes).To(BeNil(), "no resolved image means no blurhash to send") + Expect(item.ImageBlurHashes["Primary"]).To( + HaveKeyWithValue("mf-1", blurhash.Synthetic("mf-1", "#3a5f7d"))) Expect(item.AlbumPrimaryImageTag).To(BeEmpty()) }) + // The client's image request is what extracts the embedded art. Reusing the album's + // value would let it answer from cache and never send it. + It("gives the track a value distinct from its album's", func() { + mf := model.MediaFile{ID: "mf-3", AlbumID: "alb-1", HasCoverArt: true} + mf.AlbumImage.ImageHash = "0123456789abcdef" + mf.AlbumImage.BlurHash = "LEHV6nWB2yk8" + mf.AlbumImage.DominantColor = "#3a5f7d" + + track := SongToBaseItem(mf, nil).ImageBlurHashes["Primary"]["mf-3"] + album := AlbumToBaseItem(model.Album{ID: "alb-1", ItemImage: mf.AlbumImage}, nil). + ImageBlurHashes["Primary"]["0123456789abcdef"] + Expect(track).ToNot(BeEmpty()) + Expect(track).ToNot(Equal(album)) + }) + + It("still emits a value when the album has no dominant colour", func() { + mf := model.MediaFile{ID: "mf-4", AlbumID: "alb-1", HasCoverArt: true} + + item := SongToBaseItem(mf, nil) + Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("mf-4", blurhash.Synthetic("mf-4", ""))) + }) + It("falls back to the album when the track has no art of its own", func() { mf := model.MediaFile{ID: "mf-2", AlbumID: "alb-1", HasCoverArt: false} mf.AlbumImage.ImageHash = "0123456789abcdef" @@ -555,13 +580,14 @@ var _ = Describe("mappers", func() { Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("0123456789abcdef", "LEHV6nWB2yk8")) }) - It("omits the blurhash entirely when none was computed", func() { + It("synthesizes a blurhash when none was computed", func() { al := model.Album{ID: "alb-2", Name: "Album"} al.ImageHash = "0123456789abcdef" item := AlbumToBaseItem(al, nil) Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "0123456789abcdef")) - Expect(item.ImageBlurHashes).To(BeNil(), "a synthesized blurhash pins stale covers in Finamp") + Expect(item.ImageBlurHashes["Primary"]).To( + HaveKeyWithValue("0123456789abcdef", blurhash.Synthetic("0123456789abcdef", ""))) }) It("omits tags for known-absent artwork", func() { @@ -573,10 +599,19 @@ var _ = Describe("mappers", func() { Expect(item.ImageBlurHashes).To(BeNil()) }) + It("keeps known-absent artwork free of any synthesized value", func() { + al := model.Album{ID: "alb-5", Name: "Album"} + al.ImageAbsent = true + + item := AlbumToBaseItem(al, nil) + Expect(item.ImageBlurHashes).To(BeNil(), + "there is no image to key a cache on, and nothing will ever re-key it") + }) + It("falls back to the entity id while artwork is still unresolved", func() { item := AlbumToBaseItem(model.Album{ID: "alb-4", Name: "Album"}, nil) Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "alb-4")) - Expect(item.ImageBlurHashes).To(BeNil()) + Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("alb-4", blurhash.Synthetic("alb-4", ""))) }) It("versions an artist's tag by content hash", func() { @@ -601,13 +636,14 @@ var _ = Describe("mappers", func() { Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("0123456789abcdef", "LEHV6nWB2yk8")) }) - It("never synthesizes a song blurhash when the album has none", func() { + It("synthesizes a song's album blurhash when the album has none", func() { mf := model.MediaFile{ID: "song-2", Title: "Song", AlbumID: "alb-2"} mf.AlbumImage.ImageHash = "0123456789abcdef" item := SongToBaseItem(mf, nil) Expect(item.AlbumPrimaryImageTag).To(Equal("0123456789abcdef")) - Expect(item.ImageBlurHashes).To(BeNil()) + Expect(item.ImageBlurHashes["Primary"]).To( + HaveKeyWithValue("0123456789abcdef", blurhash.Synthetic("0123456789abcdef", ""))) }) It("omits a song's album tag when the album art is known absent", func() { From 8978c7b9facee94d52e371d367a1f74394439dd9 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Wed, 12 Aug 2026 11:46:23 -0400 Subject: [PATCH 67/71] Revert "feat(jellyfin): send a synthetic placeholder blurhash for unresolved artwork (#5941)" This reverts commit 036c9cab9671505c83fce6523f55d97b152b3b32. --- core/artwork/blurhash/blurhash.go | 39 +++--- core/artwork/blurhash/synthetic.go | 172 ------------------------ core/artwork/blurhash/synthetic_test.go | 103 -------------- server/jellyfin/README.md | 18 +-- server/jellyfin/dto/mappers.go | 15 +-- server/jellyfin/dto/mappers_test.go | 54 ++------ 6 files changed, 38 insertions(+), 363 deletions(-) delete mode 100644 core/artwork/blurhash/synthetic.go delete mode 100644 core/artwork/blurhash/synthetic_test.go diff --git a/core/artwork/blurhash/blurhash.go b/core/artwork/blurhash/blurhash.go index 28d3409f3..ca5bf7026 100644 --- a/core/artwork/blurhash/blurhash.go +++ b/core/artwork/blurhash/blurhash.go @@ -32,32 +32,23 @@ func Encode(img image.Image) (string, error) { } // Pre-downscale: its rounding can flip a component count, and the hash is a client cache key. xComp, yComp := components(img.Bounds().Dx(), img.Bounds().Dy()) - return encodeAt(img, xComp, yComp), nil -} - -// encodeAt encodes img at the given component counts (each 2..9). Callers guarantee non-empty -// bounds, and a source far larger than the counts: too few samples overshoot the AC terms. -func encodeAt(img image.Image, xComp, yComp int) string { src := pixelsOf(downscale(img)) - return encodePixels(src, xComp, yComp, cosBasis(xComp, src.w), cosBasis(yComp, src.h)) -} + w, h := src.w, src.h -// cosBasis is the cosine basis for comp components sampled across n pixels. It depends only -// on its arguments, so a caller with a fixed shape can build it once and reuse it. -func cosBasis(comp, n int) [][]float64 { - basis := make([][]float64, comp) - for i := range basis { - basis[i] = make([]float64, n) - for x := range basis[i] { - basis[i][x] = math.Cos(math.Pi * float64(i) * float64(x) / float64(n)) + cosX := make([][]float64, xComp) + for i := range cosX { + cosX[i] = make([]float64, w) + for x := range cosX[i] { + cosX[i][x] = math.Cos(math.Pi * float64(i) * float64(x) / float64(w)) + } + } + cosY := make([][]float64, yComp) + for j := range cosY { + cosY[j] = make([]float64, h) + for y := range cosY[j] { + cosY[j][y] = math.Cos(math.Pi * float64(j) * float64(y) / float64(h)) } } - return basis -} - -// encodePixels is the encoder proper; cosX and cosY must match src's dimensions. -func encodePixels(src pixels, xComp, yComp int, cosX, cosY [][]float64) string { - w, h := src.w, src.h lin := srgbToLinearTable() factors := make([][3]float64, xComp*yComp) @@ -95,7 +86,7 @@ func encodePixels(src pixels, xComp, yComp int, cosX, cosY [][]float64) string { var sb strings.Builder sb.WriteString(encode83((xComp-1)+(yComp-1)*9, 1)) - // Every caller passes at least 2 components, so there is always at least one AC factor. + // Derived counts are at least 1x9, so there is always at least one AC factor. ac := factors[1:] actualMax := 0.0 for _, f := range ac { @@ -110,7 +101,7 @@ func encodePixels(src pixels, xComp, yComp int, cosX, cosY [][]float64) string { for _, f := range ac { sb.WriteString(encode83(quantAC(f[0], maxVal)*19*19+quantAC(f[1], maxVal)*19+quantAC(f[2], maxVal), 2)) } - return sb.String() + return sb.String(), nil } // pixels is direct Pix access for the pixel loop, avoiding a per-pixel allocation via image.At. diff --git a/core/artwork/blurhash/synthetic.go b/core/artwork/blurhash/synthetic.go deleted file mode 100644 index 1e15507d8..000000000 --- a/core/artwork/blurhash/synthetic.go +++ /dev/null @@ -1,172 +0,0 @@ -package blurhash - -import ( - "image" - "math" - "strconv" - "sync" - - "github.com/zeebo/xxh3" -) - -// A real hash never has 3x3 components: components() always targets ~16 tiles. -const synthComponents = 3 - -// Encoding a source no bigger than the component grid overshoots the AC coefficients, -// clamping the corners to black; the encoder assumes many samples per component. -const synthSourceSize = 8 - -type colorGrid = [synthComponents * synthComponents][3]float64 - -// Synthetic returns a blurhash unique to seed, for artwork whose real hash does not exist -// yet. baseColor ("#rrggbb") sets the hue family; "" derives it from the seed. -func Synthetic(seed, baseColor string) string { - hue, sat, light := baseTone(baseColor, xxh3.HashStringSeed(seed, 0)) - - var grid colorGrid - for i := range grid { - // Each cell hashes the seed separately, so one tint shared by many items still - // yields one value per item. - bits := xxh3.HashStringSeed(seed, uint64(i)+1) - dh := (byteFrac(bits, 0) - 0.5) * 60 - ds := (byteFrac(bits, 8) - 0.5) * 0.16 - dl := (byteFrac(bits, 16) - 0.5) * 0.30 - r, g, b := hslToRGB(hue+dh, clamp01(sat+ds), clamp01(light+dl)) - grid[i] = [3]float64{float64(r), float64(g), float64(b)} - } - // The shape is fixed, so the cosine basis is reused instead of rebuilt per call. - basis := synthBasis() - return encodePixels(pixelsOf(upscale(&grid)), synthComponents, synthComponents, basis, basis) -} - -// synthBasis is the cosine basis for the fixed synthetic shape. Square, so one serves both axes. -var synthBasis = sync.OnceValue(func() [][]float64 { - return cosBasis(synthComponents, synthSourceSize) -}) - -// upscale renders the cell grid bilinearly at synthSourceSize. Hand-rolled because -// x/image's scaler allocates per call, and this runs once per mapped item. -func upscale(grid *colorGrid) *image.NRGBA { - const n, size = synthComponents, synthSourceSize - axis := axisWeights() - - // Separable: each grid row is stretched horizontally once, then rows blend vertically. - // Doing it per pixel instead would repeat every horizontal lerp `size` times. - var rows [n][size][3]float64 - for r := range n { - for x, a := range axis { - for c := range 3 { - rows[r][x][c] = grid[r*n+a.cell][c]*(1-a.frac) + grid[r*n+a.cell+1][c]*a.frac - } - } - } - - img := image.NewNRGBA(image.Rect(0, 0, size, size)) - for y, a := range axis { - top, bot := &rows[a.cell], &rows[a.cell+1] - for x := range size { - p := img.Pix[y*img.Stride+x*4:] - for c := range 3 { - p[c] = uint8(top[x][c]*(1-a.frac) + bot[x][c]*a.frac + 0.5) - } - p[3] = 255 - } - } - return img -} - -// axisWeights maps each source pixel to its lower cell and the fraction toward the next. -// Both axes are square and constant-sized, so one table serves both and outlives the call. -var axisWeights = sync.OnceValue(func() *[synthSourceSize]struct { - cell int - frac float64 -} { - var t [synthSourceSize]struct { - cell int - frac float64 - } - for i := range t { - // Edge pixels fall outside the cell centres, so both ends clamp rather than extrapolate. - f := min(max((float64(i)+0.5)*synthComponents/synthSourceSize-0.5, 0), synthComponents-1) - t[i].cell = min(int(f), synthComponents-2) - t[i].frac = f - float64(t[i].cell) - } - return &t -}) - -func byteFrac(bits uint64, shift int) float64 { - return float64(bits>>shift&0xFF) / 255 -} - -// baseTone clamps saturation and lightness away from the extremes, so a placeholder -// never reads as neon or as solid black. -func baseTone(baseColor string, hueBits uint64) (hue, sat, light float64) { - if r, g, b, ok := parseHex(baseColor); ok { - h, s, l := rgbToHSL(r, g, b) - return h, min(max(s, 0.10), 0.35), min(max(l, 0.15), 0.75) - } - return float64(hueBits&0x1FF) / 512 * 360, 0.22, 0.40 -} - -func parseHex(s string) (r, g, b uint8, ok bool) { - if len(s) != 7 || s[0] != '#' { - return 0, 0, 0, false - } - v, err := strconv.ParseUint(s[1:], 16, 32) - if err != nil { - return 0, 0, 0, false - } - return uint8(v >> 16), uint8(v >> 8), uint8(v), true -} - -func rgbToHSL(r, g, b uint8) (h, s, l float64) { - rf, gf, bf := float64(r)/255, float64(g)/255, float64(b)/255 - mx, mn := max(rf, gf, bf), min(rf, gf, bf) - l = (mx + mn) / 2 - if mx == mn { - return 0, 0, l - } - d := mx - mn - s = d / (1 - math.Abs(2*l-1)) - switch mx { - case rf: - h = math.Mod((gf-bf)/d, 6) - case gf: - h = (bf-rf)/d + 2 - default: - h = (rf-gf)/d + 4 - } - h *= 60 - if h < 0 { - h += 360 - } - return h, s, l -} - -func hslToRGB(h, s, l float64) (uint8, uint8, uint8) { - h = math.Mod(math.Mod(h, 360)+360, 360) - c := (1 - math.Abs(2*l-1)) * s - hp := h / 60 - x := c * (1 - math.Abs(math.Mod(hp, 2)-1)) - var r, g, b float64 - switch int(hp) { - case 0: - r, g, b = c, x, 0 - case 1: - r, g, b = x, c, 0 - case 2: - r, g, b = 0, c, x - case 3: - r, g, b = 0, x, c - case 4: - r, g, b = x, 0, c - default: - r, g, b = c, 0, x - } - m := l - c/2 - return to8(r + m), to8(g + m), to8(b + m) -} - -func clamp01(v float64) float64 { return min(max(v, 0), 1) } - -func to8(v float64) uint8 { return uint8(math.Round(clamp01(v) * 255)) } diff --git a/core/artwork/blurhash/synthetic_test.go b/core/artwork/blurhash/synthetic_test.go deleted file mode 100644 index 671c1cd30..000000000 --- a/core/artwork/blurhash/synthetic_test.go +++ /dev/null @@ -1,103 +0,0 @@ -package blurhash_test - -import ( - "fmt" - "strings" - - "github.com/navidrome/navidrome/core/artwork/blurhash" - "github.com/zeebo/xxh3" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Synthetic", func() { - It("returns a well-formed 3x3 blurhash", func() { - h := blurhash.Synthetic("alb-1", "") - // 3x3 encodes as (3-1)+(3-1)*9 = 20, which is base83 'K'; length is 1+1+4+8*2. - Expect(h).To(HaveLen(22)) - Expect(h).To(HavePrefix("K")) - for _, c := range h { - Expect(strings.ContainsRune(alphabet, c)).To(BeTrue(), "unexpected char %q", c) - } - }) - - It("keeps its prefix exclusive to real encodes, across aspect ratios", func() { - Expect(blurhash.Synthetic("alb-1", "")).To(HavePrefix("K")) - - ratios := []struct{ w, h int }{ - {10, 200}, {200, 10}, {1, 50}, {50, 1}, {64, 64}, {100, 300}, {300, 100}, - } - for _, r := range ratios { - encoded, err := blurhash.Encode(gradientImage(r.w, r.h)) - Expect(err).ToNot(HaveOccurred()) - Expect(encoded).ToNot(HavePrefix("K"), "real encode at %dx%d produced the synthetic prefix", r.w, r.h) - } - }) - - It("is deterministic for one seed", func() { - Expect(blurhash.Synthetic("alb-1", "")).To(Equal(blurhash.Synthetic("alb-1", ""))) - }) - - It("differs across seeds", func() { - Expect(blurhash.Synthetic("alb-1", "")).ToNot(Equal(blurhash.Synthetic("alb-2", ""))) - }) - - dcOf := func(hash string) (int, int, int) { - dc := decode83(hash[2:6]) - return dc >> 16 & 0xFF, dc >> 8 & 0xFF, dc & 0xFF - } - - It("decodes to a muted DC colour", func() { - r, g, b := dcOf(blurhash.Synthetic("alb-1", "")) - spread := max(r, g, b) - min(r, g, b) - Expect(spread).To(BeNumerically("<", 120), "saturation is capped, so no channel should run away") - Expect(max(r, g, b)).To(BeNumerically("<", 240), "lightness is capped below white") - Expect(min(r, g, b)).To(BeNumerically(">", 10), "lightness is capped above black") - }) - - It("keeps 1,000,000 seeds effectively distinct", func() { - const count = 1_000_000 - seen := make(map[uint64]struct{}, count) - for i := range count { - seen[xxh3.HashString(blurhash.Synthetic(fmt.Sprintf("item-%d", i), ""))] = struct{}{} - } - Expect(len(seen)).To(BeNumerically(">=", 999_900)) - }) - - It("leans the DC towards a red tint", func() { - r, g, b := dcOf(blurhash.Synthetic("alb-1", "#c04040")) - Expect(r).To(BeNumerically(">", g)) - Expect(r).To(BeNumerically(">", b)) - }) - - It("leans the DC towards a blue tint", func() { - r, g, b := dcOf(blurhash.Synthetic("alb-1", "#4040c0")) - Expect(b).To(BeNumerically(">", r)) - Expect(b).To(BeNumerically(">", g)) - }) - - It("changes the value when only the tint changes", func() { - Expect(blurhash.Synthetic("alb-1", "#c04040")).ToNot(Equal(blurhash.Synthetic("alb-1", "#4040c0"))) - }) - - It("falls back to the seed hue for an unparseable tint", func() { - Expect(blurhash.Synthetic("alb-1", "not-a-colour")).To(Equal(blurhash.Synthetic("alb-1", ""))) - }) - - It("tracks a dark tint's lightness", func() { - dark, _, _ := dcOf(blurhash.Synthetic("alb-1", "#101820")) - light, _, _ := dcOf(blurhash.Synthetic("alb-1", "#e8f0f8")) - Expect(dark).To(BeNumerically("<", light)) - }) - - // A shared tint must not become a shared value: the seed alone carries uniqueness. - It("keeps 1,000,000 seeds distinct under a single fixed tint", func() { - const count = 1_000_000 - seen := make(map[uint64]struct{}, count) - for i := range count { - seen[xxh3.HashString(blurhash.Synthetic(fmt.Sprintf("track-%d", i), "#3a5f7d"))] = struct{}{} - } - Expect(len(seen)).To(BeNumerically(">=", 999_900)) - }) -}) diff --git a/server/jellyfin/README.md b/server/jellyfin/README.md index b07aaee8d..13ec79aa8 100644 --- a/server/jellyfin/README.md +++ b/server/jellyfin/README.md @@ -180,14 +180,6 @@ warmer uses — so user-scoped items like private playlists still resolve their falling back to the placeholder. Album, artist, media-file and playlist ids are all resolved to their Navidrome `ArtworkID`. -Blurhashes come in three tiers. A real blurhash is computed once in -`core/artwork` from the decoded image and stored per artwork row; the mappers read it whenever it -exists. While artwork is still unresolved, `dto/mappers.go` instead emits a synthetic 3x3 blurhash -(`core/artwork/blurhash.Synthetic`), seeded on the image tag so it's effectively unique per image -and can never collide with a real hash's leading byte, giving clients a valid cache key while art -loads. A track awaiting embedded-art extraction has its synthetic hash tinted from the parent -album's dominant colour. Known-absent artwork emits no tag and no blurhash at all. - ## Finamp saved-queue id truncation Real Jellyfin item ids are GUIDs — 128-bit values, always 32 hex characters. Finamp relies on that @@ -338,6 +330,16 @@ make test PKG=./server/jellyfin/... 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. +- **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 diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go index d2bdb1b2e..31cdba1dd 100644 --- a/server/jellyfin/dto/mappers.go +++ b/server/jellyfin/dto/mappers.go @@ -6,7 +6,6 @@ import ( "time" "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core/artwork/blurhash" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/slice" ) @@ -213,8 +212,6 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto { // Nothing enqueues media files: advertising the id is what makes a client ask, and that // request is what extracts the embedded art and queues the track. item.ImageTags = map[string]string{"Primary": mf.ID} - hash := blurhash.Synthetic(mf.ID, mf.AlbumImage.DominantColor) - item.ImageBlurHashes = map[string]map[string]string{"Primary": {mf.ID: hash}} } else if mf.AlbumID != "" { if tag, blurs, ratio := primaryImage(mf.AlbumImage, mf.AlbumID, fields); tag != "" { item.AlbumPrimaryImageTag = tag @@ -230,19 +227,15 @@ func embeddedArtPending(mf model.MediaFile) bool { mf.ImageHash == "" && !mf.ItemImage.ImageAbsent } -// primaryImage synthesizes a blurhash when none was computed yet: clients key their cover -// cache on the value, so it must be unique per image, never shared or reused. +// primaryImage never fakes a blurhash: clients key their cover cache on the value, which would +// pin a stale cover forever. func primaryImage(img model.ItemImage, fallback string, fields Fields) (tag string, blurs map[string]map[string]string, ratio *float64) { if img.ImageAbsent { return "", nil, nil } tag = cmp.Or(img.ImageHash, fallback) - if tag != "" { - hash := img.BlurHash - if hash == "" { - hash = blurhash.Synthetic(tag, "") - } - blurs = map[string]map[string]string{"Primary": {tag: hash}} + if img.BlurHash != "" { + blurs = map[string]map[string]string{"Primary": {tag: img.BlurHash}} } if fields.Has("PrimaryImageAspectRatio") { ratio = img.AspectRatio() diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go index 451e1e08b..77b78e55f 100644 --- a/server/jellyfin/dto/mappers_test.go +++ b/server/jellyfin/dto/mappers_test.go @@ -6,7 +6,6 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core/artwork/blurhash" "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -38,7 +37,7 @@ var _ = Describe("mappers", func() { Expect(item.UserData.Key).To(Equal(EncodeID("song-1"))) Expect(item.UserData.ItemId).To(Equal(EncodeID("song-1"))) Expect(item.AlbumPrimaryImageTag).To(Equal("alb-1")) - Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("alb-1", blurhash.Synthetic("alb-1", ""))) + Expect(item.ImageBlurHashes).To(BeNil()) Expect(item.Genres).To(Equal([]string{"genre 1", "genre 2"})) Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID("1"), Name: "genre 1"}, {Id: EncodeID("2"), Name: "genre 2"}})) }) @@ -299,7 +298,7 @@ var _ = Describe("mappers", func() { Expect(*item.ProductionYear).To(Equal(1999)) Expect(*item.ChildCount).To(Equal(10)) Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "alb-1")) - Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("alb-1", blurhash.Synthetic("alb-1", ""))) + Expect(item.ImageBlurHashes).To(BeNil()) Expect(item.Genres).To(Equal([]string{"genre 1", "genre 2"})) Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID("1"), Name: "genre 1"}, {Id: EncodeID("2"), Name: "genre 2"}})) }) @@ -473,7 +472,7 @@ var _ = Describe("mappers", func() { Expect(item.UserData.PlayCount).To(Equal(2)) Expect(*item.UserData.Rating).To(Equal(8.0)) Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "pl-1")) - Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("pl-1", blurhash.Synthetic("pl-1", ""))) + Expect(item.ImageBlurHashes).To(BeNil()) }) It("changes the playlist image tag when the cover content changes", func() { @@ -508,37 +507,13 @@ var _ = Describe("mappers", func() { It("advertises the track id so the client triggers the read-through", func() { mf := model.MediaFile{ID: "mf-1", AlbumID: "alb-1", HasCoverArt: true} mf.AlbumImage.ImageHash = "0123456789abcdef" - mf.AlbumImage.DominantColor = "#3a5f7d" item := SongToBaseItem(mf, nil) Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "mf-1")) - Expect(item.ImageBlurHashes["Primary"]).To( - HaveKeyWithValue("mf-1", blurhash.Synthetic("mf-1", "#3a5f7d"))) + Expect(item.ImageBlurHashes).To(BeNil(), "no resolved image means no blurhash to send") Expect(item.AlbumPrimaryImageTag).To(BeEmpty()) }) - // The client's image request is what extracts the embedded art. Reusing the album's - // value would let it answer from cache and never send it. - It("gives the track a value distinct from its album's", func() { - mf := model.MediaFile{ID: "mf-3", AlbumID: "alb-1", HasCoverArt: true} - mf.AlbumImage.ImageHash = "0123456789abcdef" - mf.AlbumImage.BlurHash = "LEHV6nWB2yk8" - mf.AlbumImage.DominantColor = "#3a5f7d" - - track := SongToBaseItem(mf, nil).ImageBlurHashes["Primary"]["mf-3"] - album := AlbumToBaseItem(model.Album{ID: "alb-1", ItemImage: mf.AlbumImage}, nil). - ImageBlurHashes["Primary"]["0123456789abcdef"] - Expect(track).ToNot(BeEmpty()) - Expect(track).ToNot(Equal(album)) - }) - - It("still emits a value when the album has no dominant colour", func() { - mf := model.MediaFile{ID: "mf-4", AlbumID: "alb-1", HasCoverArt: true} - - item := SongToBaseItem(mf, nil) - Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("mf-4", blurhash.Synthetic("mf-4", ""))) - }) - It("falls back to the album when the track has no art of its own", func() { mf := model.MediaFile{ID: "mf-2", AlbumID: "alb-1", HasCoverArt: false} mf.AlbumImage.ImageHash = "0123456789abcdef" @@ -580,14 +555,13 @@ var _ = Describe("mappers", func() { Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("0123456789abcdef", "LEHV6nWB2yk8")) }) - It("synthesizes a blurhash when none was computed", func() { + It("omits the blurhash entirely when none was computed", func() { al := model.Album{ID: "alb-2", Name: "Album"} al.ImageHash = "0123456789abcdef" item := AlbumToBaseItem(al, nil) Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "0123456789abcdef")) - Expect(item.ImageBlurHashes["Primary"]).To( - HaveKeyWithValue("0123456789abcdef", blurhash.Synthetic("0123456789abcdef", ""))) + Expect(item.ImageBlurHashes).To(BeNil(), "a synthesized blurhash pins stale covers in Finamp") }) It("omits tags for known-absent artwork", func() { @@ -599,19 +573,10 @@ var _ = Describe("mappers", func() { Expect(item.ImageBlurHashes).To(BeNil()) }) - It("keeps known-absent artwork free of any synthesized value", func() { - al := model.Album{ID: "alb-5", Name: "Album"} - al.ImageAbsent = true - - item := AlbumToBaseItem(al, nil) - Expect(item.ImageBlurHashes).To(BeNil(), - "there is no image to key a cache on, and nothing will ever re-key it") - }) - It("falls back to the entity id while artwork is still unresolved", func() { item := AlbumToBaseItem(model.Album{ID: "alb-4", Name: "Album"}, nil) Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "alb-4")) - Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("alb-4", blurhash.Synthetic("alb-4", ""))) + Expect(item.ImageBlurHashes).To(BeNil()) }) It("versions an artist's tag by content hash", func() { @@ -636,14 +601,13 @@ var _ = Describe("mappers", func() { Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("0123456789abcdef", "LEHV6nWB2yk8")) }) - It("synthesizes a song's album blurhash when the album has none", func() { + It("never synthesizes a song blurhash when the album has none", func() { mf := model.MediaFile{ID: "song-2", Title: "Song", AlbumID: "alb-2"} mf.AlbumImage.ImageHash = "0123456789abcdef" item := SongToBaseItem(mf, nil) Expect(item.AlbumPrimaryImageTag).To(Equal("0123456789abcdef")) - Expect(item.ImageBlurHashes["Primary"]).To( - HaveKeyWithValue("0123456789abcdef", blurhash.Synthetic("0123456789abcdef", ""))) + Expect(item.ImageBlurHashes).To(BeNil()) }) It("omits a song's album tag when the album art is known absent", func() { From 752b38609c1599e15dc4fb3eef55fbb6f340f516 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Wed, 12 Aug 2026 11:59:56 -0400 Subject: [PATCH 68/71] feat(artist): add Share and Download actions to the Artist detail page (#5944) * feat(ui): add Share button to artist detail page * feat(ui): add Download button to artist detail page * fix(ui): scope artist share/download to album-artist content Gate the artist Share/Download actions on album-artist stats and show the album-artist size, since ZipArtist and the share query only cover album_artist_id songs. Previously the total (role-inclusive) size was shown and guest-only artists could produce an empty archive. Applies to the artist toolbar, the shared context menu, and the download dialog title. * fix: match artist download/share to album-artist participation ZipArtist and the artist share query filtered the deprecated album_artist_id column, which only stores the first album artist of a track. Secondary album-artists (co-credited but not first) got an empty download/share even though the UI offered it. Filter by the album-artist role participation instead, matching the artist's album-artist stats used to gate the actions. Also cover the artist-specific size branch of the download dialog. * fix(share): scope artist shares to the owner's libraries The artist share query broadened to album-artist participation, which could pull a secondary album artist's tracks from libraries the (non-admin) share owner cannot access into the public share. Load the artist share as the owner so their library access is applied, mirroring how playlist shares already work. Adds a repository test covering co-album-artist inclusion and library scoping. * test(share): assert album participation branch of artist shares Link the co-album-artist fixtures to albums and assert share.Albums (used by Subsonic getShares) includes the accessible album and excludes the one in a library the owner cannot access, so the album participation + scoping branch is covered too. * fix: exclude missing files from artist download/share actions An artist's stats still count files that went missing, so the toolbar/context menu could offer Download/Share for an artist whose files are all gone, while the share query (missing=false) returns nothing and downloads open dead paths. Hide the actions when the artist is missing and exclude missing files from ZipArtist, matching the share semantics. * refactor: dedupe artist download-size and share-owner lookups Extract the 'album-artist download size (or none when missing)' rule into a single artistDownloadSize() helper shared by the toolbar, context menu, and download dialog, and factor the duplicated share-owner context lookup into a shareRepository.ownerContext() method used by both the artist and playlist share cases. * refactor(ui): move artistDownloadSize helper to common utils is for domain-agnostic, potentially portable code; this helper is Navidrome-specific (artist stats shape), so it belongs in common. Consumers import it directly from common/artist to avoid pulling in the common barrel. --- core/archiver.go | 9 ++- core/archiver_test.go | 8 ++- persistence/share_repository.go | 36 +++++++--- persistence/share_repository_test.go | 83 ++++++++++++++++++++++ ui/src/artist/ArtistActions.jsx | 41 +++++++++++ ui/src/artist/ArtistActions.test.jsx | 65 ++++++++++++++++- ui/src/common/ContextMenus.jsx | 10 ++- ui/src/common/ContextMenus.test.jsx | 78 ++++++++++++++++++++ ui/src/common/artist.js | 6 ++ ui/src/common/artist.test.js | 24 +++++++ ui/src/common/index.js | 1 + ui/src/dialogs/DownloadMenuDialog.jsx | 11 ++- ui/src/dialogs/DownloadMenuDialog.test.jsx | 58 +++++++++++++++ 13 files changed, 411 insertions(+), 19 deletions(-) create mode 100644 ui/src/common/ContextMenus.test.jsx create mode 100644 ui/src/common/artist.js create mode 100644 ui/src/common/artist.test.js create mode 100644 ui/src/dialogs/DownloadMenuDialog.test.jsx diff --git a/core/archiver.go b/core/archiver.go index 8c42f8f49..c9436279e 100644 --- a/core/archiver.go +++ b/core/archiver.go @@ -14,6 +14,7 @@ import ( "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/persistence" "github.com/navidrome/navidrome/utils/slice" "github.com/navidrome/navidrome/utils/str" ) @@ -40,7 +41,13 @@ func (a *archiver) ZipAlbum(ctx context.Context, id string, format string, bitra } func (a *archiver) ZipArtist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error { - return a.zipAlbums(ctx, id, format, bitrate, out, squirrel.Eq{"album_artist_id": id}) + // Match by album-artist participation, not the deprecated album_artist_id + // column (first album artist only), so co-album-artists are included too. + filter := squirrel.And{ + persistence.ParticipantIDFilter("media_file", id, model.RoleAlbumArtist), + squirrel.Eq{"missing": false}, + } + return a.zipAlbums(ctx, id, format, bitrate, out, filter) } func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitrate int, out io.Writer, filters squirrel.Sqlizer) error { diff --git a/core/archiver_test.go b/core/archiver_test.go index 2ba8f1fc0..461af1800 100644 --- a/core/archiver_test.go +++ b/core/archiver_test.go @@ -11,6 +11,7 @@ import ( "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/persistence" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/stretchr/testify/mock" @@ -69,8 +70,11 @@ var _ = Describe("Archiver", func() { mfRepo := &mockMediaFileRepository{} mfRepo.On("GetAll", []model.QueryOptions{{ - Filters: squirrel.Eq{"album_artist_id": "1"}, - Sort: "album", + Filters: squirrel.And{ + persistence.ParticipantIDFilter("media_file", "1", model.RoleAlbumArtist), + squirrel.Eq{"missing": false}, + }, + Sort: "album", }}).Return(mfs, nil) ds.On("MediaFile", mock.Anything).Return(mfRepo) diff --git a/persistence/share_repository.go b/persistence/share_repository.go index 0013e782b..4b6dc9240 100644 --- a/persistence/share_repository.go +++ b/persistence/share_repository.go @@ -82,13 +82,20 @@ func (r *shareRepository) loadMedia(share *model.Share) error { } switch share.ResourceType { case "artist": - albumRepo := NewAlbumRepository(r.ctx, r.db) - share.Albums, err = albumRepo.GetAll(model.QueryOptions{Filters: noMissing(Eq{"album_artist_id": ids}), Sort: "artist"}) + // Match by album-artist participation, not the deprecated album_artist_id + // column (first album artist only), so co-album-artists are included too. + // Load as the share owner so their library access is applied. + ctx, err := r.ownerContext(share) if err != nil { return err } - mfRepo := NewMediaFileRepository(r.ctx, r.db) - share.Tracks, err = mfRepo.GetAll(model.QueryOptions{Filters: noMissing(Eq{"album_artist_id": ids}), Sort: "artist"}) + albumRepo := NewAlbumRepository(ctx, r.db) + share.Albums, err = albumRepo.GetAll(model.QueryOptions{Filters: noMissing(ParticipantIDFilter("album", ids, model.RoleAlbumArtist)), Sort: "artist"}) + if err != nil { + return err + } + mfRepo := NewMediaFileRepository(ctx, r.db) + share.Tracks, err = mfRepo.GetAll(model.QueryOptions{Filters: noMissing(ParticipantIDFilter("media_file", ids, model.RoleAlbumArtist)), Sort: "artist"}) return err case "album": albumRepo := NewAlbumRepository(r.ctx, r.db) @@ -101,14 +108,10 @@ func (r *shareRepository) loadMedia(share *model.Share) error { return err case "playlist": // Load tracks as the share owner so their library access is applied. - owner, err := NewUserRepository(r.ctx, r.db).Get(share.UserID) + ctx, err := r.ownerContext(share) if err != nil { - return fmt.Errorf("loading share owner %q: %w", share.UserID, err) + return err } - if owner == nil { - return fmt.Errorf("share owner %q not found", share.UserID) - } - ctx := request.WithUser(r.ctx, *owner) plsRepo := NewPlaylistRepository(ctx, r.db) // Tracks returns nil when the playlist is no longer visible to the owner // (e.g. it was made private after the share was created); leave the share @@ -133,6 +136,19 @@ func (r *shareRepository) loadMedia(share *model.Share) error { return nil } +// ownerContext returns a context scoped to the share owner, so repository +// queries apply the owner's library access when a public share is rendered. +func (r *shareRepository) ownerContext(share *model.Share) (context.Context, error) { + owner, err := NewUserRepository(r.ctx, r.db).Get(share.UserID) + if err != nil { + return nil, fmt.Errorf("loading share owner %q: %w", share.UserID, err) + } + if owner == nil { + return nil, fmt.Errorf("share owner %q not found", share.UserID) + } + return request.WithUser(r.ctx, *owner), nil +} + func sortByIdPosition(mfs model.MediaFiles, ids []string) model.MediaFiles { m := map[string]int{} for i, mf := range mfs { diff --git a/persistence/share_repository_test.go b/persistence/share_repository_test.go index 3ae456031..3af91b2af 100644 --- a/persistence/share_repository_test.go +++ b/persistence/share_repository_test.go @@ -228,6 +228,89 @@ var _ = Describe("ShareRepository", func() { }) }) + Describe("Artist share library scoping", func() { + var otherLib model.Library + var owner model.User + const primaryID = "share-aa-primary" + const secondaryID = "share-aa-secondary" + + BeforeEach(func() { + adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) + b := GetDBXBuilder() + + // A second library the owner has no access to + lr := NewLibraryRepository(adminCtx, b) + otherLib = model.Library{ID: 0, Name: "Artist Share Other Library", Path: "/share/artist/other"} + Expect(lr.Put(&otherLib)).To(Succeed()) + + ar := NewArtistRepository(adminCtx, b) + Expect(createArtistWithLibrary(ar, &model.Artist{ID: primaryID, Name: "AA Primary", OrderArtistName: "aa primary"}, 1)).To(Succeed()) + Expect(createArtistWithLibrary(ar, &model.Artist{ID: secondaryID, Name: "AA Secondary", OrderArtistName: "aa secondary"}, 1)).To(Succeed()) + + // Secondary is a co-album-artist (not the first): album_artist_id points at + // primary, so the legacy-column filter would miss both tracks. + aaParticipants := model.Participants{model.RoleAlbumArtist: { + {Artist: model.Artist{ID: primaryID, Name: "AA Primary"}}, + {Artist: model.Artist{ID: secondaryID, Name: "AA Secondary"}}, + }} + alr := NewAlbumRepository(adminCtx, b) + Expect(alr.Put(&model.Album{ID: "art-album-ok", LibraryID: 1, Name: "Art Album OK", AlbumArtistID: primaryID, AlbumArtist: "AA Primary", Participants: aaParticipants})).To(Succeed()) + Expect(alr.Put(&model.Album{ID: "art-album-other", LibraryID: otherLib.ID, Name: "Art Album Other", AlbumArtistID: primaryID, AlbumArtist: "AA Primary", Participants: aaParticipants})).To(Succeed()) + + mr := NewMediaFileRepository(adminCtx, b) + Expect(mr.Put(&model.MediaFile{ID: "art-ok", LibraryID: 1, AlbumID: "art-album-ok", Path: "a/ok.mp3", Title: "ArtOK", AlbumArtistID: primaryID, Participants: aaParticipants})).To(Succeed()) + Expect(mr.Put(&model.MediaFile{ID: "art-other", LibraryID: otherLib.ID, AlbumID: "art-album-other", Path: "a/other.mp3", Title: "ArtOther", AlbumArtistID: primaryID, Participants: aaParticipants})).To(Succeed()) + + // Non-admin owner with access to library 1 only + owner = createUserWithLibraries("artist-share-owner", []int{1}) + ur := NewUserRepository(adminCtx, b) + Expect(ur.Put(&owner)).To(Succeed()) + Expect(ur.SetUserLibraries(owner.ID, []int{1})).To(Succeed()) + + _, err := b.NewQuery(` + INSERT INTO share (id, user_id, description, resource_type, resource_ids, created_at, updated_at) + VALUES ({:id}, {:user}, {:desc}, {:type}, {:ids}, {:created}, {:updated}) + `).Bind(map[string]any{ + "id": "art-share", "user": owner.ID, "desc": "Artist scope share", + "type": "artist", "ids": secondaryID, "created": time.Now(), "updated": time.Now(), + }).Execute() + Expect(err).ToNot(HaveOccurred()) + }) + + AfterEach(func() { + adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) + b := GetDBXBuilder() + _, _ = b.NewQuery(`DELETE FROM share WHERE id = 'art-share'`).Execute() + mr := NewMediaFileRepository(adminCtx, b).(*mediaFileRepository) + _, _ = mr.executeSQL(squirrel.Delete("media_file").Where(squirrel.Eq{"id": []string{"art-ok", "art-other"}})) + alr := NewAlbumRepository(adminCtx, b).(*albumRepository) + _, _ = alr.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": []string{"art-album-ok", "art-album-other"}})) + ar := NewArtistRepository(adminCtx, b).(*artistRepository) + _, _ = ar.executeSQL(squirrel.Delete("artist").Where(squirrel.Eq{"id": []string{primaryID, secondaryID}})) + lr := NewLibraryRepository(adminCtx, b).(*libraryRepository) + _ = lr.delete(squirrel.Eq{"id": otherLib.ID}) + _ = NewUserRepository(adminCtx, b).Delete(owner.ID) + }) + + It("includes co-album-artist tracks the owner can access and excludes those they cannot", func() { + // Read as admin (mimics the public-share render path); loadMedia must still + // scope to the owner's libraries. + adminRepo := NewShareRepository(request.WithUser(log.NewContext(GinkgoT().Context()), adminUser), GetDBXBuilder()) + share, err := adminRepo.Get("art-share") + Expect(err).ToNot(HaveOccurred()) + + Expect(share.Tracks).To(ContainElement(HaveField("ID", "art-ok")), + "a co-album-artist track (not matched by album_artist_id) must be included") + Expect(share.Tracks).ToNot(ContainElement(HaveField("ID", "art-other")), + "a track outside the owner's libraries must not appear in the share") + + Expect(share.Albums).To(ContainElement(HaveField("ID", "art-album-ok")), + "a co-album-artist album must be included") + Expect(share.Albums).ToNot(ContainElement(HaveField("ID", "art-album-other")), + "an album outside the owner's libraries must not appear in the share") + }) + }) + Describe("Ownership Checks", func() { var ownerUser = model.User{ID: "2222", UserName: "regular-user"} var otherUser = model.User{ID: "3333", UserName: "third-user"} diff --git a/ui/src/artist/ArtistActions.jsx b/ui/src/artist/ArtistActions.jsx index 0b48f232d..2f329a034 100644 --- a/ui/src/artist/ArtistActions.jsx +++ b/ui/src/artist/ArtistActions.jsx @@ -13,9 +13,19 @@ import { } from 'react-admin' import ShuffleIcon from '@material-ui/icons/Shuffle' import PlayArrowIcon from '@material-ui/icons/PlayArrow' +import ShareIcon from '@material-ui/icons/Share' +import CloudDownloadOutlinedIcon from '@material-ui/icons/CloudDownloadOutlined' import { IoIosRadio } from 'react-icons/io' import { playShuffle, playTopSongs } from './actions.js' import { playSimilar } from '../common/playbackActions.js' +import { + openShareMenu, + openDownloadMenu, + DOWNLOAD_MENU_ARTIST, +} from '../actions' +import config from '../config' +import { formatBytes } from '../utils' +import { artistDownloadSize } from '../common/artist' const useStyles = makeStyles((theme) => ({ toolbar: { @@ -62,6 +72,9 @@ const ArtistActions = ({ className, record, ...rest }) => { const [loadingAction, setLoadingAction] = React.useState(null) const isLoading = !!loadingAction + const albumArtistSize = artistDownloadSize(record) + const hasAlbumArtistContent = Boolean(albumArtistSize) + const handlePlay = React.useCallback(async () => { setLoadingAction('play') try { @@ -101,6 +114,14 @@ const ArtistActions = ({ className, record, ...rest }) => { } }, [dispatch, notify, record]) + const handleShare = React.useCallback(() => { + dispatch(openShareMenu([record.id], 'artist', record.name)) + }, [dispatch, record]) + + const handleDownload = React.useCallback(() => { + dispatch(openDownloadMenu(record, DOWNLOAD_MENU_ARTIST)) + }, [dispatch, record]) + return ( <TopToolbar className={`${className} ${classes.toolbar}`} @@ -133,6 +154,26 @@ const ArtistActions = ({ className, record, ...rest }) => { loading={loadingAction === 'radio'} icon={<IoIosRadio className={classes.radioIcon} />} /> + {config.enableSharing && hasAlbumArtistContent && ( + <LoadingButton + onClick={handleShare} + label={translate('ra.action.share')} + className={classes.button} + size={isMobile ? 'small' : 'medium'} + icon={<ShareIcon />} + /> + )} + {config.enableDownloads && hasAlbumArtistContent && ( + <LoadingButton + onClick={handleDownload} + label={`${translate('ra.action.download')} (${formatBytes( + albumArtistSize, + )})`} + className={classes.button} + size={isMobile ? 'small' : 'medium'} + icon={<CloudDownloadOutlinedIcon />} + /> + )} </TopToolbar> ) } diff --git a/ui/src/artist/ArtistActions.test.jsx b/ui/src/artist/ArtistActions.test.jsx index a11ee50e3..ad25177b3 100644 --- a/ui/src/artist/ArtistActions.test.jsx +++ b/ui/src/artist/ArtistActions.test.jsx @@ -4,6 +4,11 @@ import { TestContext } from 'ra-test' import { describe, it, expect, vi, beforeEach } from 'vitest' import ArtistActions from './ArtistActions' import subsonic from '../subsonic' +import { + openShareMenu, + openDownloadMenu, + DOWNLOAD_MENU_ARTIST, +} from '../actions' import { ThemeProvider, createTheme } from '@material-ui/core/styles' const mockDispatch = vi.fn() @@ -13,6 +18,11 @@ vi.mock('../subsonic', () => ({ default: { getSimilarSongs2: vi.fn(), getTopSongs: vi.fn() }, })) +const { mockConfig } = vi.hoisted(() => ({ + mockConfig: { enableSharing: true, enableDownloads: true }, +})) +vi.mock('../config', () => ({ default: mockConfig })) + const mockNotify = vi.fn() const mockGetList = vi.fn().mockResolvedValue({ data: [{ id: 's1' }] }) @@ -27,7 +37,11 @@ vi.mock('react-admin', async (importOriginal) => { }) describe('ArtistActions', () => { - const defaultRecord = { id: 'ar1', name: 'Artist' } + const defaultRecord = { + id: 'ar1', + name: 'Artist', + stats: { albumartist: { songCount: 3, albumCount: 1, size: 1024 } }, + } const renderArtistActions = (record = defaultRecord) => { const theme = createTheme() @@ -48,6 +62,8 @@ describe('ArtistActions', () => { vi.clearAllMocks() // Mock console.error to suppress error logging in tests vi.spyOn(console, 'error').mockImplementation(() => {}) + mockConfig.enableSharing = true + mockConfig.enableDownloads = true const songWithReplayGain = { id: 'rec1', @@ -227,4 +243,51 @@ describe('ArtistActions', () => { expect(mockDispatch).not.toHaveBeenCalled() }) }) + + describe('Share action', () => { + it('shows the share button and dispatches openShareMenu when clicked', () => { + renderArtistActions() + fireEvent.click(screen.getByText('ra.action.share')) + expect(mockDispatch).toHaveBeenCalledWith( + openShareMenu(['ar1'], 'artist', 'Artist'), + ) + }) + + it('hides the share button when sharing is disabled', () => { + mockConfig.enableSharing = false + renderArtistActions() + expect(screen.queryByText('ra.action.share')).not.toBeInTheDocument() + }) + }) + + describe('Download action', () => { + it('shows the download button with album-artist size and dispatches openDownloadMenu when clicked', () => { + renderArtistActions() + expect(screen.getByText('ra.action.download (1 KB)')).toBeInTheDocument() + fireEvent.click(screen.getByText(/ra\.action\.download/)) + expect(mockDispatch).toHaveBeenCalledWith( + openDownloadMenu(defaultRecord, DOWNLOAD_MENU_ARTIST), + ) + }) + + it('hides the download button when downloads are disabled', () => { + mockConfig.enableDownloads = false + renderArtistActions() + expect(screen.queryByText(/ra\.action\.download/)).not.toBeInTheDocument() + }) + }) + + describe('Album-artist gating', () => { + it('hides Share and Download for artists with no album-artist content', () => { + renderArtistActions({ id: 'ar1', name: 'Artist', stats: {} }) + expect(screen.queryByText('ra.action.share')).not.toBeInTheDocument() + expect(screen.queryByText(/ra\.action\.download/)).not.toBeInTheDocument() + }) + + it('hides Share and Download for a missing artist', () => { + renderArtistActions({ ...defaultRecord, missing: true }) + expect(screen.queryByText('ra.action.share')).not.toBeInTheDocument() + expect(screen.queryByText(/ra\.action\.download/)).not.toBeInTheDocument() + }) + }) }) diff --git a/ui/src/common/ContextMenus.jsx b/ui/src/common/ContextMenus.jsx index 47c9c6786..7ad8c735c 100644 --- a/ui/src/common/ContextMenus.jsx +++ b/ui/src/common/ContextMenus.jsx @@ -24,6 +24,7 @@ import { import { LoveButton } from './LoveButton' import config from '../config' import { formatBytes } from '../utils' +import { artistDownloadSize } from './artist' const useStyles = makeStyles({ noWrap: { @@ -70,6 +71,9 @@ const ContextMenu = ({ const notify = useNotify() const [anchorEl, setAnchorEl] = useState(null) + const isArtist = resource === 'artist' + const downloadSize = isArtist ? artistDownloadSize(record) : record?.size + const options = { play: { enabled: true, @@ -103,7 +107,7 @@ const ContextMenu = ({ }, ...(!hideShare && { share: { - enabled: config.enableSharing, + enabled: config.enableSharing && (!isArtist || downloadSize), needData: false, label: translate('ra.action.share'), action: (record) => @@ -111,9 +115,9 @@ const ContextMenu = ({ }, }), download: { - enabled: config.enableDownloads && record.size, + enabled: config.enableDownloads && downloadSize, needData: false, - label: `${translate('ra.action.download')} (${formatBytes(record.size)})`, + label: `${translate('ra.action.download')} (${formatBytes(downloadSize)})`, action: () => { dispatch( openDownloadMenu( diff --git a/ui/src/common/ContextMenus.test.jsx b/ui/src/common/ContextMenus.test.jsx new file mode 100644 index 000000000..72a98b64b --- /dev/null +++ b/ui/src/common/ContextMenus.test.jsx @@ -0,0 +1,78 @@ +import React from 'react' +import { render, fireEvent, screen } from '@testing-library/react' +import { TestContext } from 'ra-test' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { ThemeProvider, createTheme } from '@material-ui/core/styles' +import { AlbumContextMenu, ArtistContextMenu } from './ContextMenus' + +const mockDispatch = vi.fn() +vi.mock('react-redux', () => ({ useDispatch: () => mockDispatch })) + +const { mockConfig } = vi.hoisted(() => ({ + mockConfig: { + enableSharing: true, + enableDownloads: true, + enableFavourites: false, + }, +})) +vi.mock('../config', () => ({ default: mockConfig })) + +vi.mock('react-admin', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useNotify: () => vi.fn(), + useDataProvider: () => ({ getList: vi.fn() }), + useTranslate: () => (x) => x, + } +}) + +describe('ContextMenus', () => { + const renderMenu = (Menu, record) => { + render( + <TestContext> + <ThemeProvider theme={createTheme()}> + <Menu record={record} /> + </ThemeProvider> + </TestContext>, + ) + fireEvent.click(screen.getByLabelText('more')) + } + + beforeEach(() => { + vi.clearAllMocks() + mockConfig.enableSharing = true + mockConfig.enableDownloads = true + }) + + describe('ArtistContextMenu', () => { + const withAlbumArtist = { + id: 'ar1', + name: 'Artist', + stats: { albumartist: { songCount: 3, albumCount: 1, size: 1024 } }, + } + + it('shows the album-artist size on the download item', () => { + renderMenu(ArtistContextMenu, withAlbumArtist) + expect(screen.getByText('ra.action.download (1 KB)')).toBeInTheDocument() + }) + + it('hides download and share for artists with no album-artist content', () => { + renderMenu(ArtistContextMenu, { id: 'ar1', name: 'Artist', stats: {} }) + expect(screen.queryByText(/ra\.action\.download/)).not.toBeInTheDocument() + expect(screen.queryByText('ra.action.share')).not.toBeInTheDocument() + }) + }) + + describe('AlbumContextMenu', () => { + it('uses the total size on the album download item', () => { + renderMenu(AlbumContextMenu, { + id: 'al1', + name: 'Album', + duration: 100, + size: 1024 * 1024, + }) + expect(screen.getByText('ra.action.download (1 MB)')).toBeInTheDocument() + }) + }) +}) diff --git a/ui/src/common/artist.js b/ui/src/common/artist.js new file mode 100644 index 000000000..370700160 --- /dev/null +++ b/ui/src/common/artist.js @@ -0,0 +1,6 @@ +// Size of an artist's downloadable album-artist content, or undefined when there +// is nothing to download (a missing artist, or no album-artist songs). Download +// and Share only cover album-artist songs, so callers gate on this, not the +// role-inclusive total. +export const artistDownloadSize = (record) => + record?.missing ? undefined : record?.stats?.albumartist?.size diff --git a/ui/src/common/artist.test.js b/ui/src/common/artist.test.js new file mode 100644 index 000000000..eff9935a6 --- /dev/null +++ b/ui/src/common/artist.test.js @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest' +import { artistDownloadSize } from './artist' + +describe('artistDownloadSize', () => { + it('returns the album-artist size', () => { + expect( + artistDownloadSize({ stats: { albumartist: { size: 1024 } } }), + ).toEqual(1024) + }) + + it('returns undefined for a missing artist', () => { + expect( + artistDownloadSize({ + missing: true, + stats: { albumartist: { size: 1024 } }, + }), + ).toBeUndefined() + }) + + it('returns undefined when there is no album-artist content', () => { + expect(artistDownloadSize({ stats: {} })).toBeUndefined() + expect(artistDownloadSize(undefined)).toBeUndefined() + }) +}) diff --git a/ui/src/common/index.js b/ui/src/common/index.js index 7b5b376f3..047f5b3cf 100644 --- a/ui/src/common/index.js +++ b/ui/src/common/index.js @@ -1,4 +1,5 @@ export * from './AddToPlaylistButton' +export * from './artist' export * from './ArtistLinkField' export * from './BatchPlayButton' export * from './BitrateField' diff --git a/ui/src/dialogs/DownloadMenuDialog.jsx b/ui/src/dialogs/DownloadMenuDialog.jsx index 2104cbcad..61e84a08a 100644 --- a/ui/src/dialogs/DownloadMenuDialog.jsx +++ b/ui/src/dialogs/DownloadMenuDialog.jsx @@ -8,8 +8,9 @@ import { DialogTitle, } from '@material-ui/core' import subsonic from '../subsonic' -import { closeDownloadMenu } from '../actions' +import { closeDownloadMenu, DOWNLOAD_MENU_ARTIST } from '../actions' import { formatBytes } from '../utils' +import { artistDownloadSize } from '../common/artist' import { useTranscodingOptions } from './useTranscodingOptions' const DownloadMenuDialog = () => { @@ -22,6 +23,12 @@ const DownloadMenuDialog = () => { const { TranscodingOptionsInput, format, maxBitRate, originalFormat } = useTranscodingOptions() + // Artist downloads only include album-artist songs, so show that size + const downloadSize = + recordType === DOWNLOAD_MENU_ARTIST + ? artistDownloadSize(record) + : record?.size + const handleClose = (e) => { dispatch(closeDownloadMenu()) e.stopPropagation() @@ -55,7 +62,7 @@ const DownloadMenuDialog = () => { smart_count: 1, }).toLocaleLowerCase(), name: record?.name || record?.title, - size: formatBytes(record?.size), + size: formatBytes(downloadSize), })} </DialogTitle> <DialogContent> diff --git a/ui/src/dialogs/DownloadMenuDialog.test.jsx b/ui/src/dialogs/DownloadMenuDialog.test.jsx new file mode 100644 index 000000000..b9ce7f077 --- /dev/null +++ b/ui/src/dialogs/DownloadMenuDialog.test.jsx @@ -0,0 +1,58 @@ +import * as React from 'react' +import { TestContext } from 'ra-test' +import { render, screen, cleanup } from '@testing-library/react' +import { describe, afterEach, it, expect, vi } from 'vitest' +import DownloadMenuDialog from './DownloadMenuDialog' +import { DOWNLOAD_MENU_ALBUM, DOWNLOAD_MENU_ARTIST } from '../actions' + +vi.mock('./useTranscodingOptions', () => ({ + useTranscodingOptions: () => ({ + TranscodingOptionsInput: () => null, + format: '', + maxBitRate: 0, + originalFormat: true, + }), +})) + +vi.mock('react-admin', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useTranslate: () => (key, opts) => + opts?.size ? `${key}:${opts.name}:${opts.size}` : key, + } +}) + +const renderDialog = (record, recordType) => + render( + <TestContext + initialState={{ downloadMenuDialog: { open: true, record, recordType } }} + > + <DownloadMenuDialog /> + </TestContext>, + ) + +describe('DownloadMenuDialog', () => { + afterEach(cleanup) + + it('shows the album-artist size (not the total) for an artist download', () => { + renderDialog( + { + id: 'ar1', + name: 'Artist', + size: 999999999, + stats: { albumartist: { size: 1024 } }, + }, + DOWNLOAD_MENU_ARTIST, + ) + expect(screen.getByText(/:Artist:1 KB$/)).toBeInTheDocument() + }) + + it('shows the total size for an album download', () => { + renderDialog( + { id: 'al1', name: 'Album', size: 1024 * 1024 }, + DOWNLOAD_MENU_ALBUM, + ) + expect(screen.getByText(/:Album:1 MB$/)).toBeInTheDocument() + }) +}) From 5a4a3099f1a65e31c9c1201674067987ae9fe990 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Wed, 12 Aug 2026 14:55:04 -0400 Subject: [PATCH 69/71] fix(cli): accept absolute paths in selective scan --target (#5947) * fix(scanner): accept absolute paths in selective scan --target The scanner's fs.FS only accepts paths relative to the library root, so an absolute --target path (e.g. 2:/jukebox/collection) failed with an opaque "invalid argument" error. Rebase absolute targets onto the library root before scanning; relative paths are unchanged. Fixes #5943 * refactor(scanner): simplify libraryRelativePath with IsLocal and slice.ToMap * fix(scanner): make libraryRelativePath cross-platform Windows CI failed: the tests hardcoded Unix-style absolute paths, which are not absolute on Windows, and filepath.Rel yields backslash-separated paths that the io/fs-based scanner FS rejects. Build the test paths with filepath.Abs so they are absolute on every OS, and normalize the rebased result with filepath.ToSlash. * fix(scanner): resolve relative library root before rebasing target filepath.Rel cannot rebase an absolute target onto a relative library root (e.g. the default MusicFolder=./music), so an absolute --target was left unchanged and rejected by the rooted io/fs. Make the library root absolute first; it resolves against the same cwd as the scanner's fs. --- model/scanner.go | 2 +- scanner/scanner.go | 28 ++++++++++++++++++++++- scanner/scanner_internal_test.go | 39 ++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/model/scanner.go b/model/scanner.go index 54f81037c..36c9007fb 100644 --- a/model/scanner.go +++ b/model/scanner.go @@ -12,7 +12,7 @@ import ( // NOTE: This struct is used as a map key, so it should only contain comparable types. type ScanTarget struct { LibraryID int - FolderPath string // Relative path within the library, or "" for entire library + FolderPath string // Path within the library (relative or absolute), or "" for entire library } func (st ScanTarget) String() string { diff --git a/scanner/scanner.go b/scanner/scanner.go index 29a1f9b4e..d73007bdd 100644 --- a/scanner/scanner.go +++ b/scanner/scanner.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "maps" + "path/filepath" "slices" "sync/atomic" "time" @@ -51,6 +52,27 @@ func (s *scanState) sendError(err error) { s.sendProgress(&ProgressInfo{Error: err.Error()}) } +// libraryRelativePath rebases an absolute scan target path onto the library root, since the +// scanner's fs.FS only accepts paths relative to it. Relative paths, and absolute paths outside +// the library root, are returned unchanged. +func libraryRelativePath(libPath, folderPath string) string { + if !filepath.IsAbs(folderPath) { + return folderPath + } + // The library root may be relative (e.g. the default "./music"); it must be made absolute + // to match against an absolute target, and it resolves against the same cwd as the scanner's fs. + absLib, err := filepath.Abs(libPath) + if err != nil { + return folderPath + } + rel, err := filepath.Rel(absLib, folderPath) + if err != nil || !filepath.IsLocal(rel) { + return folderPath + } + // The scanner's fs.FS is an io/fs, which always uses forward slashes. + return filepath.ToSlash(rel) +} + func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets []model.ScanTarget, progress chan<- *ProgressInfo) { startTime := time.Now() @@ -77,8 +99,12 @@ func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets [] // Selective scan: filter libraries and build targets map state.targets = make(map[int][]string) + libPaths := slice.ToMap(allLibs, func(lib model.Library) (int, string) { + return lib.ID, lib.Path + }) + for _, target := range targets { - folderPath := target.FolderPath + folderPath := libraryRelativePath(libPaths[target.LibraryID], target.FolderPath) if folderPath == "" { folderPath = "." } diff --git a/scanner/scanner_internal_test.go b/scanner/scanner_internal_test.go index e8abb7c7d..0778bd6ec 100644 --- a/scanner/scanner_internal_test.go +++ b/scanner/scanner_internal_test.go @@ -4,6 +4,8 @@ package scanner import ( "context" "errors" + "os" + "path/filepath" "sync/atomic" ppl "github.com/google/go-pipeline/pkg/pipeline" @@ -11,6 +13,43 @@ import ( . "github.com/onsi/gomega" ) +var _ = Describe("libraryRelativePath", func() { + // Paths are built with filepath so the "absolute" cases stay absolute on every OS + // (a Unix-style "/foo" is not absolute on Windows). + libRoot, _ := filepath.Abs(filepath.Join("jukebox", "collection")) + outside, _ := filepath.Abs(filepath.Join("somewhere", "else")) + + It("returns a relative path unchanged", func() { + Expect(libraryRelativePath(libRoot, "_Collection")).To(Equal("_Collection")) + }) + + It("rebases an absolute target when the library root is relative", func() { + cwd, err := os.Getwd() + Expect(err).ToNot(HaveOccurred()) + Expect(libraryRelativePath(filepath.Join("music", "library"), filepath.Join(cwd, "music", "library", "rock"))).To(Equal("rock")) + }) + + It("rebases an absolute path that equals the library root to '.'", func() { + Expect(libraryRelativePath(libRoot, libRoot)).To(Equal(".")) + }) + + It("rebases an absolute path under the library root", func() { + Expect(libraryRelativePath(libRoot, filepath.Join(libRoot, "_Collection"))).To(Equal("_Collection")) + }) + + It("handles a trailing slash on the library path", func() { + Expect(libraryRelativePath(libRoot+string(filepath.Separator), filepath.Join(libRoot, "_Collection"))).To(Equal("_Collection")) + }) + + It("leaves an absolute path outside the library root unchanged", func() { + Expect(libraryRelativePath(libRoot, outside)).To(Equal(outside)) + }) + + It("returns an empty path unchanged", func() { + Expect(libraryRelativePath(libRoot, "")).To(Equal("")) + }) +}) + type mockPhase struct { num int produceFunc func() ppl.Producer[int] From c66ef04dd3111712a44d60d5895d8b77216baba7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Wed, 12 Aug 2026 19:02:34 -0400 Subject: [PATCH 70/71] refactor(jellyfin): emit real 128-bit GUIDs as item ids (#5942) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(jellyfin): use canonical ids in dto fixtures Fixtures used short placeholder strings, which are not valid Navidrome ids. Deriving them from id.NewHash keeps the labels readable while exercising the real id shape. * test(jellyfin): use canonical ids in handler fixtures Fixtures used short placeholder strings, which are not valid Navidrome ids. Deriving them from id.NewHash keeps the labels readable while exercising the real id shape. Playlist entry positions stay decimal, matching the integer playlist_tracks.id column. * test(jellyfin): use canonical ids in e2e fixtures Fixtures used short placeholder strings, which are not valid Navidrome ids. Deriving them from id.NewHash keeps the labels readable while exercising the real id shape. * test(jellyfin): use canonical ids in audiomuse fixtures audiomuse_test.go passes ids as bare function args (mf(id, ...), call(query, user)) rather than via ID: struct-literal fields, so the original grep-built file list missed it. Same conversion as the rest of the fixtures: fake labels through id.NewHash via testID. * test(jellyfin): convert remaining nonexistent-id sentinels in e2e tests Reviewer swept for enc("literal") sites the brief's dto.EncodeID grep missed. These "does not exist" fixtures must stay well-formed GUIDs under the strict codec, or the test degrades from "resolves to nothing" to "empty path segment". * refactor(jellyfin): emit real 128-bit GUIDs as item ids Navidrome ids are now a canonical 22-char base62 encoding of exactly 128 bits, so they map losslessly onto Jellyfin GUIDs. Previously the API hex-encoded the id string itself, producing 44 hex chars where Jellyfin uses 32. Integer library ids, the synthetic playlists folder, and playlist entry positions (a playlist_tracks.id, an integer column) aren't 128-bit values, so they get a reserved GUID space tagged by kind. DecodeID is now strict: malformed input returns an empty string instead of passing through unchanged. BREAKING: Jellyfin clients see entirely new item ids. * fix(jellyfin): 404 malformed playlist ids instead of silently creating updatePlaylist decoded a malformed playlistId to "", the same sentinel core/playlists.Create uses to mean "make a new playlist" — the overload createPlaylist deliberately relies on. A malformed id now 404s before reaching Create. Also tightens id-codec test fixtures: several tests set chi params to a raw canonical id, which now decodes to "" and only passed because the fakes ignore the id argument; and a batch of not-found sentinels now use well-formed-but-nonexistent GUIDs so they exercise the intended path instead of the malformed-id path. READMEs "lossless" claim softened to note the reserved space. * refactor(jellyfin): drop the id truncation workaround Finamp's saved-queue packing keeps the first 16 bytes of each item id. That was lossy only because our ids were 44 hex chars; now they are 32, so the packing round-trips exactly and the server-side prefix recovery is dead code. Removes an indexed range scan per restored queue and the ambiguous-prefix path that could resolve to the wrong item. * fix(jellyfin): emit ServerId and PlaySessionId in Jellyfin's id format Jellyfin serializes GUIDs without dashes; ServerId was emitting the dashed UUID form. A ServerId persisted before this change is normalized on read rather than rewritten. PlaySessionId was emitting a raw internal id instead of the encoded form. BREAKING: the ServerId change makes clients treat the server as new, so users re-login once. * fix(jellyfin): 404 on undecodable id filters instead of widening the query DecodeID collapsed an absent param and an undecodable one into the empty string, and downstream an empty id means no filter. A client sending a stale pre-upgrade id therefore had its filter silently dropped: ParentId, ArtistIds and AlbumArtistIds each returned the whole library instead of a scoped result. Every existing client hits this on first launch after the id format changes. Scalar id params now distinguish the two cases and report not-found. List-valued params already failed closed. EncodeID logs a diagnostic when a non-empty id is not canonical, which should not happen post-migration and would otherwise ship an unaddressable item silently. * test(jellyfin): drop comments that restate the spec names * refactor(jellyfin): decode reserved GUIDs from bytes, not hex strings DecodeID already had the 16 decoded bytes, then re-derived the kind tag and payload by slicing the hex string and parsing it a second time. Reading them off the byte slice matches how the format is specified and removes the duplicate parse. Bounding the payload inside encodeReserved gives both encoders the 32-char guarantee, which only EncodePlaylistEntryID enforced before. Playlist entries now decode through DecodePlaylistEntryID, which rejects other kinds. The tag was being encoded and then discarded, so a song id passed as an EntryId reached RemoveTracks as a playlist_tracks position. Drops the per-field log.Warn from EncodeID: it sat in a leaf codec without a ctx and would emit once per item per request on exactly the bad-data population it was meant to surface. * refactor(jellyfin): make DecodeID report whether the id was decodable DecodeID returned the empty string for both an absent param and an undecodable one, and downstream an empty id means no filter. That conflation is what let a stale id widen /Items to the whole library; it had been patched at two call sites, leaving three different policies for an undecodable id in one package and ~16 handlers correct only because a repo Get("") happens to fail. Returning (string, bool) makes the ambiguity unrepresentable, and the compiler forces each of the ~22 sites to decide. URL params share one itemIDParam helper that 404s; id lists go through DecodeIDs, which is all-or-nothing because dropping bad entries would empty a list and make its len() > 0 filter gate vanish — the original bug by another route. A well-formed but unknown id is still 200 with zero results; only malformed ids 404. Malformed ids now also 404 on the image and similar/instant-mix routes, which previously answered with a placeholder or an empty list. --- server/jellyfin/README.md | 44 ++- server/jellyfin/annotations.go | 16 +- server/jellyfin/annotations_test.go | 146 +++++----- server/jellyfin/api_test.go | 4 +- server/jellyfin/audiomuse.go | 20 +- server/jellyfin/audiomuse_test.go | 70 +++-- server/jellyfin/auth_test.go | 6 +- server/jellyfin/browsing.go | 25 +- server/jellyfin/browsing_test.go | 49 +++- server/jellyfin/dto/dto_suite_test.go | 5 + server/jellyfin/dto/ids.go | 146 +++++++++- server/jellyfin/dto/ids_test.go | 162 +++++++++-- server/jellyfin/dto/mappers.go | 5 +- server/jellyfin/dto/mappers_test.go | 204 ++++++------- server/jellyfin/e2e/annotations_test.go | 4 +- server/jellyfin/e2e/audiomuse_test.go | 4 +- server/jellyfin/e2e/auth_test.go | 8 +- server/jellyfin/e2e/browsing_test.go | 132 ++++++--- server/jellyfin/e2e/e2e_suite_test.go | 13 +- server/jellyfin/e2e/lyrics_test.go | 2 +- server/jellyfin/e2e/multiuser_test.go | 2 +- server/jellyfin/e2e/playlists_test.go | 54 +++- server/jellyfin/e2e/search_test.go | 3 +- server/jellyfin/e2e/similar_test.go | 59 ++-- server/jellyfin/e2e/streaming_test.go | 4 +- server/jellyfin/images.go | 21 +- server/jellyfin/images_test.go | 124 ++++---- server/jellyfin/items.go | 104 ++++--- server/jellyfin/items_test.go | 366 +++++++++++++++--------- server/jellyfin/jellyfin_suite_test.go | 5 + server/jellyfin/library.go | 34 ++- server/jellyfin/lyrics_test.go | 38 +-- server/jellyfin/middlewares_test.go | 6 +- server/jellyfin/playlists.go | 68 +++-- server/jellyfin/playlists_test.go | 171 +++++------ server/jellyfin/sessions.go | 9 +- server/jellyfin/sessions_test.go | 34 ++- server/jellyfin/similar.go | 16 +- server/jellyfin/similar_test.go | 32 +-- server/jellyfin/socket_test.go | 4 +- server/jellyfin/stream.go | 7 +- server/jellyfin/stream_test.go | 100 +++---- server/jellyfin/system.go | 17 +- server/jellyfin/system_test.go | 20 +- server/jellyfin/truncated_ids.go | 113 -------- server/jellyfin/users_test.go | 14 +- 46 files changed, 1549 insertions(+), 941 deletions(-) delete mode 100644 server/jellyfin/truncated_ids.go diff --git a/server/jellyfin/README.md b/server/jellyfin/README.md index 13ec79aa8..195173c2a 100644 --- a/server/jellyfin/README.md +++ b/server/jellyfin/README.md @@ -85,15 +85,6 @@ player id is the device id from `X-Emby-Authorization` (`DeviceId="..."`); the p 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 base62 ids (e.g. `5QFKvMsJrd57QE2Le2dKKo`). Because a base62 id can -itself be 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 @@ -180,26 +171,25 @@ warmer uses — so user-scoped items like private playlists still resolve their falling back to the placeholder. Album, artist, media-file and playlist ids are all resolved to their Navidrome `ArtworkID`. -## Finamp saved-queue id truncation +## Item ids are GUIDs -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 22-character base62 strings, not 32-hex 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. +Jellyfin item ids are GUIDs, serialized as 32 lowercase hex chars with no dashes +(`Guid.ToString("N")`). Navidrome ids are canonical 22-char base62 encodings of a 128-bit value, +so `dto.EncodeID`/`dto.DecodeID` map between the two via `model/id` — losslessly except for the +~2⁻⁹⁶ chance an id's 128-bit value falls in the reserved space below (leading 12 bytes all zero). -This API compensates server-side (`truncated_ids.go`): a 16-character id — a length no Navidrome -id 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. +Three emitted ids aren't 128-bit values: integer library ids, the synthetic playlists folder, and +`PlaylistItemId` (a playlist *entry position* — `playlist_tracks.id` is an `integer` column). +They use a reserved GUID space — 12 zero bytes, a non-zero kind tag, a 24-bit payload — so +library `1` is `00000000000000000000000001000001`. The tag is never zero, because Jellyfin +serializes the all-zero GUID as `null`. -**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. +`DecodeID` accepts dashed and uppercase GUIDs (Jellyfin's `Guid.Parse` does) and returns +`ok=false` for anything malformed — including "" — which handlers surface as a 404. + +The wire format must stay GUID-shaped for this reason: Finamp's saved-queue persistence bit-packs +each item id into exactly 16 bytes (`packIds()` in `lib/models/finamp_models.dart`), so a 32-hex +GUID round-trips exactly, whereas a longer id would be silently truncated. ## Streaming and transcoding @@ -242,7 +232,7 @@ plugin being loaded, like the Subsonic `sonicSimilarity` OpenSubsonic extension. loaded; otherwise `{"path": [{author, item_id, title, tempo?}], "total_distance": <float>}` (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. +`item_id`/`start_song_id`/`end_song_id` are the GUID-form 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 diff --git a/server/jellyfin/annotations.go b/server/jellyfin/annotations.go index f2c2f546e..ec84f65e0 100644 --- a/server/jellyfin/annotations.go +++ b/server/jellyfin/annotations.go @@ -5,7 +5,6 @@ import ( "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/events" @@ -49,7 +48,10 @@ func (api *Router) resolveAnnotated(w http.ResponseWriter, r *http.Request, id s // 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"))) + id, ok := itemIDParam(w, r, "itemId") + if !ok { + return + } item, ok := api.resolveItemByID(r.Context(), id, nil) if !ok { http.Error(w, "Not Found", http.StatusNotFound) @@ -64,7 +66,10 @@ func (api *Router) getUserItemData(w http.ResponseWriter, r *http.Request) { } func (api *Router) setFavorite(w http.ResponseWriter, r *http.Request, starred bool) { - id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId"))) + id, ok := itemIDParam(w, r, "itemId") + if !ok { + return + } repo, resource := api.resolveAnnotated(w, r, id) if repo == nil { return @@ -84,7 +89,10 @@ func (api *Router) unmarkFavorite(w http.ResponseWriter, r *http.Request) { } func (api *Router) setItemRating(w http.ResponseWriter, r *http.Request, rating int) { - id := api.resolveItemID(r.Context(), dto.DecodeID(chi.URLParam(r, "itemId"))) + id, ok := itemIDParam(w, r, "itemId") + if !ok { + return + } repo, resource := api.resolveAnnotated(w, r, id) if repo == nil { return diff --git a/server/jellyfin/annotations_test.go b/server/jellyfin/annotations_test.go index 9e811a379..dd1487011 100644 --- a/server/jellyfin/annotations_test.go +++ b/server/jellyfin/annotations_test.go @@ -21,7 +21,7 @@ var _ = Describe("Annotations", func() { var broker *fakeEventBroker // 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"}}}) + return request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice", Libraries: model.Libraries{{ID: 1, Name: "Music"}}}) } BeforeEach(func() { @@ -33,98 +33,98 @@ var _ = Describe("Annotations", func() { 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}}) + mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}}) w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/s1", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", "s1") + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("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()) + Expect(mfRepo.Data[testID("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}}) + albumRepo.SetData(model.Albums{{ID: testID("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")) + r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID(testID("a1")), nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("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()) + Expect(albumRepo.Data[testID("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"}}) + artistRepo.SetData(model.Artists{{ID: testID("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") + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("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()) + Expect(artistRepo.Data[testID("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"}}) + playlistRepo.SetData(model.Playlists{{ID: testID("p1"), Name: "Mix", OwnerID: testID("u1")}}) w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID("p1"), nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID("p1")) + r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID(testID("p1")), nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("p1"))) invoke(api.markFavorite, w, r) Expect(w.Code).To(Equal(http.StatusOK)) - Expect(playlistRepo.Starred["p1"]).To(BeTrue()) + Expect(playlistRepo.Starred[testID("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}}}) + mfRepo.SetData(model.MediaFiles{{ID: testID("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") + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("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()) + Expect(mfRepo.Data[testID("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}}) + albumRepo.SetData(model.Albums{{ID: testID("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")) + r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID(testID("a1")), nil).WithContext(ctxUser()) // only has access to library 1 + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1"))) invoke(api.markFavorite, w, r) Expect(w.Code).To(Equal(http.StatusNotFound)) - Expect(albumRepo.Data["a1"].Starred).To(BeFalse()) + Expect(albumRepo.Data[testID("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}}) + mfRepo.SetData(model.MediaFiles{{ID: testID("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") + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) invoke(api.markFavorite, w, r) Expect(w.Code).To(Equal(http.StatusNotFound)) - Expect(mfRepo.Data["s1"].Starred).To(BeFalse()) + Expect(mfRepo.Data[testID("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") + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("missing"))) invoke(api.markFavorite, w, r) Expect(w.Code).To(Equal(http.StatusNotFound)) }) @@ -133,39 +133,39 @@ var _ = Describe("Annotations", 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") + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("x1"))) invoke(api.markFavorite, w, r) Expect(w.Code).To(Equal(http.StatusInternalServerError)) }) It("emits a refreshResource event when starring a song", func() { mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}}) + mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}}) w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/s1", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", "s1") + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) invoke(api.markFavorite, w, r) Expect(broker.Events).To(HaveLen(1)) - Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"song":["s1"]}`)) + Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"song":["` + testID("s1") + `"]}`)) }) It("emits a refreshResource event when starring an album", func() { albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}}) + albumRepo.SetData(model.Albums{{ID: testID("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")) + r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID(testID("a1")), nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1"))) invoke(api.markFavorite, w, r) Expect(broker.Events).To(HaveLen(1)) - Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"album":["a1"]}`)) + Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"album":["` + testID("a1") + `"]}`)) }) It("does not emit an event when the item is not accessible", func() { albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 2}}) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 2}}) w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID("a1"), nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID("a1")) + r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID(testID("a1")), nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1"))) invoke(api.markFavorite, w, r) Expect(w.Code).To(Equal(http.StatusNotFound)) Expect(broker.Events).To(BeEmpty()) @@ -175,13 +175,13 @@ var _ = Describe("Annotations", func() { 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}}) + mfRepo.SetData(model.MediaFiles{{ID: testID("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") + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) invoke(api.setRating, w, r) Expect(w.Code).To(Equal(http.StatusOK)) - Expect(mfRepo.Data["s1"].Rating).To(Equal(4)) + Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4)) var d dto.UserItemDataDto Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed()) Expect(d.Rating).NotTo(BeNil()) @@ -190,35 +190,35 @@ var _ = Describe("Annotations", func() { It("rates an album", func() { albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: "a1", Name: "One", LibraryID: 1}}) + albumRepo.SetData(model.Albums{{ID: testID("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")) + r := httptest.NewRequest("POST", "/Users/u1/Items/"+dto.EncodeID(testID("a1"))+"/Rating?Rating=10", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1"))) invoke(api.setRating, w, r) Expect(w.Code).To(Equal(http.StatusOK)) - Expect(albumRepo.Data["a1"].Rating).To(Equal(5)) + Expect(albumRepo.Data[testID("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"}}) + playlistRepo.SetData(model.Playlists{{ID: testID("p1"), Name: "Mix", OwnerID: testID("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")) + r := httptest.NewRequest("POST", "/Users/u1/Items/"+dto.EncodeID(testID("p1"))+"/Rating?Rating=8", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("p1"))) invoke(api.setRating, w, r) Expect(w.Code).To(Equal(http.StatusOK)) - Expect(playlistRepo.Ratings["p1"]).To(Equal(4)) + Expect(playlistRepo.Ratings[testID("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}}}) + mfRepo.SetData(model.MediaFiles{{ID: testID("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") + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) invoke(api.removeRating, w, r) Expect(w.Code).To(Equal(http.StatusOK)) - Expect(mfRepo.Data["s1"].Rating).To(Equal(0)) + Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(0)) var d dto.UserItemDataDto Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed()) Expect(d.Rating).To(BeNil()) @@ -226,79 +226,79 @@ var _ = Describe("Annotations", func() { 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}}) + albumRepo.SetData(model.Albums{{ID: testID("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")) + r := httptest.NewRequest("POST", "/Users/u1/Items/"+dto.EncodeID(testID("a1"))+"/Rating?Rating=10", nil).WithContext(ctxUser()) // only has access to library 1 + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1"))) invoke(api.setRating, w, r) Expect(w.Code).To(Equal(http.StatusNotFound)) - Expect(albumRepo.Data["a1"].Rating).To(Equal(0)) + Expect(albumRepo.Data[testID("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}}) + mfRepo.SetData(model.MediaFiles{{ID: testID("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") + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) invoke(api.setRating, w, r) Expect(w.Code).To(Equal(http.StatusOK)) - Expect(mfRepo.Data["s1"].Rating).To(Equal(5)) + Expect(mfRepo.Data[testID("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}}}) + mfRepo.SetData(model.MediaFiles{{ID: testID("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") + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) invoke(api.setRating, w, r) Expect(w.Code).To(Equal(http.StatusOK)) - Expect(mfRepo.Data["s1"].Rating).To(Equal(1)) + Expect(mfRepo.Data[testID("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}}) + mfRepo.SetData(model.MediaFiles{{ID: testID("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") + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) invoke(api.setRating, w, r) Expect(w.Code).To(Equal(http.StatusOK)) - Expect(mfRepo.Data["s1"].Rating).To(Equal(4)) + Expect(mfRepo.Data[testID("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}}) + mfRepo.SetData(model.MediaFiles{{ID: testID("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") + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) invoke(api.setRating, w, r) Expect(w.Code).To(Equal(http.StatusOK)) - Expect(mfRepo.Data["s1"].Rating).To(Equal(5)) + Expect(mfRepo.Data[testID("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}}) + mfRepo.SetData(model.MediaFiles{{ID: testID("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") + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) invoke(api.setRating, w, r) Expect(w.Code).To(Equal(http.StatusOK)) - Expect(mfRepo.Data["s1"].Rating).To(Equal(0)) + Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(0)) }) It("emits a refreshResource event when rating a song", func() { mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song", LibraryID: 1}}) + mfRepo.SetData(model.MediaFiles{{ID: testID("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") + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) invoke(api.setRating, w, r) Expect(broker.Events).To(HaveLen(1)) - Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"song":["s1"]}`)) + Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"song":["` + testID("s1") + `"]}`)) }) }) }) diff --git a/server/jellyfin/api_test.go b/server/jellyfin/api_test.go index e8e9cbd4b..fe69c8321 100644 --- a/server/jellyfin/api_test.go +++ b/server/jellyfin/api_test.go @@ -48,8 +48,8 @@ var _ = Describe("Router", 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(ur.Put(&model.User{ID: testID("u1"), UserName: "alice", NewPassword: "secret"})).To(Succeed()) + token, err := auth.CreateToken(&model.User{ID: testID("u1"), UserName: "alice"}) Expect(err).ToNot(HaveOccurred()) fp := &fakePlayers{} diff --git a/server/jellyfin/audiomuse.go b/server/jellyfin/audiomuse.go index b01a4bf92..4cc1a2cc5 100644 --- a/server/jellyfin/audiomuse.go +++ b/server/jellyfin/audiomuse.go @@ -62,12 +62,13 @@ func (api *Router) audioMuseSimilarTracks(w http.ResponseWriter, r *http.Request tracks := []audioMuseSimilarTrack{} itemID := p.StringOr("item_id", "") - if itemID == "" { + id, ok := dto.DecodeID(itemID) + if !ok { + // Every other failure here (no provider, no match) degrades to an empty list rather than an + // error, so a malformed id does too instead of being the one path that 404s. 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) @@ -129,11 +130,16 @@ func (api *Router) audioMuseFindPath(w http.ResponseWriter, r *http.Request) { } resp := audioMusePathResponse{Path: []audioMusePathTrack{}} + startDecoded, startOk := dto.DecodeID(startID) + endDecoded, endOk := dto.DecodeID(endID) + if !startOk || !endOk { + // This endpoint already 400s when an endpoint id is absent, so an unusable one reports the + // same way rather than looking like a successful search that found no path. + http.Error(w, "start_song_id and end_song_id must be valid item ids.", http.StatusBadRequest) + return + } 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) + matches, err := api.sonic.FindSonicPath(ctx, startDecoded, endDecoded, maxSteps) if err != nil { api.ok(w, r, resp) return diff --git a/server/jellyfin/audiomuse_test.go b/server/jellyfin/audiomuse_test.go index e9d6d4e85..6815753fd 100644 --- a/server/jellyfin/audiomuse_test.go +++ b/server/jellyfin/audiomuse_test.go @@ -118,28 +118,28 @@ var _ = Describe("AudioMuse similar_tracks", func() { 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}, + {MediaFile: mf(testID("mf1"), "A", "T1", 1), Similarity: 0.3}, + {MediaFile: mf(testID("mf2"), "B", "T2", 1), Similarity: 0.5}, } - w := call("item_id="+dto.EncodeID("seed")+"&n=5", model.User{IsAdmin: true}) + w := call("item_id="+dto.EncodeID(testID("seed"))+"&n=5", model.User{IsAdmin: true}) Expect(w.Code).To(Equal(200)) - Expect(fake.gotID).To(Equal("seed")) + Expect(fake.gotID).To(Equal(testID("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", + Author: "A", Distance: 0.3, ItemID: dto.EncodeID(testID("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}, + {MediaFile: mf(testID("mf1"), "A", "T1", 1), Similarity: 0.3}, + {MediaFile: mf(testID("mf2"), "A", "T2", 1), Similarity: 0.5}, } - w := call("item_id="+dto.EncodeID("seed"), model.User{IsAdmin: true}) + w := call("item_id="+dto.EncodeID(testID("seed")), model.User{IsAdmin: true}) var body []audioMuseSimilarTrack Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed()) Expect(body).To(HaveLen(1)) @@ -147,18 +147,18 @@ var _ = Describe("AudioMuse similar_tracks", func() { 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}, + {MediaFile: mf(testID("mf1"), "A", "T1", 1), Similarity: 0.3}, + {MediaFile: mf(testID("mf2"), "A", "T2", 1), Similarity: 0.5}, } - w := call("item_id="+dto.EncodeID("seed")+"&eliminate_duplicates=false", model.User{IsAdmin: true}) + w := call("item_id="+dto.EncodeID(testID("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}}}) + fake.similar = []sonic.SimilarMatch{{MediaFile: mf(testID("mf1"), "A", "T1", 2), Similarity: 0.3}} + w := call("item_id="+dto.EncodeID(testID("seed")), model.User{Libraries: model.Libraries{{ID: 1}}}) Expect(strings.TrimSpace(w.Body.String())).To(Equal("[]")) }) @@ -169,16 +169,25 @@ var _ = Describe("AudioMuse similar_tracks", func() { Expect(fake.gotID).To(Equal("")) }) + // A malformed item_id degrades the same way as a missing one (200, empty array) rather than + // 404ing: every other failure on this endpoint already degrades gracefully. + It("returns an empty array without calling the engine when item_id is malformed", func() { + w := call("item_id=not-a-valid-id", 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}) + w := call("item_id="+dto.EncodeID(testID("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}) + fake.similar = []sonic.SimilarMatch{{MediaFile: mf(testID("mf1"), "A", "T1", 1), Similarity: 0.3}} + w := call("item_id="+dto.EncodeID(testID("seed")), model.User{IsAdmin: true}) Expect(w.Code).To(Equal(200)) Expect(strings.TrimSpace(w.Body.String())).To(Equal("[]")) }) @@ -198,53 +207,60 @@ var _ = Describe("AudioMuse find_path", func() { }) 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}) + w := call("end_song_id="+dto.EncodeID(testID("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}) + w := call("start_song_id="+dto.EncodeID(testID("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 := mf(testID("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}, + {MediaFile: mf(testID("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}) + w := call("start_song_id="+dto.EncodeID(testID("s"))+"&end_song_id="+dto.EncodeID(testID("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.gotStart).To(Equal(testID("s"))) + Expect(fake.gotEnd).To(Equal(testID("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].ItemID).To(Equal(dto.EncodeID(testID("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}) + w := call("start_song_id="+dto.EncodeID(testID("s"))+"&end_song_id="+dto.EncodeID(testID("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}) + fake.path = []sonic.SimilarMatch{{MediaFile: mf(testID("mf1"), "A", "T1", 1), Similarity: 1.0}} + w := call("start_song_id="+dto.EncodeID(testID("s"))+"&end_song_id="+dto.EncodeID(testID("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)) }) + + It("400s without calling the engine when an id is malformed", func() { + w := call("start_song_id=not-a-valid-id&end_song_id="+dto.EncodeID(testID("e")), model.User{IsAdmin: true}) + Expect(w.Code).To(Equal(http.StatusBadRequest)) + Expect(fake.gotStart).To(Equal("")) + Expect(fake.gotEnd).To(Equal("")) + }) }) diff --git a/server/jellyfin/auth_test.go b/server/jellyfin/auth_test.go index b51420f1a..5a613f828 100644 --- a/server/jellyfin/auth_test.go +++ b/server/jellyfin/auth_test.go @@ -22,7 +22,7 @@ var _ = Describe("AuthenticateByName", 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()) + Expect(ur.Put(&model.User{ID: testID("u1"), UserName: "alice", NewPassword: "secret"})).To(Succeed()) api = &Router{ds: ds} }) @@ -68,7 +68,7 @@ var _ = Describe("AuthenticateByName", func() { 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()) + Expect(ur.Put(&model.User{ID: testID("admin1"), UserName: "root", NewPassword: "secret", IsAdmin: true})).To(Succeed()) w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/Users/AuthenticateByName", @@ -92,7 +92,7 @@ var _ = Describe("AuthenticateByName", func() { 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()) + Expect(ur.Put(&model.User{ID: testID("e"), UserName: "empty", NewPassword: ""})).To(Succeed()) w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/Users/AuthenticateByName", diff --git a/server/jellyfin/browsing.go b/server/jellyfin/browsing.go index faf43d352..9acd8d5a8 100644 --- a/server/jellyfin/browsing.go +++ b/server/jellyfin/browsing.go @@ -28,12 +28,21 @@ func (api *Router) listArtistsByRole(w http.ResponseWriter, r *http.Request, rol opts := model.QueryOptions{Offset: p.IntOr("startindex", 0), Max: p.IntOr("limit", 0)} applySort(&opts, "MusicArtist", p.StringOr("sortby", ""), p.StringOr("sortorder", "")) - scopeIDs, _ := parentIDScope(ctx, r) + scopeIDs, _, ok := parentIDScope(ctx, r) + if !ok { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + genreIds, ok := decodedQueryIDs(r, "genreids") + if !ok { + http.Error(w, "Not Found", http.StatusNotFound) + return + } // 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. q := itemsQuery{ scopeIDs: scopeIDs, - genreIds: decodedQueryIDs(r, "genreids"), + genreIds: genreIds, search: searchTerm(p), fields: dto.ParseFields(p.Strings("fields")...), } @@ -67,7 +76,11 @@ func (api *Router) getGenres(w http.ResponseWriter, r *http.Request) { func (api *Router) getStudios(w http.ResponseWriter, r *http.Request) { ctx := r.Context() p := req.Params(r) - scope, _ := parentIDScope(ctx, r) + scope, _, ok := parentIDScope(ctx, r) + if !ok { + http.Error(w, "Not Found", http.StatusNotFound) + return + } opts := model.QueryOptions{Sort: "tag_value", Filters: libraryScopeFilter(scope)} labels, err := api.ds.Tag(ctx).GetAll(model.TagRecordLabel, opts) if err != nil { @@ -83,7 +96,11 @@ func (api *Router) getStudios(w http.ResponseWriter, r *http.Request) { // library when accessible. Tags/OfficialRatings have no music source, so they are always empty. func (api *Router) getQueryFiltersLegacy(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - scope, _ := parentIDScope(ctx, r) + scope, _, ok := parentIDScope(ctx, r) + if !ok { + http.Error(w, "Not Found", http.StatusNotFound) + return + } genreOpts := model.QueryOptions{Sort: "name", Filters: libraryScopeFilter(scope)} genres, err := api.ds.Genre(ctx).GetAll(genreOpts) if err != nil { diff --git a/server/jellyfin/browsing_test.go b/server/jellyfin/browsing_test.go index e346b56cb..2825c03e8 100644 --- a/server/jellyfin/browsing_test.go +++ b/server/jellyfin/browsing_test.go @@ -18,12 +18,12 @@ 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}) + return request.WithUser(context.Background(), model.User{ID: testID("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}) + return request.WithUser(context.Background(), model.User{ID: testID("admin"), IsAdmin: true, Libraries: nil}) } BeforeEach(func() { @@ -33,7 +33,7 @@ var _ = Describe("Browsing", func() { Describe("getArtists", func() { It("lists artists via /Artists", func() { - ds.Artist(context.Background()).(*tests.MockArtistRepo).SetData(model.Artists{{ID: "ar1", Name: "A"}}) + ds.Artist(context.Background()).(*tests.MockArtistRepo).SetData(model.Artists{{ID: testID("ar1"), Name: "A"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Artists", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) invoke(api.getArtists, w, r) @@ -45,7 +45,7 @@ var _ = Describe("Browsing", func() { }) It("handles /Artists/AlbumArtists the same way", func() { - ds.Artist(context.Background()).(*tests.MockArtistRepo).SetData(model.Artists{{ID: "ar1", Name: "A"}}) + ds.Artist(context.Background()).(*tests.MockArtistRepo).SetData(model.Artists{{ID: testID("ar1"), Name: "A"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Artists/AlbumArtists", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) invoke(api.getArtists, w, r) @@ -57,7 +57,7 @@ var _ = Describe("Browsing", func() { 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"}}) + artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) w := httptest.NewRecorder() libs := model.Libraries{{ID: 1}, {ID: 2}} r := httptest.NewRequest("GET", "/Artists", nil).WithContext(ctxUser(libs)) @@ -71,10 +71,10 @@ var _ = Describe("Browsing", func() { 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"}}) + artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) w := httptest.NewRecorder() libs := model.Libraries{{ID: 1}, {ID: 2}} - r := httptest.NewRequest("GET", "/Artists?ParentId=2", nil).WithContext(ctxUser(libs)) + r := httptest.NewRequest("GET", "/Artists?ParentId="+dto.EncodeLibraryID(2), nil).WithContext(ctxUser(libs)) invoke(api.getArtists, w, r) Expect(w.Code).To(Equal(http.StatusOK)) sql, args, err := artistRepo.Options.Filters.ToSql() @@ -86,10 +86,10 @@ var _ = Describe("Browsing", func() { It("does not let ParentId=<inaccessible library id> narrow the scope", func() { artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) - artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + artistRepo.SetData(model.Artists{{ID: testID("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)) + r := httptest.NewRequest("GET", "/Artists?ParentId="+dto.EncodeLibraryID(99), nil).WithContext(ctxUser(libs)) invoke(api.getArtists, w, r) Expect(w.Code).To(Equal(http.StatusOK)) sql, args, err := artistRepo.Options.Filters.ToSql() @@ -101,7 +101,7 @@ var _ = Describe("Browsing", func() { 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"}}) + artistRepo.SetData(model.Artists{{ID: testID("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) @@ -113,7 +113,7 @@ var _ = Describe("Browsing", func() { 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"}}) + artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Artists?SearchTerm=art", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) @@ -130,7 +130,7 @@ var _ = Describe("Browsing", func() { It("forwards StartIndex/Limit as Offset/Max", func() { artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) - artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + artistRepo.SetData(model.Artists{{ID: testID("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) @@ -141,7 +141,7 @@ var _ = Describe("Browsing", func() { 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"}}) + artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Artists", nil).WithContext(ctxAdmin()) invoke(api.getArtists, w, r) @@ -155,6 +155,15 @@ var _ = Describe("Browsing", func() { Expect(err).NotTo(HaveOccurred()) Expect(sql).NotTo(ContainSubstring("library_artist.library_id")) }) + + It("404s a malformed ParentId instead of listing every library's artists", func() { + artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) + artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Artists?ParentId=not-a-valid-id", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) + invoke(api.getArtists, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) }) Describe("getGenres", func() { @@ -199,6 +208,13 @@ var _ = Describe("Browsing", func() { Expect(w.Code).To(Equal(http.StatusOK)) Expect(tagRepo.Options.Filters).To(BeNil()) }) + + It("404s a malformed ParentId instead of listing every library's studios", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Studios?ParentId=not-a-valid-id", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) + invoke(api.getStudios, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) }) Describe("getQueryFiltersLegacy", func() { @@ -222,5 +238,12 @@ var _ = Describe("Browsing", func() { Expect(w.Code).To(Equal(http.StatusOK)) Expect(genreRepo.Options.Filters).To(BeNil()) }) + + It("404s a malformed ParentId instead of listing every library's filters", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/Filters?ParentId=not-a-valid-id", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) + invoke(api.getQueryFiltersLegacy, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) }) }) diff --git a/server/jellyfin/dto/dto_suite_test.go b/server/jellyfin/dto/dto_suite_test.go index 1d8ec47e4..a4eebb0d3 100644 --- a/server/jellyfin/dto/dto_suite_test.go +++ b/server/jellyfin/dto/dto_suite_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -15,3 +16,7 @@ func TestDto(t *testing.T) { RegisterFailHandler(Fail) RunSpecs(t, "Jellyfin DTO Suite") } + +// testID maps a readable label to a deterministic canonical id, so fixtures exercise the same +// id shape production uses. +func testID(label string) string { return id.NewHash("jellyfin-test", label) } diff --git a/server/jellyfin/dto/ids.go b/server/jellyfin/dto/ids.go index 2ac7e2a79..2c8b5317b 100644 --- a/server/jellyfin/dto/ids.go +++ b/server/jellyfin/dto/ids.go @@ -1,23 +1,143 @@ package dto -import "encoding/hex" +import ( + "bytes" + "encoding/hex" + "strconv" + "strings" -// 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 ids if emitted as-is. -func EncodeID(id string) string { - if id == "" { + "github.com/navidrome/navidrome/model/id" +) + +// guidLen is the length of a Jellyfin GUID on the wire: 16 bytes as lowercase hex, no dashes +// (what Guid.ToString("N") produces). +const guidLen = 32 + +// Reserved GUIDs stand in for ids that aren't 128-bit values: 12 zero bytes, a non-zero kind tag, +// then a 24-bit payload. The tag is never zero because Jellyfin serializes the all-zero GUID as null. +const ( + kindIdx = 12 + maxPayload = 1<<24 - 1 +) + +const ( + kindLibrary byte = iota + 1 + kindPlaylistsFolder + kindPlaylistEntry +) + +var zeroPrefix [kindIdx]byte + +// PlaylistsFolderID is the internal id of the synthetic "playlists library" folder. It can't be +// mistaken for a real id, which is always 22-char base62. +const PlaylistsFolderID = "playlists" + +// PlaylistsFolderGUID is the wire form of PlaylistsFolderID. +var PlaylistsFolderGUID = encodeReserved(kindPlaylistsFolder, 0) + +// EncodeID renders a canonical Navidrome id as a Jellyfin GUID. Anything that isn't one encodes +// to "" rather than to a shape clients can't parse. +func EncodeID(ndID string) string { + b, err := id.Decode(ndID) + if err != nil { return "" } - return hex.EncodeToString([]byte(id)) + return hex.EncodeToString(b) } -// DecodeID reverses EncodeID; non-hex input is returned unchanged, so it's safe on any inbound id. -func DecodeID(id string) string { - if id == "" { +// EncodeLibraryID renders a library's integer id in the reserved GUID space. +func EncodeLibraryID(libID int) string { + return encodeReserved(kindLibrary, libID) +} + +// EncodePlaylistEntryID renders a playlist entry's position (model.PlaylistTrack.ID, an integer +// column) in the reserved GUID space. Clients echo it back to remove one occurrence of a song. +func EncodePlaylistEntryID(entryID string) string { + n, err := strconv.Atoi(entryID) + if err != nil { return "" } - if b, err := hex.DecodeString(id); err == nil && len(b) > 0 { - return string(b) - } - return id + return encodeReserved(kindPlaylistEntry, n) +} + +func encodeReserved(kind byte, payload int) string { + if payload < 0 || payload > maxPayload { + return "" + } + var b [16]byte + b[kindIdx] = kind + b[13], b[14], b[15] = byte(payload>>16), byte(payload>>8), byte(payload) + return hex.EncodeToString(b[:]) +} + +// DecodeID maps an inbound GUID back to the identifier the rest of the API uses: a canonical id, +// a decimal library id, or PlaylistsFolderID. ok is false for anything that isn't a well-formed +// GUID — including the empty string — so an undecodable id can't reach a caller as "no filter". +// Dashed and uppercase forms are accepted, as Jellyfin's Guid.Parse accepts them. Playlist entries +// decode through DecodePlaylistEntryID instead, so a position can't reach a caller expecting an +// entity id. +func DecodeID(guid string) (string, bool) { + b, ok := decodeGUID(guid) + if !ok { + return "", false + } + kind, payload, reserved := reservedFields(b) + if !reserved { + return id.Encode(b), true + } + switch kind { + case kindLibrary: + return strconv.Itoa(payload), true + case kindPlaylistsFolder: + return PlaylistsFolderID, true + } + return "", false +} + +// DecodeIDs decodes a list of GUIDs, all-or-nothing: ok is false if any entry is malformed, so a +// caller can't mistake "every entry failed" for "no filter" (see DecodeID). +func DecodeIDs(guids []string) ([]string, bool) { + out := make([]string, len(guids)) + for i, guid := range guids { + decoded, ok := DecodeID(guid) + if !ok { + return nil, false + } + out[i] = decoded + } + return out, true +} + +// DecodePlaylistEntryID decodes a playlist entry GUID to its position. It rejects every other kind, +// so an entity id can't be taken for a playlist_tracks row. +func DecodePlaylistEntryID(guid string) (string, bool) { + b, ok := decodeGUID(guid) + if !ok { + return "", false + } + if kind, payload, reserved := reservedFields(b); reserved && kind == kindPlaylistEntry { + return strconv.Itoa(payload), true + } + return "", false +} + +func decodeGUID(guid string) ([16]byte, bool) { + guid = strings.ToLower(strings.ReplaceAll(guid, "-", "")) + if len(guid) != guidLen { + return [16]byte{}, false + } + bs, err := hex.DecodeString(guid) + if err != nil { + return [16]byte{}, false + } + return [16]byte(bs), true +} + +// reservedFields reports the kind tag and payload of a reserved GUID; reserved is false for the +// entity GUIDs that make up almost all traffic. +func reservedFields(b [16]byte) (kind byte, payload int, reserved bool) { + if !bytes.Equal(b[:kindIdx], zeroPrefix[:]) { + return 0, 0, false + } + return b[kindIdx], int(b[13])<<16 | int(b[14])<<8 | int(b[15]), true } diff --git a/server/jellyfin/dto/ids_test.go b/server/jellyfin/dto/ids_test.go index d85786d99..c95dbd790 100644 --- a/server/jellyfin/dto/ids_test.go +++ b/server/jellyfin/dto/ids_test.go @@ -1,35 +1,161 @@ package dto import ( + "encoding/hex" + "strconv" + "strings" + + "github.com/navidrome/navidrome/model/id" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) var _ = Describe("id codec", func() { - It("round-trips a base62 id", func() { - id := "5QFKvMsJrd57QE2Le2dKKo" - Expect(DecodeID(EncodeID(id))).To(Equal(id)) + Describe("canonical ids", func() { + It("round-trips a random id", func() { + ndID := id.NewRandom() + decoded, ok := DecodeID(EncodeID(ndID)) + Expect(ok).To(BeTrue()) + Expect(decoded).To(Equal(ndID)) + }) + + It("round-trips a hash id", func() { + ndID := id.NewHash("artist", "Weird Al") + decoded, ok := DecodeID(EncodeID(ndID)) + Expect(ok).To(BeTrue()) + Expect(decoded).To(Equal(ndID)) + }) + + It("emits exactly 32 lowercase hex chars", func() { + for range 20 { + Expect(EncodeID(id.NewRandom())).To(MatchRegexp("^[0-9a-f]{32}$")) + } + }) + + It("accepts a dashed GUID, matching Jellyfin's Guid.Parse", func() { + ndID := id.NewRandom() + guid := EncodeID(ndID) + dashed := guid[0:8] + "-" + guid[8:12] + "-" + guid[12:16] + "-" + guid[16:20] + "-" + guid[20:32] + decoded, ok := DecodeID(dashed) + Expect(ok).To(BeTrue()) + Expect(decoded).To(Equal(ndID)) + }) + + It("accepts an uppercase GUID", func() { + ndID := id.NewRandom() + decoded, ok := DecodeID(strings.ToUpper(EncodeID(ndID))) + Expect(ok).To(BeTrue()) + Expect(decoded).To(Equal(ndID)) + }) }) - It("passes a raw (non-hex) id through DecodeID unchanged", func() { - Expect(DecodeID("5QFKvMsJrd57QE2Le2dKKo")).To(Equal("5QFKvMsJrd57QE2Le2dKKo")) + Describe("reserved space", func() { + DescribeTable("round-trips library ids", + func(libID int, guid string) { + Expect(EncodeLibraryID(libID)).To(Equal(guid)) + decoded, ok := DecodeID(guid) + Expect(ok).To(BeTrue()) + Expect(decoded).To(Equal(strconv.Itoa(libID))) + }, + Entry("first library", 1, "00000000000000000000000001000001"), + Entry("double digit", 42, "0000000000000000000000000100002a"), + Entry("max payload", 0xffffff, "00000000000000000000000001ffffff"), + ) + + It("round-trips the playlists folder", func() { + Expect(PlaylistsFolderGUID).To(Equal("00000000000000000000000002000000")) + decoded, ok := DecodeID(PlaylistsFolderGUID) + Expect(ok).To(BeTrue()) + Expect(decoded).To(Equal(PlaylistsFolderID)) + }) + + DescribeTable("round-trips playlist entry positions", + func(entryID, guid string) { + Expect(EncodePlaylistEntryID(entryID)).To(Equal(guid)) + decoded, ok := DecodePlaylistEntryID(guid) + Expect(ok).To(BeTrue()) + Expect(decoded).To(Equal(entryID)) + }, + Entry("first entry", "1", "00000000000000000000000003000001"), + Entry("later entry", "300", "0000000000000000000000000300012c"), + ) + + It("rejects a non-integer playlist entry id", func() { + Expect(EncodePlaylistEntryID("s1")).To(Equal("")) + Expect(EncodePlaylistEntryID("")).To(Equal("")) + Expect(EncodePlaylistEntryID("-1")).To(Equal("")) + }) + + It("rejects a payload wider than the reserved 24 bits", func() { + Expect(EncodeLibraryID(1 << 24)).To(Equal("")) + Expect(EncodeLibraryID(-1)).To(Equal("")) + Expect(EncodePlaylistEntryID("16777216")).To(Equal("")) + }) + + It("keeps library and playlist-entry GUIDs distinct for the same number", func() { + Expect(EncodeLibraryID(3)).ToNot(Equal(EncodePlaylistEntryID("3"))) + }) + + It("does not let one reserved kind decode as another", func() { + _, ok := DecodeID(EncodePlaylistEntryID("3")) + Expect(ok).To(BeFalse()) + _, ok = DecodePlaylistEntryID(EncodeLibraryID(3)) + Expect(ok).To(BeFalse()) + _, ok = DecodePlaylistEntryID(PlaylistsFolderGUID) + Expect(ok).To(BeFalse()) + }) + + It("rejects an unknown kind tag", func() { + _, ok := DecodeID("000000000000000000000000ff000000") + Expect(ok).To(BeFalse()) + }) + + It("never emits the all-zero GUID, which Jellyfin serializes as null", func() { + Expect(EncodeLibraryID(0)).ToNot(Equal("00000000000000000000000000000000")) + Expect(PlaylistsFolderGUID).ToNot(Equal("00000000000000000000000000000000")) + }) }) - 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)) + Describe("malformed input", func() { + DescribeTable("DecodeID reports ok=false", + func(input string) { + decoded, ok := DecodeID(input) + Expect(ok).To(BeFalse()) + Expect(decoded).To(BeEmpty()) + }, + Entry("empty", ""), + Entry("too short", "abc123"), + Entry("too long", "000000000000000000000000010000011"), + Entry("non-hex chars", "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"), + Entry("a raw base62 id", id.NewRandom()), + Entry("the old 44-char hex format", hex.EncodeToString([]byte("5QFKvMsJrd57QE2Le2dKKo"))), + ) + + It("encodes a non-canonical id to the empty string", func() { + Expect(EncodeID("")).To(Equal("")) + Expect(EncodeID("playlists")).To(Equal("")) + Expect(EncodeID("42")).To(Equal("")) + }) }) - It("round-trips the empty string", func() { - Expect(EncodeID("")).To(Equal("")) - Expect(DecodeID("")).To(Equal("")) - }) + Describe("DecodeIDs", func() { + It("decodes every entry when all are well-formed", func() { + a, b := id.NewRandom(), id.NewRandom() + decoded, ok := DecodeIDs([]string{EncodeID(a), EncodeID(b)}) + Expect(ok).To(BeTrue()) + Expect(decoded).To(Equal([]string{a, b})) + }) - 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")) + It("is all-or-nothing: one malformed entry fails the whole list", func() { + decoded, ok := DecodeIDs([]string{EncodeID(id.NewRandom()), "not-a-guid"}) + Expect(ok).To(BeFalse()) + Expect(decoded).To(BeNil()) + }) + + It("succeeds on an empty list", func() { + decoded, ok := DecodeIDs(nil) + Expect(ok).To(BeTrue()) + Expect(decoded).To(BeEmpty()) + }) }) }) diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go index 31cdba1dd..1ebf66ca1 100644 --- a/server/jellyfin/dto/mappers.go +++ b/server/jellyfin/dto/mappers.go @@ -138,6 +138,7 @@ func UserData(a model.Annotations, itemID string) *UserItemDataDto { // 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 { + albumID := EncodeID(mf.AlbumID) item := BaseItemDto{ Name: mf.Title, Id: EncodeID(mf.ID), @@ -146,9 +147,9 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto { IsFolder: false, LocationType: "FileSystem", HasLyrics: mf.HasEmbeddedLyrics(), - ParentId: EncodeID(mf.AlbumID), + ParentId: albumID, Album: mf.Album, - AlbumId: EncodeID(mf.AlbumID), + AlbumId: albumID, AlbumArtist: mf.AlbumArtist, RunTimeTicks: TicksFromSeconds(mf.Duration), DateCreated: jellyfinDate(&mf.CreatedAt), diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go index 77b78e55f..576482e61 100644 --- a/server/jellyfin/dto/mappers_test.go +++ b/server/jellyfin/dto/mappers_test.go @@ -14,10 +14,10 @@ import ( 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", + ID: testID("song-1"), Title: "Song", Album: "Alb", AlbumID: testID("alb-1"), Artist: "Art", AlbumArtist: "AA", TrackNumber: 3, DiscNumber: 1, Year: 1999, Duration: 60, Size: 2_500_000, - Genres: []model.Genre{{ID: "1", Name: "genre 1"}, {ID: "2", Name: "genre 2"}}, + Genres: []model.Genre{{ID: testID("1"), Name: "genre 1"}, {ID: testID("2"), Name: "genre 2"}}, } mf.PlayCount = 2 mf.Starred = true @@ -26,24 +26,24 @@ var _ = Describe("mappers", func() { 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.Id).To(Equal(EncodeID(testID("song-1")))) + Expect(item.AlbumId).To(Equal(EncodeID(testID("alb-1")))) + Expect(item.ParentId).To(Equal(EncodeID(testID("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.AlbumPrimaryImageTag).To(Equal("alb-1")) + Expect(item.UserData.Key).To(Equal(EncodeID(testID("song-1")))) + Expect(item.UserData.ItemId).To(Equal(EncodeID(testID("song-1")))) + Expect(item.AlbumPrimaryImageTag).To(Equal(testID("alb-1"))) Expect(item.ImageBlurHashes).To(BeNil()) Expect(item.Genres).To(Equal([]string{"genre 1", "genre 2"})) - Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID("1"), Name: "genre 1"}, {Id: EncodeID("2"), Name: "genre 2"}})) + Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID(testID("1")), Name: "genre 1"}, {Id: EncodeID(testID("2")), Name: "genre 2"}})) }) Describe("Fields gating (matches real Jellyfin)", func() { - mf := model.MediaFile{ID: "s1", Title: "Song", Size: 2_500_000, Suffix: "mp3", Duration: 60, + mf := model.MediaFile{ID: testID("s1"), Title: "Song", Size: 2_500_000, Suffix: "mp3", Duration: 60, SortTitle: "sort song", Lyrics: `[{"line":[{"value":"la"}]}]`} It("omits MediaSources when Fields does not ask for them", func() { @@ -59,9 +59,9 @@ var _ = Describe("mappers", func() { // SortName must match the server sort order — see the sortName helper. Describe("SortName", func() { - song := model.MediaFile{ID: "s1", Title: "The Song", SortTitle: "Song, The", OrderTitle: "song"} - ar := model.Artist{ID: "art-1", Name: "The B-52's", SortArtistName: "B-52's, The", OrderArtistName: "b-52's"} - al := model.Album{ID: "alb-1", Name: "The Wall", SortAlbumName: "Wall, The", OrderAlbumName: "wall"} + song := model.MediaFile{ID: testID("s1"), Title: "The Song", SortTitle: "Song, The", OrderTitle: "song"} + ar := model.Artist{ID: testID("art-1"), Name: "The B-52's", SortArtistName: "B-52's, The", OrderArtistName: "b-52's"} + al := model.Album{ID: testID("alb-1"), Name: "The Wall", SortAlbumName: "Wall, The", OrderAlbumName: "wall"} BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) @@ -91,85 +91,85 @@ var _ = Describe("mappers", func() { }) It("falls back to the order name when there is no sort tag", func() { - Expect(ArtistToBaseItem(model.Artist{ID: "a", Name: "The X", OrderArtistName: "x"}, + Expect(ArtistToBaseItem(model.Artist{ID: testID("a"), Name: "The X", OrderArtistName: "x"}, ParseFields("SortName")).SortName).To(Equal("x")) }) }) It("falls back to the display name when order name and sort tag are empty", func() { - Expect(SongToBaseItem(model.MediaFile{ID: "s", Title: "T"}, ParseFields("SortName")).SortName).To(Equal("T")) - Expect(ArtistToBaseItem(model.Artist{ID: "a", Name: "N"}, ParseFields("SortName")).SortName).To(Equal("N")) - Expect(AlbumToBaseItem(model.Album{ID: "al", Name: "A"}, ParseFields("SortName")).SortName).To(Equal("A")) + Expect(SongToBaseItem(model.MediaFile{ID: testID("s"), Title: "T"}, ParseFields("SortName")).SortName).To(Equal("T")) + Expect(ArtistToBaseItem(model.Artist{ID: testID("a"), Name: "N"}, ParseFields("SortName")).SortName).To(Equal("N")) + Expect(AlbumToBaseItem(model.Album{ID: testID("al"), Name: "A"}, ParseFields("SortName")).SortName).To(Equal("A")) }) }) 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()) + Expect(SongToBaseItem(model.MediaFile{ID: testID("s2"), Title: "No Lyrics"}, nil).HasLyrics).To(BeFalse()) // "[]" is the no-lyrics sentinel, not a truthy value. - Expect(SongToBaseItem(model.MediaFile{ID: "s3", Title: "Empty Lyrics", Lyrics: "[]"}, nil).HasLyrics).To(BeFalse()) + Expect(SongToBaseItem(model.MediaFile{ID: testID("s3"), Title: "Empty Lyrics", 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} + mf := model.MediaFile{ID: testID("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)} + mf := model.MediaFile{ID: testID("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()) + Expect(SongToBaseItem(model.MediaFile{ID: testID("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", + ID: testID("s1"), Title: "Song", + Artist: "The Band", ArtistID: testID("ar-1"), + AlbumArtist: "Various", AlbumArtistID: testID("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")}})) + Expect(item.ArtistItems).To(Equal([]NameGuidPair{{Name: "The Band", Id: EncodeID(testID("ar-1"))}})) + Expect(item.AlbumArtists).To(Equal([]NameGuidPair{{Name: "Various", Id: EncodeID(testID("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()) + Expect(SongToBaseItem(model.MediaFile{ID: testID("s1"), Title: "Song", Artist: "X"}, nil).ArtistItems).To(BeNil()) }) It("omits Artists when the track has no artist name or participants", func() { - Expect(SongToBaseItem(model.MediaFile{ID: "s1", Title: "Song"}, nil).Artists).To(BeNil()) + Expect(SongToBaseItem(model.MediaFile{ID: testID("s1"), Title: "Song"}, nil).Artists).To(BeNil()) }) It("splits Artists and ArtistItems per track artist from Participants", func() { mf := model.MediaFile{ - ID: "s1", Title: "Oooh", - Artist: "De La Soul feat. Redman", ArtistID: "ar-delasoul", - AlbumArtist: "De La Soul", AlbumArtistID: "ar-delasoul", + ID: testID("s1"), Title: "Oooh", + Artist: "De La Soul feat. Redman", ArtistID: testID("ar-delasoul"), + AlbumArtist: "De La Soul", AlbumArtistID: testID("ar-delasoul"), } mf.Participants = model.Participants{ model.RoleArtist: model.ParticipantList{ - {Artist: model.Artist{ID: "ar-delasoul", Name: "De La Soul"}}, - {Artist: model.Artist{ID: "ar-redman", Name: "Redman"}}, + {Artist: model.Artist{ID: testID("ar-delasoul"), Name: "De La Soul"}}, + {Artist: model.Artist{ID: testID("ar-redman"), Name: "Redman"}}, }, } item := SongToBaseItem(mf, nil) Expect(item.Artists).To(Equal([]string{"De La Soul", "Redman"})) Expect(item.ArtistItems).To(Equal([]NameGuidPair{ - {Name: "De La Soul", Id: EncodeID("ar-delasoul")}, - {Name: "Redman", Id: EncodeID("ar-redman")}, + {Name: "De La Soul", Id: EncodeID(testID("ar-delasoul"))}, + {Name: "Redman", Id: EncodeID(testID("ar-redman"))}, })) // AlbumArtists stays single, matching real Jellyfin. - Expect(item.AlbumArtists).To(Equal([]NameGuidPair{{Name: "De La Soul", Id: EncodeID("ar-delasoul")}})) + Expect(item.AlbumArtists).To(Equal([]NameGuidPair{{Name: "De La Soul", Id: EncodeID(testID("ar-delasoul"))}})) }) It("serializes normalization gains with Jellyfin's exact key casing", func() { - mf := model.MediaFile{ID: "s1", Title: "Song", + mf := model.MediaFile{ID: testID("s1"), Title: "Song", RGTrackGain: new(-3.5), RGAlbumGain: new(-4.25)} b, err := json.Marshal(SongToBaseItem(mf, nil)) Expect(err).ToNot(HaveOccurred()) @@ -178,16 +178,16 @@ var _ = Describe("mappers", func() { }) It("omits normalization gains when the file has no ReplayGain tags", func() { - b, err := json.Marshal(SongToBaseItem(model.MediaFile{ID: "s1", Title: "Song"}, nil)) + b, err := json.Marshal(SongToBaseItem(model.MediaFile{ID: testID("s1"), Title: "Song"}, nil)) Expect(err).ToNot(HaveOccurred()) // Substring check covers both keys (AlbumNormalizationGain contains NormalizationGain). Expect(string(b)).ToNot(ContainSubstring("NormalizationGain")) }) It("builds a MediaSourceInfo from a media file", func() { - mf := model.MediaFile{ID: "s1", Size: 5242880, Suffix: "mp3", BitRate: 320, Duration: 100} + mf := model.MediaFile{ID: testID("s1"), Size: 5242880, Suffix: "mp3", BitRate: 320, Duration: 100} src := MediaSourceFromMediaFile(mf) - Expect(src.Id).To(Equal(EncodeID("s1"))) + Expect(src.Id).To(Equal(EncodeID(testID("s1")))) Expect(src.Size).To(Equal(int64(5242880))) Expect(src.Container).To(Equal("mp3")) Expect(src.Bitrate).To(Equal(320_000)) @@ -198,7 +198,7 @@ var _ = Describe("mappers", func() { 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, + ID: testID("s1"), Size: 5242880, Suffix: "mp3", BitRate: 320, Duration: 100, Channels: 2, SampleRate: 44100, Codec: "mp3", } src := MediaSourceFromMediaFile(mf) @@ -213,7 +213,7 @@ var _ = Describe("mappers", func() { }) 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} + mf := model.MediaFile{ID: testID("s1"), Size: 5242880, Suffix: "mp3", BitRate: 320, Duration: 100} src := MediaSourceFromMediaFile(mf) b, err := json.Marshal(src) Expect(err).ToNot(HaveOccurred()) @@ -241,7 +241,7 @@ var _ = Describe("mappers", func() { Describe("Lyric media stream advertising", func() { It("adds a Lyric media stream when the file has embedded lyrics", func() { - mf := model.MediaFile{ID: "s1", Lyrics: `[{"line":[{"value":"la"}]}]`} + mf := model.MediaFile{ID: testID("s1"), Lyrics: `[{"line":[{"value":"la"}]}]`} src := MediaSourceFromMediaFile(mf) Expect(src.MediaStreams).To(HaveLen(2)) Expect(src.MediaStreams[0].Type).To(Equal("Audio")) @@ -251,20 +251,20 @@ var _ = Describe("mappers", func() { }) It("emits only the Audio stream without lyrics", func() { - src := MediaSourceFromMediaFile(model.MediaFile{ID: "s1"}) + src := MediaSourceFromMediaFile(model.MediaFile{ID: testID("s1")}) Expect(src.MediaStreams).To(HaveLen(1)) Expect(src.MediaStreams[0].Type).To(Equal("Audio")) }) It("emits only the Audio stream for the post-scan empty-lyrics sentinel", func() { - src := MediaSourceFromMediaFile(model.MediaFile{ID: "s1", Lyrics: "[]"}) + src := MediaSourceFromMediaFile(model.MediaFile{ID: testID("s1"), Lyrics: "[]"}) Expect(src.MediaStreams).To(HaveLen(1)) }) }) 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", + ID: testID("song-2"), Title: "Song", Album: "Alb", AlbumID: testID("alb-1"), Artist: "Art", AlbumArtist: "AA", TrackNumber: 0, DiscNumber: 0, Duration: 60, } @@ -276,7 +276,7 @@ var _ = Describe("mappers", func() { 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", + ID: testID("song-3"), Title: "Song", Album: "Alb", AlbumID: testID("alb-1"), Artist: "Art", AlbumArtist: "AA", Duration: 60, } mf.PlayDate = &playDate @@ -286,25 +286,25 @@ var _ = Describe("mappers", func() { }) 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, Genres: []model.Genre{{ID: "1", Name: "genre 1"}, {ID: "2", Name: "genre 2"}}} + al := model.Album{ID: testID("alb-1"), Name: "Alb", AlbumArtist: "AA", AlbumArtistID: testID("art-1"), MaxYear: 1999, SongCount: 10, Genres: []model.Genre{{ID: testID("1"), Name: "genre 1"}, {ID: testID("2"), Name: "genre 2"}}} item := AlbumToBaseItem(al, nil) 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.Id).To(Equal(EncodeID(testID("alb-1")))) + Expect(item.ParentId).To(Equal(EncodeID(testID("art-1")))) Expect(item.AlbumArtists).To(HaveLen(1)) - Expect(item.AlbumArtists[0].Id).To(Equal(EncodeID("art-1"))) + Expect(item.AlbumArtists[0].Id).To(Equal(EncodeID(testID("art-1")))) Expect(item.ArtistItems).To(Equal(item.AlbumArtists)) Expect(*item.ProductionYear).To(Equal(1999)) Expect(*item.ChildCount).To(Equal(10)) - Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "alb-1")) + Expect(item.ImageTags).To(HaveKeyWithValue("Primary", testID("alb-1"))) Expect(item.ImageBlurHashes).To(BeNil()) Expect(item.Genres).To(Equal([]string{"genre 1", "genre 2"})) - Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID("1"), Name: "genre 1"}, {Id: EncodeID("2"), Name: "genre 2"}})) + Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID(testID("1")), Name: "genre 1"}, {Id: EncodeID(testID("2")), Name: "genre 2"}})) }) It("populates album Studios from record-label tags only when Fields=Studios", func() { - al := model.Album{ID: "alb-2", Name: "Alb2"} + al := model.Album{ID: testID("alb-2"), Name: "Alb2"} al.Tags = model.Tags{model.TagRecordLabel: []string{"Columbia", "Legacy"}} Expect(AlbumToBaseItem(al, nil).Studios).To(BeEmpty()) @@ -317,7 +317,7 @@ var _ = Describe("mappers", func() { }) It("sets NormalizationGain on the album from its ReplayGain", func() { - al := model.Album{ID: "al1", Name: "Album", RGAlbumGain: new(-6.0)} + al := model.Album{ID: testID("al1"), Name: "Album", RGAlbumGain: new(-6.0)} b, err := json.Marshal(AlbumToBaseItem(al, nil)) Expect(err).ToNot(HaveOccurred()) Expect(string(b)).To(ContainSubstring(`"NormalizationGain":-6`)) @@ -326,14 +326,14 @@ var _ = Describe("mappers", func() { }) It("omits NormalizationGain when the album has no ReplayGain", func() { - b, err := json.Marshal(AlbumToBaseItem(model.Album{ID: "al1", Name: "Album"}, nil)) + b, err := json.Marshal(AlbumToBaseItem(model.Album{ID: testID("al1"), Name: "Album"}, nil)) Expect(err).ToNot(HaveOccurred()) Expect(string(b)).ToNot(ContainSubstring("NormalizationGain")) }) Describe("PrimaryImageAspectRatio", func() { nonSquare := func() model.Album { - al := model.Album{ID: "al1", Name: "Album"} + al := model.Album{ID: testID("al1"), Name: "Album"} al.ImageHash, al.ImageWidth, al.ImageHeight = "abc", 1200, 800 return al } @@ -351,14 +351,14 @@ var _ = Describe("mappers", func() { }) It("is omitted when the dimensions are unknown, rather than guessing square", func() { - al := model.Album{ID: "al1", Name: "Album"} + al := model.Album{ID: testID("al1"), Name: "Album"} al.ImageHash = "abc" item := AlbumToBaseItem(al, ParseFields("PrimaryImageAspectRatio")) Expect(item.PrimaryImageAspectRatio).To(BeNil()) }) It("is omitted when the item has no image at all", func() { - al := model.Album{ID: "al1", Name: "Album"} + al := model.Album{ID: testID("al1"), Name: "Album"} al.ImageAbsent = true al.ImageWidth, al.ImageHeight = 1200, 800 item := AlbumToBaseItem(al, ParseFields("PrimaryImageAspectRatio")) @@ -366,21 +366,21 @@ var _ = Describe("mappers", func() { }) It("carries the ratio for an artist", func() { - ar := model.Artist{ID: "ar1", Name: "Artist"} + ar := model.Artist{ID: testID("ar1"), Name: "Artist"} ar.ImageHash, ar.ImageWidth, ar.ImageHeight = "abc", 1000, 500 Expect(*ArtistToBaseItem(ar, ParseFields("PrimaryImageAspectRatio")).PrimaryImageAspectRatio). To(BeNumerically("~", 2.0, 0.0001)) }) It("carries the ratio for a playlist", func() { - pl := model.Playlist{ID: "pl1", Name: "Playlist"} + pl := model.Playlist{ID: testID("pl1"), Name: "Playlist"} pl.ImageHash, pl.ImageWidth, pl.ImageHeight = "abc", 400, 800 Expect(*PlaylistToBaseItem(pl, ParseFields("PrimaryImageAspectRatio")).PrimaryImageAspectRatio). To(BeNumerically("~", 0.5, 0.0001)) }) It("carries the ratio for a song with its own art", func() { - mf := model.MediaFile{ID: "mf1", Title: "Song"} + mf := model.MediaFile{ID: testID("mf1"), Title: "Song"} mf.ImageHash, mf.ImageWidth, mf.ImageHeight = "abc", 300, 600 Expect(*SongToBaseItem(mf, ParseFields("PrimaryImageAspectRatio")).PrimaryImageAspectRatio). To(BeNumerically("~", 0.5, 0.0001)) @@ -388,7 +388,7 @@ var _ = Describe("mappers", func() { // A track without its own art shows the album's, so the ratio has to describe that image. It("uses the album's dimensions for a track falling back to album art", func() { - mf := model.MediaFile{ID: "mf1", Title: "Song", AlbumID: "al1"} + mf := model.MediaFile{ID: testID("mf1"), Title: "Song", AlbumID: testID("al1")} mf.AlbumImage.ImageHash, mf.AlbumImage.ImageWidth, mf.AlbumImage.ImageHeight = "abc", 1200, 800 item := SongToBaseItem(mf, ParseFields("PrimaryImageAspectRatio")) Expect(item.AlbumPrimaryImageTag).To(Equal("abc")) @@ -397,73 +397,73 @@ var _ = Describe("mappers", func() { }) It("maps an artist to a MusicArtist folder item", func() { - ar := model.Artist{ID: "art-1", Name: "AA", AlbumCount: 2, SongCount: 20} + ar := model.Artist{ID: testID("art-1"), Name: "AA", AlbumCount: 2, SongCount: 20} item := ArtistToBaseItem(ar, nil) Expect(item.Type).To(Equal("MusicArtist")) Expect(item.IsFolder).To(BeTrue()) - Expect(item.Id).To(Equal(EncodeID("art-1"))) + Expect(item.Id).To(Equal(EncodeID(testID("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"} + g := model.Genre{ID: testID("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.Id).To(Equal(EncodeID(testID("genre-1")))) Expect(item.Name).To(Equal("Rock")) }) It("maps a tag to a Studio BaseItemDto", func() { - item := StudioToBaseItem(model.Tag{ID: "t1", TagValue: "Blue Note"}) + item := StudioToBaseItem(model.Tag{ID: testID("t1"), TagValue: "Blue Note"}) Expect(item.Type).To(Equal("Studio")) Expect(item.Name).To(Equal("Blue Note")) - Expect(item.Id).To(Equal(EncodeID("t1"))) + Expect(item.Id).To(Equal(EncodeID(testID("t1")))) }) 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} + mf := model.MediaFile{ID: testID("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} + mf := model.MediaFile{ID: testID("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"} + mf := model.MediaFile{ID: testID("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} + mf := model.MediaFile{ID: testID("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()) + Expect(SongToBaseItem(model.MediaFile{ID: testID("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"}, nil).PremiereDate).To(Equal("2013-09-06T00:00:00Z")) - Expect(*AlbumToBaseItem(model.Album{ID: "a2", MaxYear: 2013}, nil).PremiereDate).To(Equal("2013-01-01T00:00:00Z")) - Expect(AlbumToBaseItem(model.Album{ID: "a3"}, nil).PremiereDate).To(BeNil()) + Expect(*AlbumToBaseItem(model.Album{ID: testID("a1"), Date: "2013-09-06"}, nil).PremiereDate).To(Equal("2013-09-06T00:00:00Z")) + Expect(*AlbumToBaseItem(model.Album{ID: testID("a2"), MaxYear: 2013}, nil).PremiereDate).To(Equal("2013-01-01T00:00:00Z")) + Expect(AlbumToBaseItem(model.Album{ID: testID("a3")}, nil).PremiereDate).To(BeNil()) }) }) It("maps a playlist to a Playlist BaseItemDto", func() { p := model.Playlist{ - ID: "pl-1", Name: "Chill", SongCount: 7, Duration: 120, + ID: testID("pl-1"), Name: "Chill", SongCount: 7, Duration: 120, Annotations: model.Annotations{Starred: true, Rating: 4, PlayCount: 2}, } item := PlaylistToBaseItem(p, nil) Expect(item.Type).To(Equal("Playlist")) Expect(item.IsFolder).To(BeTrue()) - Expect(item.Id).To(Equal(EncodeID("pl-1"))) + Expect(item.Id).To(Equal(EncodeID(testID("pl-1")))) Expect(item.Name).To(Equal("Chill")) Expect(item.MediaType).To(Equal("Audio")) Expect(*item.ChildCount).To(Equal(7)) @@ -471,12 +471,12 @@ var _ = Describe("mappers", func() { Expect(item.UserData.IsFavorite).To(BeTrue()) Expect(item.UserData.PlayCount).To(Equal(2)) Expect(*item.UserData.Rating).To(Equal(8.0)) - Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "pl-1")) + Expect(item.ImageTags).To(HaveKeyWithValue("Primary", testID("pl-1"))) Expect(item.ImageBlurHashes).To(BeNil()) }) It("changes the playlist image tag when the cover content changes", func() { - p := model.Playlist{ID: "pl-1", Name: "Chill"} + p := model.Playlist{ID: testID("pl-1"), Name: "Chill"} p.ImageHash = "1111111111111111" before := PlaylistToBaseItem(p, nil) p.ImageHash = "2222222222222222" @@ -487,7 +487,7 @@ var _ = Describe("mappers", func() { }) It("keeps the playlist image tag stable across a metadata-only edit", func() { - p := model.Playlist{ID: "pl-1", UpdatedAt: time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)} + p := model.Playlist{ID: testID("pl-1"), UpdatedAt: time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)} p.ImageHash = "1111111111111111" before := PlaylistToBaseItem(p, nil) p.UpdatedAt = time.Date(2026, 7, 2, 0, 0, 0, 0, time.UTC) @@ -505,17 +505,17 @@ var _ = Describe("mappers", func() { }) It("advertises the track id so the client triggers the read-through", func() { - mf := model.MediaFile{ID: "mf-1", AlbumID: "alb-1", HasCoverArt: true} + mf := model.MediaFile{ID: testID("mf-1"), AlbumID: testID("alb-1"), HasCoverArt: true} mf.AlbumImage.ImageHash = "0123456789abcdef" item := SongToBaseItem(mf, nil) - Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "mf-1")) + Expect(item.ImageTags).To(HaveKeyWithValue("Primary", testID("mf-1"))) Expect(item.ImageBlurHashes).To(BeNil(), "no resolved image means no blurhash to send") Expect(item.AlbumPrimaryImageTag).To(BeEmpty()) }) It("falls back to the album when the track has no art of its own", func() { - mf := model.MediaFile{ID: "mf-2", AlbumID: "alb-1", HasCoverArt: false} + mf := model.MediaFile{ID: testID("mf-2"), AlbumID: testID("alb-1"), HasCoverArt: false} mf.AlbumImage.ImageHash = "0123456789abcdef" item := SongToBaseItem(mf, nil) @@ -524,7 +524,7 @@ var _ = Describe("mappers", func() { }) It("falls back to the album once the track's art is known absent", func() { - mf := model.MediaFile{ID: "mf-3", AlbumID: "alb-1", HasCoverArt: true} + mf := model.MediaFile{ID: testID("mf-3"), AlbumID: testID("alb-1"), HasCoverArt: true} mf.ItemImage.ImageAbsent = true mf.AlbumImage.ImageHash = "0123456789abcdef" @@ -535,7 +535,7 @@ var _ = Describe("mappers", func() { It("falls back to the album when per-track art is disabled", func() { conf.Server.EnableMediaFileCoverArt = false - mf := model.MediaFile{ID: "mf-4", AlbumID: "alb-1", HasCoverArt: true} + mf := model.MediaFile{ID: testID("mf-4"), AlbumID: testID("alb-1"), HasCoverArt: true} mf.AlbumImage.ImageHash = "0123456789abcdef" item := SongToBaseItem(mf, nil) @@ -546,7 +546,7 @@ var _ = Describe("mappers", func() { Describe("primary image tags", func() { It("uses the content hash as the tag and emits the real blurhash", func() { - al := model.Album{ID: "alb-1", Name: "Album"} + al := model.Album{ID: testID("alb-1"), Name: "Album"} al.ImageHash = "0123456789abcdef" al.BlurHash = "LEHV6nWB2yk8" @@ -556,7 +556,7 @@ var _ = Describe("mappers", func() { }) It("omits the blurhash entirely when none was computed", func() { - al := model.Album{ID: "alb-2", Name: "Album"} + al := model.Album{ID: testID("alb-2"), Name: "Album"} al.ImageHash = "0123456789abcdef" item := AlbumToBaseItem(al, nil) @@ -565,7 +565,7 @@ var _ = Describe("mappers", func() { }) It("omits tags for known-absent artwork", func() { - al := model.Album{ID: "alb-3", Name: "Album"} + al := model.Album{ID: testID("alb-3"), Name: "Album"} al.ImageAbsent = true item := AlbumToBaseItem(al, nil) @@ -574,13 +574,13 @@ var _ = Describe("mappers", func() { }) It("falls back to the entity id while artwork is still unresolved", func() { - item := AlbumToBaseItem(model.Album{ID: "alb-4", Name: "Album"}, nil) - Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "alb-4")) + item := AlbumToBaseItem(model.Album{ID: testID("alb-4"), Name: "Album"}, nil) + Expect(item.ImageTags).To(HaveKeyWithValue("Primary", testID("alb-4"))) Expect(item.ImageBlurHashes).To(BeNil()) }) It("versions an artist's tag by content hash", func() { - ar := model.Artist{ID: "art-1", Name: "Artist"} + ar := model.Artist{ID: testID("art-1"), Name: "Artist"} ar.ImageHash = "fedcba9876543210" ar.BlurHash = "L6PZfSi_.AyE" @@ -592,7 +592,7 @@ var _ = Describe("mappers", func() { Describe("song and playlist image tags", func() { It("versions a song's album tag by the album's content hash", func() { - mf := model.MediaFile{ID: "song-1", Title: "Song", AlbumID: "alb-1"} + mf := model.MediaFile{ID: testID("song-1"), Title: "Song", AlbumID: testID("alb-1")} mf.AlbumImage.ImageHash = "0123456789abcdef" mf.AlbumImage.BlurHash = "LEHV6nWB2yk8" @@ -602,7 +602,7 @@ var _ = Describe("mappers", func() { }) It("never synthesizes a song blurhash when the album has none", func() { - mf := model.MediaFile{ID: "song-2", Title: "Song", AlbumID: "alb-2"} + mf := model.MediaFile{ID: testID("song-2"), Title: "Song", AlbumID: testID("alb-2")} mf.AlbumImage.ImageHash = "0123456789abcdef" item := SongToBaseItem(mf, nil) @@ -611,7 +611,7 @@ var _ = Describe("mappers", func() { }) It("omits a song's album tag when the album art is known absent", func() { - mf := model.MediaFile{ID: "song-3", Title: "Song", AlbumID: "alb-3"} + mf := model.MediaFile{ID: testID("song-3"), Title: "Song", AlbumID: testID("alb-3")} mf.AlbumImage.ImageAbsent = true item := SongToBaseItem(mf, nil) @@ -620,7 +620,7 @@ var _ = Describe("mappers", func() { }) It("versions a playlist tag by content hash instead of UpdatedAt", func() { - pl := model.Playlist{ID: "pl-1", Name: "Playlist"} + pl := model.Playlist{ID: testID("pl-1"), Name: "Playlist"} pl.ImageHash = "abcdef0123456789" item := PlaylistToBaseItem(pl, nil) @@ -630,7 +630,7 @@ var _ = Describe("mappers", func() { Describe("per-song artwork", func() { It("emits the track's own Primary tag when it has distinct art", func() { - mf := model.MediaFile{ID: "song-own", Title: "Song", AlbumID: "alb-1"} + mf := model.MediaFile{ID: testID("song-own"), Title: "Song", AlbumID: testID("alb-1")} mf.ImageHash = "aaaaaaaaaaaaaaaa" mf.BlurHash = "LTRACKblur" mf.AlbumImage.ImageHash = "bbbbbbbbbbbbbbbb" @@ -644,7 +644,7 @@ var _ = Describe("mappers", func() { }) It("falls back to the album tag when the track has no distinct art", func() { - mf := model.MediaFile{ID: "song-inherit", Title: "Song", AlbumID: "alb-1"} + mf := model.MediaFile{ID: testID("song-inherit"), Title: "Song", AlbumID: testID("alb-1")} mf.ImageHash = "bbbbbbbbbbbbbbbb" mf.BlurHash = "LALBUMblur" mf.AlbumImage.ImageHash = "bbbbbbbbbbbbbbbb" @@ -658,7 +658,7 @@ var _ = Describe("mappers", func() { }) It("omits the track tag when its own art is known absent", func() { - mf := model.MediaFile{ID: "song-absent", Title: "Song", AlbumID: "alb-1"} + mf := model.MediaFile{ID: testID("song-absent"), Title: "Song", AlbumID: testID("alb-1")} mf.ImageAbsent = true mf.AlbumImage.ImageAbsent = true @@ -673,7 +673,7 @@ var _ = Describe("mappers", func() { var _ = Describe("LyricDtoFromLyrics", func() { ms := func(v int64) *int64 { return &v } - mf := model.MediaFile{ID: "s1", Title: "Song", Artist: "Artist", Album: "Album", Duration: 100} + mf := model.MediaFile{ID: testID("s1"), Title: "Song", Artist: "Artist", Album: "Album", Duration: 100} It("maps synced lyrics with tick conversion", func() { l := model.Lyrics{ diff --git a/server/jellyfin/e2e/annotations_test.go b/server/jellyfin/e2e/annotations_test.go index b1ad850e3..182fd5436 100644 --- a/server/jellyfin/e2e/annotations_test.go +++ b/server/jellyfin/e2e/annotations_test.go @@ -79,7 +79,7 @@ var _ = Describe("Annotations", func() { }) It("returns 404 when favoriting an unknown item", func() { - Expect(post("/Users/admin-1/FavoriteItems/"+enc("nope"), "").Code).To(Equal(http.StatusNotFound)) + Expect(post("/Users/admin-1/FavoriteItems/"+enc(testID("nope")), "").Code).To(Equal(http.StatusNotFound)) }) }) @@ -102,7 +102,7 @@ var _ = Describe("Annotations", func() { }) It("returns 404 for an unknown item", func() { - Expect(get("/UserItems/" + enc("nope") + "/UserData").Code).To(Equal(http.StatusNotFound)) + Expect(get("/UserItems/" + enc(testID("nope")) + "/UserData").Code).To(Equal(http.StatusNotFound)) }) }) diff --git a/server/jellyfin/e2e/audiomuse_test.go b/server/jellyfin/e2e/audiomuse_test.go index 383dbab83..074c8e1a4 100644 --- a/server/jellyfin/e2e/audiomuse_test.go +++ b/server/jellyfin/e2e/audiomuse_test.go @@ -61,7 +61,9 @@ var _ = Describe("AudioMuse endpoints", func() { 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))) + decoded, ok := dto.DecodeID(body[0].ItemID) + Expect(ok).To(BeTrue()) + Expect(decoded).To(Equal(songID(body[0].Title))) }) It("collapses to one track per artist by default", func() { diff --git a/server/jellyfin/e2e/auth_test.go b/server/jellyfin/e2e/auth_test.go index 7128972ba..806b0e5e7 100644 --- a/server/jellyfin/e2e/auth_test.go +++ b/server/jellyfin/e2e/auth_test.go @@ -27,9 +27,9 @@ 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(enc("admin-1"))) + Expect(res.User.Id).To(Equal(enc(testID("admin-1")))) Expect(res.User.Policy.IsAdministrator).To(BeTrue()) - Expect(res.ServerId).ToNot(BeEmpty()) + Expect(res.ServerId).To(MatchRegexp("^[0-9a-f]{32}$")) // The returned token must actually authenticate a protected request. r := httptest.NewRequest("GET", "/Users/Me", nil) @@ -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(enc("regular-1"))) + Expect(users[0].Id).To(Equal(enc(testID("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(enc("regular-1"))) + Expect(u.Id).To(Equal(enc(testID("regular-1")))) }) It("returns the caller from GET /Users/{userId}", func() { diff --git a/server/jellyfin/e2e/browsing_test.go b/server/jellyfin/e2e/browsing_test.go index 14ab8b62b..004090de6 100644 --- a/server/jellyfin/e2e/browsing_test.go +++ b/server/jellyfin/e2e/browsing_test.go @@ -46,7 +46,7 @@ var _ = Describe("Browsing", func() { 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.ServerId).To(MatchRegexp("^[0-9a-f]{32}$")) // real Jellyfin always sets it, no-dash GUID form Expect(it.AlbumId).ToNot(BeEmpty()) } }) @@ -151,7 +151,7 @@ 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") + lib1 := dto.EncodeLibraryID(1) It("returns no songs for a library parent", func() { q := queryResult(get("/Items?IncludeItemTypes=Audio&ParentId=" + lib1 + "&Recursive=false")) @@ -173,7 +173,7 @@ var _ = Describe("Browsing", func() { // Finamp's artist screen sends ParentId=<libraryId> (scoping) plus AlbumArtistIds/ArtistIds // for the actual artist filter, not ParentId=<artistId>. Describe("artist filtering (AlbumArtistIds / ArtistIds)", func() { - lib1 := enc("1") + lib1 := dto.EncodeLibraryID(1) It("filters albums by AlbumArtistIds", func() { q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&ParentId=" + lib1 + "&AlbumArtistIds=" + enc(artistID("The Beatles")))) @@ -209,6 +209,87 @@ var _ = Describe("Browsing", func() { }) }) + // A malformed id must 404, not silently drop the filter and widen the query to the whole + // library; a well-formed but unknown one must still fail closed to zero results. + Describe("stale and malformed id filtering", func() { + lib1 := dto.EncodeLibraryID(1) + + It("404s a malformed ParentId instead of listing every song", func() { + w := get("/Items?IncludeItemTypes=Audio&ParentId=not-a-valid-id") + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns zero songs, not the whole library, for a well-formed but unknown ParentId", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&ParentId=" + enc(testID("no-such-album")))) + Expect(q.TotalRecordCount).To(BeZero()) + Expect(q.Items).To(BeEmpty()) + }) + + It("404s a malformed AlbumArtistIds instead of listing every album", func() { + w := get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&ParentId=" + lib1 + "&AlbumArtistIds=not-a-valid-id") + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns zero albums, not every album, for a well-formed but unknown AlbumArtistIds", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&ParentId=" + lib1 + "&AlbumArtistIds=" + enc(testID("no-such-artist")))) + Expect(q.TotalRecordCount).To(BeZero()) + Expect(q.Items).To(BeEmpty()) + }) + + It("404s a malformed ArtistIds instead of listing every song", func() { + w := get("/Items?IncludeItemTypes=Audio&Recursive=true&ParentId=" + lib1 + "&ArtistIds=not-a-valid-id") + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns zero songs, not every song, for a well-formed but unknown ArtistIds", func() { + q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&ParentId=" + lib1 + "&ArtistIds=" + enc(testID("no-such-artist")))) + Expect(q.TotalRecordCount).To(BeZero()) + Expect(q.Items).To(BeEmpty()) + }) + + It("404s a malformed Ids entry instead of batch-fetching nothing", func() { + w := get("/Items?Ids=not-a-valid-id") + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("404s when only one of several Ids entries is malformed", func() { + w := get("/Items?Ids=" + enc(songID("Something")) + ",not-a-valid-id") + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns zero items, not an error, for a well-formed but unknown Ids entry", func() { + q := queryResult(get("/Items?Ids=" + enc(testID("no-such-item")))) + Expect(q.TotalRecordCount).To(BeZero()) + Expect(q.Items).To(BeEmpty()) + }) + + It("404s a malformed GenreIds entry instead of listing every album", func() { + w := get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&GenreIds=not-a-valid-id") + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("404s when only one of several GenreIds entries is malformed", func() { + w := get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&GenreIds=" + enc(genreID("Jazz")) + ",not-a-valid-id") + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("404s a malformed AlbumIds entry instead of listing every song", func() { + w := get("/Items?IncludeItemTypes=Audio&Recursive=true&AlbumIds=not-a-valid-id") + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("404s a malformed StudioIds entry instead of listing every album", func() { + w := get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&StudioIds=not-a-valid-id") + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns zero albums, not every album, for a well-formed but unknown StudioIds", func() { + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&StudioIds=" + enc(testID("no-such-studio")))) + Expect(q.TotalRecordCount).To(BeZero()) + Expect(q.Items).To(BeEmpty()) + }) + }) + // Feishin fetches an album's tracks with AlbumIds=<albumId>&IncludeItemTypes=Audio&Recursive=true. Describe("album filtering (AlbumIds)", func() { It("filters songs by AlbumIds", func() { @@ -224,7 +305,7 @@ var _ = Describe("Browsing", func() { }) It("returns nothing for an unknown album id", func() { - q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&AlbumIds=" + enc("no-such-album"))) + q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&AlbumIds=" + enc(testID("no-such-album")))) Expect(q.Items).To(BeEmpty()) Expect(q.TotalRecordCount).To(Equal(0)) }) @@ -261,16 +342,16 @@ var _ = Describe("Browsing", func() { It("returns filter lists scoped to a ParentId library", func() { var filters dto.QueryFiltersLegacy - parseInto(get("/Items/Filters?ParentId="+enc("1")+"&IncludeItemTypes=Audio&Recursive=true"), &filters) + parseInto(get("/Items/Filters?ParentId="+dto.EncodeLibraryID(1)+"&IncludeItemTypes=Audio&Recursive=true"), &filters) Expect(filters.Years).To(ContainElements(1959, 1965)) - studios := queryResult(get("/Studios?ParentId=" + enc("1"))) + studios := queryResult(get("/Studios?ParentId=" + dto.EncodeLibraryID(1))) Expect(names(studios.Items)).To(ContainElement("Columbia")) }) }) // Finamp's genre screen sends ParentId=<libraryId> (scoping) plus GenreIds=<genreId>. Describe("genre filtering (GenreIds)", func() { - lib1 := enc("1") + lib1 := dto.EncodeLibraryID(1) It("filters albums by GenreIds", func() { q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&ParentId=" + lib1 + "&GenreIds=" + enc(genreID("Jazz")))) @@ -295,7 +376,7 @@ var _ = Describe("Browsing", func() { }) It("returns nothing for an unknown genre id", func() { - q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&GenreIds=" + enc("no-such-genre"))) + q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&GenreIds=" + enc(testID("no-such-genre")))) Expect(q.Items).To(BeEmpty()) Expect(q.TotalRecordCount).To(Equal(0)) }) @@ -317,7 +398,7 @@ var _ = Describe("Browsing", func() { }) It("returns no artists for an unknown genre id", func() { - q := queryResult(get("/Artists/AlbumArtists?GenreIds=" + enc("no-such-genre"))) + q := queryResult(get("/Artists/AlbumArtists?GenreIds=" + enc(testID("no-such-genre")))) Expect(q.Items).To(BeEmpty()) }) }) @@ -327,7 +408,7 @@ var _ = Describe("Browsing", func() { // 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") + lib1 := dto.EncodeLibraryID(1) It("filters albums by camelCase albumArtistIds", func() { q := queryResult(get("/Items?includeItemTypes=MusicAlbum&recursive=true&parentId=" + lib1 + "&albumArtistIds=" + enc(artistID("The Beatles")))) @@ -364,35 +445,6 @@ var _ = Describe("Browsing", func() { 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)) @@ -496,7 +548,7 @@ var _ = Describe("Browsing", func() { }) It("returns 404 for an unknown id", func() { - Expect(get("/Items/" + enc("does-not-exist")).Code).To(Equal(http.StatusNotFound)) + Expect(get("/Items/" + enc(testID("does-not-exist"))).Code).To(Equal(http.StatusNotFound)) }) }) diff --git a/server/jellyfin/e2e/e2e_suite_test.go b/server/jellyfin/e2e/e2e_suite_test.go index ca1308e9e..1ee19ea01 100644 --- a/server/jellyfin/e2e/e2e_suite_test.go +++ b/server/jellyfin/e2e/e2e_suite_test.go @@ -51,6 +51,7 @@ import ( "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/persistence" "github.com/navidrome/navidrome/server/events" @@ -70,6 +71,10 @@ func TestJellyfinE2E(t *testing.T) { RunSpecs(t, "Jellyfin API E2E Suite") } +// testID maps a readable label to a deterministic canonical id, so fixtures exercise the same +// id shape production uses. +func testID(label string) string { return id.NewHash("jellyfin-test", label) } + // Easy aliases for the storagetest package type _t = map[string]any @@ -91,14 +96,14 @@ var ( dataFolder string adminUser = model.User{ - ID: "admin-1", + ID: testID("admin-1"), UserName: "admin", Name: "Admin User", IsAdmin: true, } regularUser = model.User{ - ID: "regular-1", + ID: testID("regular-1"), UserName: "regular", Name: "Regular User", IsAdmin: false, @@ -219,7 +224,9 @@ func createPlaylistAs(user model.User, name string, encodedIds ...string) string var res map[string]string parseInto(postAs(user, "/Playlists", string(body)), &res) Expect(res["Id"]).ToNot(BeEmpty()) - return dto.DecodeID(res["Id"]) + id, ok := dto.DecodeID(res["Id"]) + Expect(ok).To(BeTrue()) + return id } // --- Seeded-id lookup helpers (return Navidrome ids; wrap with enc() for URLs) --- diff --git a/server/jellyfin/e2e/lyrics_test.go b/server/jellyfin/e2e/lyrics_test.go index 279f288b8..a49019bbf 100644 --- a/server/jellyfin/e2e/lyrics_test.go +++ b/server/jellyfin/e2e/lyrics_test.go @@ -53,7 +53,7 @@ var _ = Describe("Lyrics", func() { }) It("returns 404 for a fabricated id", func() { - Expect(get("/Audio/" + enc("nope") + "/Lyrics").Code).To(Equal(http.StatusNotFound)) + Expect(get("/Audio/" + enc(testID("nope")) + "/Lyrics").Code).To(Equal(http.StatusNotFound)) }) }) diff --git a/server/jellyfin/e2e/multiuser_test.go b/server/jellyfin/e2e/multiuser_test.go index 015d82d91..d3a521332 100644 --- a/server/jellyfin/e2e/multiuser_test.go +++ b/server/jellyfin/e2e/multiuser_test.go @@ -18,7 +18,7 @@ var _ = Describe("Multi-user access control", func() { }) 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"} + noAccess := model.User{ID: testID("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()) diff --git a/server/jellyfin/e2e/playlists_test.go b/server/jellyfin/e2e/playlists_test.go index 08d6f4f6f..50c2e17f2 100644 --- a/server/jellyfin/e2e/playlists_test.go +++ b/server/jellyfin/e2e/playlists_test.go @@ -46,6 +46,20 @@ var _ = Describe("Playlists", func() { plID := createPlaylist("From Artist", []string{enc(artistID("The Beatles"))}) Expect(playlistItems(plID).TotalRecordCount).To(Equal(3)) // Abbey Road (2) + Help! (1) }) + + // dto.DecodeIDs is all-or-nothing: a malformed entry must 404 the whole request, not get + // dropped while the well-formed entries are still used to create a playlist. + It("404s when one of the Ids is malformed, without creating a playlist", func() { + before, err := ds.Playlist(ctx).CountAll() + Expect(err).ToNot(HaveOccurred()) + + body := `{"Name":"ShouldNotExist","Ids":["` + enc(songID("So What")) + `","not-a-valid-id"]}` + Expect(post("/Playlists", body).Code).To(Equal(http.StatusNotFound)) + + after, err := ds.Playlist(ctx).CountAll() + Expect(err).ToNot(HaveOccurred()) + Expect(after).To(Equal(before)) + }) }) Describe("items", func() { @@ -81,6 +95,13 @@ var _ = Describe("Playlists", func() { Expect(playlistItems(plID).TotalRecordCount).To(Equal(3)) }) + It("404s when one of the ids to add is malformed, without adding any track", func() { + plID := createPlaylist("AddMalformed", nil) + url := "/Playlists/" + enc(plID) + "/Items?ids=" + enc(songID("So What")) + ",not-a-valid-id" + Expect(post(url, "").Code).To(Equal(http.StatusNotFound)) + Expect(playlistItems(plID).TotalRecordCount).To(BeZero()) + }) + 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 @@ -103,7 +124,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(enc("admin-1"))) + Expect(perms[0].UserId).To(Equal(enc(testID("admin-1")))) Expect(perms[0].CanEdit).To(BeTrue()) }) }) @@ -135,12 +156,12 @@ var _ = Describe("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"))) + Expect(q.Items[0].Id).To(Equal(dto.PlaylistsFolderGUID)) }) 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"))) + q := queryResult(get("/Items?parentId=" + dto.PlaylistsFolderGUID)) Expect(names(q.Items)).To(ContainElement("My Mix")) Expect(q.Items[0].Type).To(Equal("Playlist")) // Jellify keeps only playlists whose Path contains "data". @@ -149,10 +170,10 @@ var _ = Describe("Playlists", func() { It("resolves the synthetic playlists folder by its own advertised id", func() { var item dto.BaseItemDto - parseInto(get("/Items/"+enc("playlists")), &item) + parseInto(get("/Items/"+dto.PlaylistsFolderGUID), &item) Expect(item.Type).To(Equal("ManualPlaylistsFolder")) Expect(item.CollectionType).To(Equal("playlists")) - Expect(item.Id).To(Equal(enc("playlists"))) + Expect(item.Id).To(Equal(dto.PlaylistsFolderGUID)) }) }) @@ -239,6 +260,15 @@ var _ = Describe("Playlists", func() { }) Describe("update", func() { + It("404s when one of the replacement Ids is malformed, leaving the track list unchanged", func() { + plID := createPlaylist("UpdateMalformed", []string{enc(songID("Come Together"))}) + body := `{"Ids":["` + enc(songID("So What")) + `","not-a-valid-id"]}` + Expect(post("/Playlists/"+enc(plID), body).Code).To(Equal(http.StatusNotFound)) + q := playlistItems(plID) + Expect(q.TotalRecordCount).To(Equal(1)) + Expect(names(q.Items)).To(ConsistOf("Come Together")) + }) + 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)) @@ -295,6 +325,20 @@ var _ = Describe("Playlists", func() { 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)) }) + + // An id that decodes to "" would tell Create to make a new playlist instead of updating one — + // itemIDParam must 404 before that decode ever runs, not silently create one. + It("404s for a malformed playlist id, without creating a playlist", func() { + before, err := ds.Playlist(ctx).CountAll() + Expect(err).ToNot(HaveOccurred()) + + w := post("/Playlists/00000000000000000000000000000000", `{"Ids":["`+enc(songID("So What"))+`"]}`) + Expect(w.Code).To(Equal(http.StatusNotFound)) + + after, err := ds.Playlist(ctx).CountAll() + Expect(err).ToNot(HaveOccurred()) + Expect(after).To(Equal(before)) + }) }) Describe("delete", func() { diff --git a/server/jellyfin/e2e/search_test.go b/server/jellyfin/e2e/search_test.go index 6c26569fc..fa14f6811 100644 --- a/server/jellyfin/e2e/search_test.go +++ b/server/jellyfin/e2e/search_test.go @@ -3,6 +3,7 @@ package e2e import ( "fmt" + "github.com/navidrome/navidrome/server/jellyfin/dto" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -12,7 +13,7 @@ import ( var _ = Describe("Search", func() { BeforeEach(func() { setupTestDB() }) - lib1 := func() string { return enc("1") } // Library id 1 encodes to "31" + lib1 := func() string { return dto.EncodeLibraryID(1) } Describe("artists", func() { It("searches all album artists", func() { diff --git a/server/jellyfin/e2e/similar_test.go b/server/jellyfin/e2e/similar_test.go index 43ef857fa..5f1a88a21 100644 --- a/server/jellyfin/e2e/similar_test.go +++ b/server/jellyfin/e2e/similar_test.go @@ -1,6 +1,8 @@ package e2e import ( + "net/http" + "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -12,7 +14,7 @@ var _ = Describe("Similar", func() { 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: testID("z"), Name: "Led Zeppelin"}, {ID: "", Name: "Not In Library"}, // no id -> not present -> excluded } q := queryResult(get("/Artists/" + enc(artistID("The Beatles")) + "/Similar")) @@ -25,11 +27,18 @@ var _ = Describe("Similar", func() { Expect(q.Items).To(BeEmpty()) Expect(q.TotalRecordCount).To(Equal(0)) }) + + // Unlike an unresolvable-but-well-formed id (empty result above), a malformed itemId never + // reaches provider lookup at all — itemIDParam rejects it first. + It("404s a malformed itemId", func() { + w := get("/Artists/not-a-valid-id/Similar") + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) }) 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}} + providerFake.similarSongs = model.MediaFiles{{ID: testID("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")) @@ -37,8 +46,8 @@ var _ = Describe("Similar", func() { 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 + {ID: testID("x1"), Title: "In Library", LibraryID: 1}, + {ID: testID("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")) @@ -46,9 +55,9 @@ var _ = Describe("Similar", func() { 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")}, + {ID: testID("x1"), AlbumID: albumID("IV")}, + {ID: testID("x2"), AlbumID: albumID("IV")}, // same album -> counted once + {ID: testID("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"})) @@ -60,27 +69,32 @@ var _ = Describe("Similar", func() { // 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} + otherAlbum := model.Album{ID: testID("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 + {ID: testID("x1"), AlbumID: albumID("IV")}, // library 1 -> visible + {ID: testID("x2"), AlbumID: testID("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")) + q := queryResult(get("/Items/" + enc(testID("does-not-exist")) + "/Similar")) Expect(q.Items).To(BeEmpty()) }) + + It("404s a malformed itemId", func() { + w := get("/Items/not-a-valid-id/Similar") + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) }) // 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}} + providerFake.similarSongs = model.MediaFiles{{ID: testID("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")) @@ -89,7 +103,7 @@ var _ = Describe("Similar", func() { 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}, + {ID: testID("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"})) @@ -97,9 +111,9 @@ var _ = Describe("Similar", func() { 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}, + {ID: testID("x1"), Title: "S1", LibraryID: 1}, + {ID: testID("x2"), Title: "S2", LibraryID: 1}, + {ID: testID("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"})) @@ -107,21 +121,21 @@ var _ = Describe("Similar", func() { 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}, + {ID: testID("x1"), Title: "In Library", LibraryID: 1}, + {ID: testID("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}} + providerFake.similarSongs = model.MediaFiles{{ID: testID("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") + w := get("/Items/" + enc(testID("does-not-exist")) + "/InstantMix") Expect(w.Code).To(Equal(200)) Expect(queryResult(w).Items).To(BeEmpty()) }) @@ -130,5 +144,10 @@ var _ = Describe("Similar", func() { q := queryResult(get("/Items/" + enc(songID("Help!")) + "/InstantMix")) Expect(names(q.Items)).To(Equal([]string{"Help!"})) }) + + It("404s a malformed itemId", func() { + w := get("/Items/not-a-valid-id/InstantMix") + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) }) }) diff --git a/server/jellyfin/e2e/streaming_test.go b/server/jellyfin/e2e/streaming_test.go index c096d904a..954981910 100644 --- a/server/jellyfin/e2e/streaming_test.go +++ b/server/jellyfin/e2e/streaming_test.go @@ -43,7 +43,7 @@ var _ = Describe("Streaming", func() { }) It("returns 404 for an unknown track", func() { - Expect(get("/Audio/" + enc("nope") + "/stream").Code).To(Equal(http.StatusNotFound)) + Expect(get("/Audio/" + enc(testID("nope")) + "/stream").Code).To(Equal(http.StatusNotFound)) }) }) @@ -98,7 +98,7 @@ var _ = Describe("Streaming", func() { 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()) + Expect(info.PlaySessionId).To(MatchRegexp("^[0-9a-f]{32}$")) }) It("returns a MediaSource via POST", func() { diff --git a/server/jellyfin/images.go b/server/jellyfin/images.go index b844e7c74..65f02d7a0 100644 --- a/server/jellyfin/images.go +++ b/server/jellyfin/images.go @@ -14,14 +14,12 @@ import ( "strconv" "github.com/dustin/go-humanize" - "github.com/go-chi/chi/v5" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/imghttp" - "github.com/navidrome/navidrome/server/jellyfin/dto" _ "golang.org/x/image/webp" ) @@ -29,7 +27,10 @@ func (api *Router) getItemImage(w http.ResponseWriter, r *http.Request) { // Public endpoint, like real Jellyfin's image routes: clients fetch cover URLs without credentials // and item ids are unguessable, so resolution runs elevated to bypass the visibility filter. ctx := request.WithUser(r.Context(), model.User{IsAdmin: true}) - itemId := api.resolveItemID(ctx, dto.DecodeID(chi.URLParam(r, "itemId"))) + itemId, ok := itemIDParam(w, r, "itemId") + if !ok { + return + } size, _ := strconv.Atoi(r.URL.Query().Get("maxwidth")) artID := api.resolveArtworkID(ctx, itemId) @@ -84,11 +85,14 @@ func (api *Router) resolveArtworkID(ctx context.Context, itemId string) 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. +// from scanning. Past the auth and id gates the body is drained before answering — including on the +// not-implemented path — because Finamp writes it synchronously and would see a broken pipe. func (api *Router) postItemImage(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - id := dto.DecodeID(chi.URLParam(r, "itemId")) + id, ok := itemIDParam(w, r, "itemId") + if !ok { + return + } // Honor the same artwork-upload gate and size cap as the native endpoint. u, _ := request.UserFrom(ctx) @@ -143,7 +147,10 @@ func (api *Router) postItemImage(w http.ResponseWriter, r *http.Request) { // 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")) + id, ok := itemIDParam(w, r, "itemId") + if !ok { + return + } if _, err := api.playlists.Get(ctx, id); err != nil { http.Error(w, "Not Implemented", http.StatusNotImplemented) diff --git a/server/jellyfin/images_test.go b/server/jellyfin/images_test.go index bdd47e956..0e0d6220d 100644 --- a/server/jellyfin/images_test.go +++ b/server/jellyfin/images_test.go @@ -63,16 +63,29 @@ func newImageRequest(itemId string) (*httptest.ResponseRecorder, *http.Request) 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"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) fa := &fakeArtwork{} api := &Router{ds: ds, artwork: fa} - w, r := newImageRequest(dto.EncodeID("a1")) + w, r := newImageRequest(dto.EncodeID(testID("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")) + Expect(fa.recvId).To(ContainSubstring(testID("a1"))) + }) + + // A malformed itemId now 404s via itemIDParam instead of falling through to a placeholder image. + It("404s a malformed itemId instead of serving a placeholder", func() { + ds := &tests.MockDataStore{} + fa := &fakeArtwork{} + api := &Router{ds: ds, artwork: fa} + + w, r := newImageRequest("not-a-valid-id") + api.getItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(fa.recvId).To(BeEmpty(), "artwork resolution must not run for an undecodable id") }) // resolveArtworkID probes the entity tables, so a deleted item yields no artwork id at all. @@ -81,22 +94,23 @@ var _ = Describe("Images", func() { fa := &fakeArtwork{} api := &Router{ds: ds, artwork: fa} - w, r := newImageRequest(dto.EncodeID("deleted-item")) + w, r := newImageRequest(dto.EncodeID(testID("deleted-item"))) api.getItemImage(w, r) + // 200 here is what separates a well-formed unknown id from a malformed one, which 404s. + Expect(w.Code).To(Equal(http.StatusOK)) Expect(fa.recvId).To(BeEmpty(), "an empty artwork id can only yield a placeholder") - Expect(w.Body.String()).ToNot(ContainSubstring("deleted-item")) }) 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"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("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")) + w, r := newImageRequest(dto.EncodeID(testID("a1"))) api.getItemImage(w, r) Expect(w.Code).To(Equal(http.StatusOK)) @@ -105,15 +119,15 @@ var _ = Describe("Images", func() { It("resolves a playlist's cover regardless of visibility, even for an anonymous caller", func() { ds := &tests.MockDataStore{} - ds.Playlist(context.Background()).(*tests.MockPlaylistRepo).SetData(model.Playlists{{ID: "pl1", Name: "Mix", OwnerID: "someone"}}) + ds.Playlist(context.Background()).(*tests.MockPlaylistRepo).SetData(model.Playlists{{ID: testID("pl1"), Name: "Mix", OwnerID: testID("someone")}}) fa := &fakeArtwork{} api := &Router{ds: ds, artwork: fa} - w, r := newImageRequest(dto.EncodeID("pl1")) + w, r := newImageRequest(dto.EncodeID(testID("pl1"))) api.getItemImage(w, r) Expect(w.Code).To(Equal(http.StatusOK)) - Expect(fa.recvId).To(ContainSubstring("pl1")) + Expect(fa.recvId).To(ContainSubstring(testID("pl1"))) }) // This endpoint is public (no user in the request), so artwork must be resolved under an @@ -121,11 +135,11 @@ var _ = Describe("Images", func() { // 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"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) fa := &fakeArtwork{} api := &Router{ds: ds, artwork: fa} - w, r := newImageRequest(dto.EncodeID("a1")) + w, r := newImageRequest(dto.EncodeID(testID("a1"))) api.getItemImage(w, r) Expect(w.Code).To(Equal(http.StatusOK)) @@ -137,11 +151,11 @@ var _ = Describe("Images", func() { It("serves immutable when the tag param asserts the current hash", func() { const hash = "0123456789abcdef" ds := &tests.MockDataStore{} - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) fa := &fakeArtwork{hash: hash} api := &Router{ds: ds, artwork: fa} - w, r := newImageRequest(dto.EncodeID("a1")) + w, r := newImageRequest(dto.EncodeID(testID("a1"))) q := r.URL.Query() q.Set("tag", hash) r.URL.RawQuery = q.Encode() @@ -155,11 +169,11 @@ var _ = Describe("Images", func() { It("revalidates via no-cache when no tag is provided", func() { const hash = "0123456789abcdef" ds := &tests.MockDataStore{} - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) fa := &fakeArtwork{hash: hash} api := &Router{ds: ds, artwork: fa} - w, r := newImageRequest(dto.EncodeID("a1")) + w, r := newImageRequest(dto.EncodeID(testID("a1"))) api.getItemImage(w, r) Expect(w.Code).To(Equal(http.StatusOK)) @@ -199,21 +213,21 @@ var _ = Describe("postItemImage", func() { var fp *fakePlaylists BeforeEach(func() { - fp = &fakePlaylists{getByIDPls: &model.Playlist{ID: "pl1"}} + fp = &fakePlaylists{getByIDPls: &model.Playlist{ID: testID("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 := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(body)) r.Header.Set("Content-Type", "image/jpeg") - r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) api.postItemImage(w, r) Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(fp.setImagePlaylistID).To(Equal("pl1")) + Expect(fp.setImagePlaylistID).To(Equal(testID("pl1"))) Expect(fp.setImageBytes).To(Equal(body)) Expect(fp.setImageExt).To(Equal(".jpeg")) }) @@ -222,9 +236,9 @@ var _ = Describe("postItemImage", 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 := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("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")) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) api.postItemImage(w, r) @@ -238,9 +252,9 @@ var _ = Describe("postItemImage", func() { 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 := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("al1"))+"/Images/Primary", bodyReader) r.Header.Set("Content-Type", "image/jpeg") - r = withChiURLParam(r, "itemId", dto.EncodeID("al1")) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("al1"))) api.postItemImage(w, r) @@ -251,9 +265,9 @@ var _ = Describe("postItemImage", func() { 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 := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(jpegBytes())) r.Header.Set("Content-Type", "image/jpeg") - r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) api.postItemImage(w, r) @@ -263,9 +277,9 @@ var _ = Describe("postItemImage", func() { 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 := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(body)) r.Header.Set("Content-Type", "image/webp") - r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) api.postItemImage(w, r) @@ -277,9 +291,9 @@ var _ = Describe("postItemImage", func() { 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 := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(body)) r.Header.Set("Content-Type", "image/gif") - r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) api.postItemImage(w, r) @@ -292,9 +306,9 @@ var _ = Describe("postItemImage", 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 := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(jpegBytes())) r.Header.Set("Content-Type", "image/jpeg") - r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) api.postItemImage(w, r) @@ -310,9 +324,9 @@ var _ = Describe("postItemImage", func() { 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 := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(body)) r.Header.Set("Content-Type", "image/png") - r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) api.postItemImage(w, r) @@ -326,9 +340,9 @@ var _ = Describe("postItemImage", func() { 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 := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(body)) r.Header.Set("Content-Type", "image/png") - r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) api.postItemImage(w, r) @@ -338,9 +352,9 @@ var _ = Describe("postItemImage", func() { 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 := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", strings.NewReader("!!not base64!!")) r.Header.Set("Content-Type", "image/jpeg") - r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) api.postItemImage(w, r) @@ -351,9 +365,9 @@ var _ = Describe("postItemImage", func() { 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 := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(body)) r.Header.Set("Content-Type", "image/jpeg") - r = withChiURLParam(r, "itemId", dto.EncodeID("pl1")) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) api.postItemImage(w, r) @@ -365,10 +379,10 @@ var _ = Describe("postItemImage", 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 := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("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})) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) + r = r.WithContext(request.WithUser(r.Context(), model.User{ID: testID("u1"), IsAdmin: false})) api.postItemImage(w, r) @@ -380,10 +394,10 @@ var _ = Describe("postItemImage", 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 := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("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})) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) + r = r.WithContext(request.WithUser(r.Context(), model.User{ID: testID("admin"), IsAdmin: true})) api.postItemImage(w, r) @@ -393,24 +407,24 @@ var _ = Describe("postItemImage", func() { var _ = Describe("deleteItemImage", func() { It("removes the playlist image and returns 204", func() { - fp := &fakePlaylists{getByIDPls: &model.Playlist{ID: "pl1"}} + fp := &fakePlaylists{getByIDPls: &model.Playlist{ID: testID("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")) + r := httptest.NewRequest("DELETE", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", nil) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) api.deleteItemImage(w, r) Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(fp.removeImagePlaylistID).To(Equal("pl1")) + Expect(fp.removeImagePlaylistID).To(Equal(testID("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")) + r := httptest.NewRequest("DELETE", "/Items/"+dto.EncodeID(testID("al1"))+"/Images/Primary", nil) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("al1"))) api.deleteItemImage(w, r) @@ -418,11 +432,11 @@ var _ = Describe("deleteItemImage", func() { }) It("returns 500 when the service fails", func() { - fp := &fakePlaylists{getByIDPls: &model.Playlist{ID: "pl1"}, removeImageErr: errors.New("boom")} + fp := &fakePlaylists{getByIDPls: &model.Playlist{ID: testID("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")) + r := httptest.NewRequest("DELETE", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", nil) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) api.deleteItemImage(w, r) diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go index fed5bbbc8..de0da88eb 100644 --- a/server/jellyfin/items.go +++ b/server/jellyfin/items.go @@ -2,6 +2,7 @@ package jellyfin import ( "context" + "errors" "io" "iter" "net/http" @@ -10,7 +11,6 @@ import ( "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" @@ -34,6 +34,10 @@ func searchTerm(p *req.Values) string { func (api *Router) getItems(w http.ResponseWriter, r *http.Request) { res, err := api.queryItems(r.Context(), r) if err != nil { + if errors.Is(err, model.ErrNotFound) { + http.Error(w, "Not Found", http.StatusNotFound) + return + } api.internalError(w, r, err) return } @@ -229,12 +233,37 @@ type itemsQuery struct { // 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 { +// normalizeQueryKeys folded them (Jellyfin binds case-insensitively). A non-empty id param that +// fails to decode reports model.ErrNotFound rather than silently dropping the filter (see decodeFilterParam). +func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) (itemsQuery, error) { p := req.Params(r) + parentId, ok := decodeFilterParam(p.StringOr("parentid", "")) + if !ok { + return itemsQuery{}, model.ErrNotFound + } + // Any malformed entry in one of these id lists must 404, not silently drop out of the filter + // (see dto.DecodeIDs) — an all-malformed list would otherwise widen the query to everything. + ids, ok := decodedQueryIDs(r, "ids") + if !ok { + return itemsQuery{}, model.ErrNotFound + } + // Finamp's genre screen sends ParentId=<libraryId> for scoping plus GenreIds for the genre. + genreIds, ok := decodedQueryIDs(r, "genreids") + if !ok { + return itemsQuery{}, model.ErrNotFound + } + // Feishin fetches an album's tracks with AlbumIds instead of ParentId. + albumIds, ok := decodedQueryIDs(r, "albumids") + if !ok { + return itemsQuery{}, model.ErrNotFound + } + studioIds, ok := decodedQueryIDs(r, "studioids") + if !ok { + return itemsQuery{}, model.ErrNotFound + } q := itemsQuery{ fields: dto.ParseFields(p.Strings("fields")...), - ids: decodedQueryIDs(r, "ids"), + ids: ids, rawTypes: p.StringOr("includeitemtypes", ""), search: searchTerm(p), sortBy: p.StringOr("sortby", ""), @@ -243,20 +272,22 @@ func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) itemsQu 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=<libraryId> for scoping plus GenreIds for the genre. - genreIds: decodedQueryIDs(r, "genreids"), - // Feishin fetches an album's tracks with AlbumIds instead of ParentId. - albumIds: decodedQueryIDs(r, "albumids"), + favOnly: strings.Contains(p.StringOr("filters", ""), "IsFavorite") || p.BoolOr("isfavorite", false), + parentId: parentId, + genreIds: genreIds, + albumIds: albumIds, years: parseYears(r), - studioIds: decodedQueryIDs(r, "studioids"), + studioIds: studioIds, } // An artist's page filters by artist, not ParentId: Finamp sends ParentId=<libraryId> 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)) + artistId, ok := firstDecodedID(firstNonEmpty(albumArtistScope, contributingScope)) + if !ok { + return itemsQuery{}, model.ErrNotFound + } + q.artistId = artistId q.contributingOnly = albumArtistScope == "" && contributingScope != "" q.types = parseTypes(q.rawTypes) @@ -272,7 +303,7 @@ func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) itemsQu // (Jellify opens albums this way). An artist parent keeps parseTypes' MusicAlbum default (browse // its albums). if q.rawTypes == "" && q.parentId != "" && !q.isLibraryParent { - if q.parentId == playlistsFolderID { + if q.parentId == dto.PlaylistsFolderID { // Browsing into the synthetic playlists folder lists the user's playlists. q.types = []string{"Playlist"} } else if _, err := api.ds.Album(ctx).Get(q.parentId); err == nil { @@ -285,14 +316,17 @@ func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) itemsQu if q.isLibraryParent || len(q.types) > 1 { q.entityParent = "" } - return q + return q, nil } // 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) + q, err := api.parseItemsQuery(ctx, r) + if err != nil { + return itemsResult{}, err + } switch { // /Items?ids= is a batch-fetch-by-id that bypasses the type dispatch. case len(q.ids) > 0: @@ -322,7 +356,7 @@ func (api *Router) queryItems(ctx context.Context, r *http.Request) (itemsResult // 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 { + if q.parentId == "" || q.isLibraryParent || q.parentId == dto.PlaylistsFolderID { return nil, false } // Tracks enforces visibility. @@ -450,18 +484,20 @@ func firstNonEmpty(vals ...string) string { return "" } -// firstDecodedID decodes the first id from a (possibly comma-separated) Jellyfin id list. -func firstDecodedID(s string) string { +// firstDecodedID decodes the first id from a (possibly comma-separated) Jellyfin id list, reporting +// whether it decoded successfully (see decodeFilterParam). +func firstDecodedID(s string) (string, bool) { if s == "" { - return "" + return "", true } first, _, _ := strings.Cut(s, ",") - return dto.DecodeID(strings.TrimSpace(first)) + return decodeFilterParam(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) +// decodedQueryIDs reads an id-list param in both client spellings (see queryIDs). ok is false if +// any entry is malformed, so a dropped entry can't shrink the list into an empty, no-op filter. +func decodedQueryIDs(r *http.Request, key string) ([]string, bool) { + return dto.DecodeIDs(queryIDs(r, key)) } // parseYears reads Years= as a discrete list, accepting comma-separated and repeated params. @@ -740,7 +776,7 @@ func (api *Router) listPlaylists(ctx context.Context, opts model.QueryOptions, q // 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 { + if id == dto.PlaylistsFolderID { return playlistsFolder(), true } u, _ := request.UserFrom(ctx) @@ -802,14 +838,11 @@ func (api *Router) songsByIDs(ctx context.Context, ids []string) map[string]mode } // 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) + songs := api.songsByIDs(ctx, ids) var items []dto.BaseItemDto - for i, id := range fullIDs { + for _, id := range ids { var item dto.BaseItemDto if mf, ok := songs[id]; ok { if !u.HasLibraryAccess(mf.LibraryID) { @@ -819,16 +852,16 @@ func (api *Router) itemsByIDs(ctx context.Context, ids []string, fields dto.Fiel } 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"))) + id, ok := itemIDParam(w, r, "itemId") + if !ok { + return + } fields := dto.ParseFields(req.Params(r).Strings("fields")...) if item, ok := api.resolveItemByID(r.Context(), id, fields); ok { api.ok(w, r, item) @@ -841,7 +874,10 @@ func (api *Router) getItem(w http.ResponseWriter, r *http.Request) { // 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")) + id, ok := itemIDParam(w, r, "itemId") + if !ok { + return + } if err := api.playlists.Delete(ctx, id); err != nil { api.playlistError(w, r, err) return diff --git a/server/jellyfin/items_test.go b/server/jellyfin/items_test.go index 3213379e5..b227ab719 100644 --- a/server/jellyfin/items_test.go +++ b/server/jellyfin/items_test.go @@ -6,6 +6,8 @@ import ( "fmt" "net/http" "net/http/httptest" + "slices" + "strings" "github.com/go-chi/chi/v5" "github.com/navidrome/navidrome/model" @@ -30,14 +32,14 @@ var _ = Describe("Items", func() { 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"}}}) + return request.WithUser(context.Background(), model.User{ID: testID("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}) + return request.WithUser(context.Background(), model.User{ID: testID("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}) + return request.WithUser(context.Background(), model.User{ID: testID("admin"), IsAdmin: true, Libraries: nil}) } BeforeEach(func() { ds = &tests.MockDataStore{} @@ -47,7 +49,7 @@ var _ = Describe("Items", func() { 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"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}, {ID: testID("a2"), Name: "Two"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&Recursive=true", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -60,44 +62,44 @@ var _ = Describe("Items", func() { }) 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"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", AlbumID: testID("a1")}}) w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("a1")+"&IncludeItemTypes=Audio", nil).WithContext(ctxUser()) + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID(testID("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"))) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("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"}}, + fp.getPls = &model.Playlist{ID: testID("pl1"), Tracks: model.PlaylistTracks{ + {ID: "1", MediaFileID: testID("s1"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s1")}}, + {ID: "2", MediaFileID: testID("s2"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s2")}}, }} w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("pl1")+"&IncludeItemTypes=Audio", nil). + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID(testID("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.Items[0].Id).To(Equal(dto.EncodeID(testID("s1")))) + Expect(res.Items[0].PlaylistItemId).To(Equal(dto.EncodePlaylistEntryID("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"}}, + fp.getPls = &model.Playlist{ID: testID("pl1"), Tracks: model.PlaylistTracks{ + {ID: "1", MediaFileID: testID("s1"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s1")}}, + {ID: "2", MediaFileID: testID("s2"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s2")}}, + {ID: "3", MediaFileID: testID("s3"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s3")}}, }} w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("pl1")+"&StartIndex=1&Limit=1", nil). + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID(testID("pl1"))+"&StartIndex=1&Limit=1", nil). WithContext(ctxUser()) invoke(api.getItems, w, r) Expect(w.Code).To(Equal(http.StatusOK)) @@ -105,24 +107,24 @@ var _ = Describe("Items", func() { 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(res.Items[0].Id).To(Equal(dto.EncodeID(testID("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"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), AlbumID: testID("a1")}}) w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("a1")+"&IncludeItemTypes=Audio", nil). + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID(testID("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"))) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s1")))) }) It("returns 500 when the song cursor fails to open, instead of a truncated 200", func() { @@ -137,13 +139,13 @@ var _ = Describe("Items", func() { // 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"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), AlbumID: testID("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). + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeLibraryID(1)+"&IncludeItemTypes=Audio&Recursive=false", nil). WithContext(ctxUser()) invoke(api.getItems, w, r) Expect(w.Code).To(Equal(http.StatusOK)) @@ -155,7 +157,7 @@ var _ = Describe("Items", func() { 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). + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeLibraryID(1)+"&IncludeItemTypes=Audio,MusicAlbum&Recursive=false", nil). WithContext(ctxUser()) invoke(api.getItems, w, r) var res dto.QueryResult @@ -166,7 +168,7 @@ var _ = Describe("Items", func() { 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). + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeLibraryID(1)+"&IncludeItemTypes=MusicAlbum&Recursive=false", nil). WithContext(ctxUser()) invoke(api.getItems, w, r) var res dto.QueryResult @@ -177,13 +179,13 @@ var _ = Describe("Items", func() { 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). + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID(testID("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"))) + Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s1")))) }) It("keeps returning every song when no parent scopes the query", func() { @@ -199,7 +201,7 @@ var _ = Describe("Items", func() { // `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). + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeLibraryID(1)+"&IncludeItemTypes=Audio", nil). WithContext(ctxUser()) invoke(api.getItems, w, r) var res dto.QueryResult @@ -209,9 +211,9 @@ var _ = Describe("Items", func() { }) 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"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", AlbumArtistID: testID("ar1")}}) w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID("ar1")+"&IncludeItemTypes=MusicAlbum", nil).WithContext(ctxUser()) + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID(testID("ar1"))+"&IncludeItemTypes=MusicAlbum", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) var res dto.QueryResult Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) @@ -223,7 +225,7 @@ var _ = Describe("Items", func() { }) It("lists artists when IncludeItemTypes=MusicArtist", func() { - ds.Artist(context.Background()).(*tests.MockArtistRepo).SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) + ds.Artist(context.Background()).(*tests.MockArtistRepo).SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicArtist", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -244,7 +246,7 @@ var _ = Describe("Items", func() { }) It("lists playlists when IncludeItemTypes=Playlist", func() { - ds.Playlist(context.Background()).(*tests.MockPlaylistRepo).SetData(model.Playlists{{ID: "p1", Name: "My Mix", SongCount: 5}}) + ds.Playlist(context.Background()).(*tests.MockPlaylistRepo).SetData(model.Playlists{{ID: testID("p1"), Name: "My Mix", SongCount: 5}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Playlist", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -253,13 +255,13 @@ var _ = Describe("Items", func() { 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.Items[0].Id).To(Equal(dto.EncodeID(testID("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"}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -274,11 +276,11 @@ var _ = Describe("Items", func() { It("merges favorite songs, albums, and playlists", func() { mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: "s1", Title: "Song"}}) + mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) playlistRepo := ds.Playlist(context.Background()).(*tests.MockPlaylistRepo) - playlistRepo.SetData(model.Playlists{{ID: "p1", Name: "My Mix", Annotations: model.Annotations{Starred: true}}}) + playlistRepo.SetData(model.Playlists{{ID: testID("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) @@ -298,8 +300,8 @@ var _ = Describe("Items", func() { }) 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"}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}, {ID: testID("s2"), Title: "Song2"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}, {ID: testID("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) @@ -313,9 +315,9 @@ var _ = Describe("Items", func() { 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"}}) + mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}, {ID: testID("s2"), Title: "Song2"}}) albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}, {ID: "a2", Name: "Two"}}) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}, {ID: testID("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) @@ -327,7 +329,7 @@ var _ = Describe("Items", func() { It("applies a starred filter when Filters=IsFavorite", func() { albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&Filters=IsFavorite", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -339,7 +341,7 @@ var _ = Describe("Items", func() { 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"}}) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=one", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -351,7 +353,7 @@ var _ = Describe("Items", func() { It("caps a search the client left unbounded", func() { albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=one", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -361,7 +363,7 @@ var _ = Describe("Items", func() { 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"}}) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=one&Limit=500", nil). WithContext(ctxUser()) @@ -372,7 +374,7 @@ var _ = Describe("Items", func() { 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"}}) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=one&Limit=999999", nil). WithContext(ctxUser()) @@ -383,7 +385,7 @@ var _ = Describe("Items", func() { 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"}}) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}, {ID: testID("a2"), Name: "Two"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=%20%20", nil). WithContext(ctxUser()) @@ -398,10 +400,10 @@ var _ = Describe("Items", func() { 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"} + songs[i] = model.MediaFile{ID: testID(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"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&Limit=10", nil). WithContext(ctxUser()) @@ -415,8 +417,8 @@ var _ = Describe("Items", func() { 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"}}) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&StartIndex=500000&Limit=1", nil). WithContext(ctxUser()) @@ -431,10 +433,10 @@ var _ = Describe("Items", func() { // 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"} + songs[i] = model.MediaFile{ID: testID(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"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", fmt.Sprintf("/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&StartIndex=%d&Limit=1", maxSearchLimit), @@ -450,10 +452,12 @@ var _ = Describe("Items", func() { 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"} + songs[i] = model.MediaFile{ID: testID(fmt.Sprintf("s%05d", i)), Title: "Song"} } + // The mock repo returns rows sorted by ID; reorder to match so index-based assertions hold. + slices.SortFunc(songs, func(a, b model.MediaFile) int { return strings.Compare(a.ID, b.ID) }) ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(songs) - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", fmt.Sprintf("/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&StartIndex=%d&Limit=10", maxSearchLimit-1), @@ -470,10 +474,10 @@ var _ = Describe("Items", func() { 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"} + songs[i] = model.MediaFile{ID: testID(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"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song", nil). WithContext(ctxUser()) @@ -487,10 +491,12 @@ var _ = Describe("Items", func() { 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"} + songs[i] = model.MediaFile{ID: testID(fmt.Sprintf("s%05d", i)), Title: "Song"} } + // The mock repo returns rows sorted by ID; reorder to match so index-based assertions hold. + slices.SortFunc(songs, func(a, b model.MediaFile) int { return strings.Compare(a.ID, b.ID) }) ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(songs) - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", fmt.Sprintf("/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&StartIndex=%d", defaultSearchLimit+50), @@ -506,7 +512,7 @@ var _ = Describe("Items", func() { 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"}, + {ID: testID("r1"), Name: "Alpha"}, {ID: testID("r2"), Name: "Beta"}, {ID: testID("r3"), Name: "Gamma"}, }) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicArtist&SearchTerm=a&Limit=1", nil).WithContext(ctxUser()) @@ -520,7 +526,7 @@ var _ = Describe("Items", func() { It("forwards StartIndex/Limit as Offset/Max", func() { albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + albumRepo.SetData(model.Albums{{ID: testID("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) @@ -533,31 +539,31 @@ var _ = Describe("Items", func() { // Finamp's download/sync fetches a track's BaseItemDto via /Items?ids=<id>; 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}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}}) w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?Ids="+dto.EncodeID("s1"), nil).WithContext(ctxUser()) + r := httptest.NewRequest("GET", "/Items?Ids="+dto.EncodeID(testID("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].Id).To(Equal(dto.EncodeID(testID("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}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}}) w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?ids="+dto.EncodeID("a1")+","+dto.EncodeID("s1"), nil).WithContext(ctxUser()) + r := httptest.NewRequest("GET", "/Items?ids="+dto.EncodeID(testID("a1"))+","+dto.EncodeID(testID("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"))) + Expect(ids).To(ConsistOf(dto.EncodeID(testID("a1")), dto.EncodeID(testID("s1")))) types := []string{res.Items[0].Type, res.Items[1].Type} Expect(types).To(ConsistOf("MusicAlbum", "Audio")) Expect(res.TotalRecordCount).To(Equal(2)) @@ -565,9 +571,9 @@ var _ = Describe("Items", func() { 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}}) + mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}, {ID: testID("s2"), Title: "Song2", LibraryID: 1}}) w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?ids="+dto.EncodeID("s1")+","+dto.EncodeID("s2"), nil).WithContext(ctxUser()) + r := httptest.NewRequest("GET", "/Items?ids="+dto.EncodeID(testID("s1"))+","+dto.EncodeID(testID("s2")), nil).WithContext(ctxUser()) invoke(api.getItems, w, r) Expect(w.Code).To(Equal(http.StatusOK)) var res dto.QueryResult @@ -576,20 +582,20 @@ var _ = Describe("Items", func() { 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")) + Expect(args).To(ConsistOf(testID("s1"), testID("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 + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("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()) + r := httptest.NewRequest("GET", "/Items?Ids="+dto.EncodeID(testID("a1"))+","+dto.EncodeID(testID("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.Items[0].Id).To(Equal(dto.EncodeID(testID("a1")))) Expect(res.TotalRecordCount).To(Equal(1)) }) }) @@ -597,7 +603,7 @@ var _ = Describe("Items", func() { 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"}}) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SortBy=PlayCount", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -607,7 +613,7 @@ var _ = Describe("Items", func() { 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"}}) + mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&SortBy=DatePlayed", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -617,7 +623,7 @@ var _ = Describe("Items", func() { 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"}}) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SortBy=DateCreated,SortName", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -627,7 +633,7 @@ var _ = Describe("Items", func() { 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"}}) + mfRepo.SetData(model.MediaFiles{{ID: testID("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) @@ -637,7 +643,7 @@ var _ = Describe("Items", func() { 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"}}) + mfRepo.SetData(model.MediaFiles{{ID: testID("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) @@ -647,7 +653,7 @@ var _ = Describe("Items", func() { 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"}}) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SortBy=SeriesSortName", nil).WithContext(ctxUser()) invoke(api.getItems, w, r) @@ -659,7 +665,7 @@ var _ = Describe("Items", func() { 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"}}) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) w := httptest.NewRecorder() libs := model.Libraries{{ID: 1}, {ID: 2}} r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum", nil).WithContext(ctxUserWithLibraries(libs)) @@ -673,7 +679,7 @@ var _ = Describe("Items", func() { 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"}}) + mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) w := httptest.NewRecorder() libs := model.Libraries{{ID: 1}, {ID: 2}} r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio", nil).WithContext(ctxUserWithLibraries(libs)) @@ -687,7 +693,7 @@ var _ = Describe("Items", func() { 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"}}) + artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) w := httptest.NewRecorder() libs := model.Libraries{{ID: 1}, {ID: 2}} r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicArtist", nil).WithContext(ctxUserWithLibraries(libs)) @@ -701,10 +707,10 @@ var _ = Describe("Items", func() { 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"}}) + albumRepo.SetData(model.Albums{{ID: testID("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)) + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeLibraryID(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() @@ -716,10 +722,10 @@ var _ = Describe("Items", func() { It("does not let ParentId=<inaccessible library id> scope results to that library", func() { albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: "a1", Name: "One"}}) + albumRepo.SetData(model.Albums{{ID: testID("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)) + r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeLibraryID(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() @@ -734,7 +740,7 @@ var _ = Describe("Items", func() { 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}}) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}, {ID: testID("a2"), Name: "Two", LibraryID: 2}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum", nil).WithContext(ctxAdmin()) invoke(api.getItems, w, r) @@ -750,10 +756,63 @@ var _ = Describe("Items", func() { }) }) + // A malformed id must 404, not silently drop the filter; a well-formed but unknown one must + // still reach the entity filter, not the unfiltered default. + Describe("stale and malformed id filtering", func() { + It("404s a malformed ParentId instead of listing every song", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&ParentId=not-a-valid-id", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("404s a malformed AlbumArtistIds instead of listing every album", func() { + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&AlbumArtistIds=not-a-valid-id", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("404s a malformed ArtistIds instead of listing every song", func() { + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&ArtistIds=not-a-valid-id", nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("still applies the artist filter (rather than dropping it) for a well-formed but unknown AlbumArtistIds", func() { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&AlbumArtistIds="+dto.EncodeID(testID("no-such-artist")), 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("album_artists")) + }) + + It("still applies the album filter (rather than dropping it) for a well-formed but unknown ParentId", func() { + mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) + mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&ParentId="+dto.EncodeID(testID("no-such-album")), nil).WithContext(ctxUser()) + 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("album_id")) + Expect(args).To(ContainElement(testID("no-such-album"))) + }) + }) + Describe("mixed IncludeItemTypes merge", func() { BeforeEach(func() { - ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}, {ID: "a2", Name: "Two"}}) - ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: "s1", Title: "S1"}, {ID: "s2", Title: "S2"}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}, {ID: testID("a2"), Name: "Two"}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "S1"}, {ID: testID("s2"), Title: "S2"}}) }) It("returns a mix of both types, not all of one", func() { @@ -815,54 +874,54 @@ var _ = Describe("Items", func() { 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}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("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")) + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("a1")), nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("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.Id).To(Equal(dto.EncodeID(testID("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") + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("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}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("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")) + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("a1")), nil).WithContext(ctxUser()) // only has access to library 1 + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("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}}) + ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("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")) + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("s1")), nil).WithContext(ctxUser()) // only has access to library 1 + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("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}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("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")) + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("a1")), nil).WithContext(ctxAdmin()) // admin, Libraries: nil + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("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.Id).To(Equal(dto.EncodeID(testID("a1")))) }) // Finamp fetches a /UserViews entry (Id=library id) as a plain item to resolve the @@ -870,13 +929,13 @@ var _ = Describe("Items", func() { 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")) + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeLibraryID(1), nil).WithContext(ctxUserWithLibraries(libs)) + r = withChiURLParam(r, "itemId", dto.EncodeLibraryID(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.Id).To(Equal(dto.EncodeLibraryID(1))) Expect(item.Name).To(Equal("Music Library")) Expect(item.Type).To(Equal("CollectionFolder")) Expect(item.CollectionType).To(Equal("music")) @@ -886,8 +945,8 @@ var _ = Describe("Items", func() { 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")) + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeLibraryID(1), nil).WithContext(ctxUserWithLibraries(libs)) + r = withChiURLParam(r, "itemId", dto.EncodeLibraryID(1)) invoke(api.getItem, w, r) Expect(w.Code).To(Equal(http.StatusNotFound)) }) @@ -895,15 +954,15 @@ var _ = Describe("Items", func() { // Finamp's SyncBuffer fetches a playlist by id as a plain item; without this probe it // 404s with "Could not fetch BaseItemDto <playlist> from server." It("resolves a playlist id via the playlists service", func() { - fp.getByIDPls = &model.Playlist{ID: "p1", Name: "My Mix", SongCount: 5} + fp.getByIDPls = &model.Playlist{ID: testID("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")) + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("p1")), nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("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.Id).To(Equal(dto.EncodeID(testID("p1")))) Expect(item.Name).To(Equal("My Mix")) Expect(item.Type).To(Equal("Playlist")) }) @@ -911,23 +970,23 @@ var _ = Describe("Items", func() { 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")) + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("p1")), nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("p1"))) invoke(api.getItem, w, r) Expect(w.Code).To(Equal(http.StatusNotFound)) }) // Finamp's genre "See all" fetches the genre by id; a 404 white-screens it (see resolveItemByID). It("resolves a genre id as a MusicGenre item", func() { - Expect(ds.Genre(context.Background()).(*tests.MockedGenreRepo).Put(&model.Genre{ID: "g1", Name: "Rock"})).To(Succeed()) + Expect(ds.Genre(context.Background()).(*tests.MockedGenreRepo).Put(&model.Genre{ID: testID("g1"), Name: "Rock"})).To(Succeed()) w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID("g1"), nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID("g1")) + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("g1")), nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("g1"))) 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("g1"))) + Expect(item.Id).To(Equal(dto.EncodeID(testID("g1")))) Expect(item.Name).To(Equal("Rock")) Expect(item.Type).To(Equal("MusicGenre")) }) @@ -935,13 +994,13 @@ var _ = Describe("Items", func() { 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")) + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeLibraryID(1), nil).WithContext(ctxAdmin()) + r = withChiURLParam(r, "itemId", dto.EncodeLibraryID(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.Id).To(Equal(dto.EncodeLibraryID(1))) Expect(item.Name).To(Equal("Music Library")) Expect(item.Type).To(Equal("CollectionFolder")) }) @@ -949,7 +1008,7 @@ var _ = Describe("Items", func() { 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}}) + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}}) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Users/u1/Items/Latest", nil).WithContext(ctxUser()) invoke(api.getLatest, w, r) @@ -957,12 +1016,12 @@ var _ = Describe("Items", func() { 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"))) + Expect(items[0].Id).To(Equal(dto.EncodeID(testID("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}}) + albumRepo.SetData(model.Albums{{ID: testID("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)) @@ -1014,4 +1073,47 @@ var _ = Describe("Items", func() { Expect(parseTypes("Audio,MusicAlbum,Audio")).To(Equal([]string{"Audio", "MusicAlbum"})) }) }) + + Describe("decodeFilterParam", func() { + It("reports ok for an absent param, decoding to \"\"", func() { + id, ok := decodeFilterParam("") + Expect(id).To(BeEmpty()) + Expect(ok).To(BeTrue()) + }) + + It("reports ok for a well-formed id, whether or not it exists", func() { + id, ok := decodeFilterParam(dto.EncodeID(testID("a1"))) + Expect(id).To(Equal(testID("a1"))) + Expect(ok).To(BeTrue()) + }) + + It("reports not ok for a non-empty param that fails to decode", func() { + id, ok := decodeFilterParam("not-a-valid-id") + Expect(id).To(BeEmpty()) + Expect(ok).To(BeFalse()) + }) + }) + + Describe("decodedQueryIDs", func() { + It("decodes every entry when all are well-formed", func() { + r := httptest.NewRequest("GET", "/Items?ids="+dto.EncodeID(testID("a1"))+","+dto.EncodeID(testID("a2")), nil) + ids, ok := decodedQueryIDs(r, "ids") + Expect(ok).To(BeTrue()) + Expect(ids).To(Equal([]string{testID("a1"), testID("a2")})) + }) + + It("reports not ok and an empty list, not a partially-decoded one, for a mix of valid and malformed entries", func() { + r := httptest.NewRequest("GET", "/Items?ids="+dto.EncodeID(testID("a1"))+",not-a-valid-id", nil) + ids, ok := decodedQueryIDs(r, "ids") + Expect(ok).To(BeFalse()) + Expect(ids).To(BeEmpty()) + }) + + It("reports ok for an absent param, decoding to an empty list", func() { + r := httptest.NewRequest("GET", "/Items", nil) + ids, ok := decodedQueryIDs(r, "ids") + Expect(ok).To(BeTrue()) + Expect(ids).To(BeEmpty()) + }) + }) }) diff --git a/server/jellyfin/jellyfin_suite_test.go b/server/jellyfin/jellyfin_suite_test.go index aab9628a0..2ae7da6e2 100644 --- a/server/jellyfin/jellyfin_suite_test.go +++ b/server/jellyfin/jellyfin_suite_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -17,6 +18,10 @@ func TestJellyfinApi(t *testing.T) { RunSpecs(t, "Jellyfin API Suite") } +// testID maps a readable label to a deterministic canonical id, so fixtures exercise the same +// id shape production uses. +func testID(label string) string { return id.NewHash("jellyfin-test", label) } + // 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. diff --git a/server/jellyfin/library.go b/server/jellyfin/library.go index 2830f1105..b21603843 100644 --- a/server/jellyfin/library.go +++ b/server/jellyfin/library.go @@ -6,6 +6,7 @@ import ( "strconv" "github.com/Masterminds/squirrel" + "github.com/go-chi/chi/v5" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/jellyfin/dto" @@ -33,9 +34,36 @@ func resolveLibraryScope(ctx context.Context, parentId string) (scopeIDs []int, return accessibleLibraryIDs(ctx), false } +// decodeFilterParam separates an absent param from an undecodable one: dropping the filter for a +// stale id would silently widen the query to the whole library. It is the only place that opts +// into treating "absent" as fine — every other caller of dto.DecodeID must 404 on ok=false. +func decodeFilterParam(raw string) (id string, ok bool) { + if raw == "" { + return "", true + } + return dto.DecodeID(raw) +} + +// itemIDParam decodes a chi URL id param, writing 404 and reporting false when it isn't a +// well-formed GUID. Handlers must return immediately when ok is false. +func itemIDParam(w http.ResponseWriter, r *http.Request, key string) (string, bool) { + id, ok := dto.DecodeID(chi.URLParam(r, key)) + if !ok { + http.Error(w, "Not Found", http.StatusNotFound) + return "", false + } + return id, true +} + // parentIDScope resolves the request's ParentId param to a library scope (see resolveLibraryScope). -func parentIDScope(ctx context.Context, r *http.Request) (scopeIDs []int, isLibraryParent bool) { - return resolveLibraryScope(ctx, dto.DecodeID(req.Params(r).StringOr("parentid", ""))) +// ok is false when a non-empty ParentId fails to decode (see decodeFilterParam). +func parentIDScope(ctx context.Context, r *http.Request) (scopeIDs []int, isLibraryParent bool, ok bool) { + parentId, ok := decodeFilterParam(req.Params(r).StringOr("parentid", "")) + if !ok { + return nil, false, false + } + scopeIDs, isLibraryParent = resolveLibraryScope(ctx, parentId) + return scopeIDs, isLibraryParent, true } // libraryScopeFilter restricts a tag query to the given library scope. Empty scope means @@ -51,7 +79,7 @@ func libraryScopeFilter(scope []int) squirrel.Sqlizer { // 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)), + Id: dto.EncodeLibraryID(lib.ID), Name: lib.Name, Type: "CollectionFolder", CollectionType: "music", diff --git a/server/jellyfin/lyrics_test.go b/server/jellyfin/lyrics_test.go index 402d14ed0..2cf53a280 100644 --- a/server/jellyfin/lyrics_test.go +++ b/server/jellyfin/lyrics_test.go @@ -53,8 +53,8 @@ var _ = Describe("getLyrics", func() { BeforeEach(func() { ds = &tests.MockDataStore{} ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "s1", Title: "Song", LibraryID: 1}, - {ID: "s2", Title: "Silent Song", LibraryID: 1}, + {ID: testID("s1"), Title: "Song", LibraryID: 1}, + {ID: testID("s2"), Title: "Silent Song", LibraryID: 1}, }) fake = &fakeLyricsService{lyrics: map[string]model.LyricList{}} api = &Router{ @@ -66,7 +66,7 @@ var _ = Describe("getLyrics", func() { doRequest := func(id string) *httptest.ResponseRecorder { w := httptest.NewRecorder() - ctx := request.WithUser(context.Background(), model.User{ID: "u1", Libraries: model.Libraries{{ID: 1}}}) + ctx := request.WithUser(context.Background(), model.User{ID: testID("u1"), Libraries: model.Libraries{{ID: 1}}}) // Clients send hex-encoded ids (matching real traffic and the other handler tests). enc := dto.EncodeID(id) r := httptest.NewRequest("GET", "/Audio/"+enc+"/Lyrics", nil).WithContext(ctx) @@ -76,10 +76,10 @@ var _ = Describe("getLyrics", func() { } It("returns 200 with a LyricDto for a track with synced lyrics", func() { - fake.lyrics["s1"] = model.LyricList{ + fake.lyrics[testID("s1")] = model.LyricList{ {Kind: "main", Synced: true, Line: []model.Line{{Start: p(1000), Value: "hello"}}}, } - w := doRequest("s1") + w := doRequest(testID("s1")) Expect(w.Code).To(Equal(http.StatusOK)) var res dto.LyricDto @@ -91,11 +91,11 @@ var _ = Describe("getLyrics", func() { }) It("serves the main-kind lyric when a translation is also present", func() { - fake.lyrics["s1"] = model.LyricList{ + fake.lyrics[testID("s1")] = model.LyricList{ {Kind: "translation", Synced: true, Line: []model.Line{{Start: p(1000), Value: "bonjour"}}}, {Kind: "main", Synced: true, Line: []model.Line{{Start: p(1000), Value: "hello"}}}, } - w := doRequest("s1") + w := doRequest(testID("s1")) Expect(w.Code).To(Equal(http.StatusOK)) var res dto.LyricDto @@ -105,13 +105,13 @@ var _ = Describe("getLyrics", func() { }) It("returns 404 when the service returns no lyrics", func() { - w := doRequest("s2") + w := doRequest(testID("s2")) Expect(w.Code).To(Equal(http.StatusNotFound)) }) It("returns 404 when the main lyric has no lines", func() { - fake.lyrics["s1"] = model.LyricList{{Kind: "main", Lang: "eng"}} - w := doRequest("s1") + fake.lyrics[testID("s1")] = model.LyricList{{Kind: "main", Lang: "eng"}} + w := doRequest(testID("s1")) Expect(w.Code).To(Equal(http.StatusNotFound)) }) @@ -121,35 +121,35 @@ var _ = Describe("getLyrics", func() { }) It("caches results so a second request doesn't re-invoke the service", func() { - fake.lyrics["s1"] = model.LyricList{ + fake.lyrics[testID("s1")] = model.LyricList{ {Kind: "main", Synced: true, Line: []model.Line{{Start: p(1000), Value: "hello"}}}, } - Expect(doRequest("s1").Code).To(Equal(http.StatusOK)) - Expect(doRequest("s1").Code).To(Equal(http.StatusOK)) + Expect(doRequest(testID("s1")).Code).To(Equal(http.StatusOK)) + Expect(doRequest(testID("s1")).Code).To(Equal(http.StatusOK)) Expect(fake.calls).To(Equal(1)) }) It("caches empty results too", func() { - Expect(doRequest("s2").Code).To(Equal(http.StatusNotFound)) - Expect(doRequest("s2").Code).To(Equal(http.StatusNotFound)) + Expect(doRequest(testID("s2")).Code).To(Equal(http.StatusNotFound)) + Expect(doRequest(testID("s2")).Code).To(Equal(http.StatusNotFound)) Expect(fake.calls).To(Equal(1)) }) It("completes and caches the fetch even when the request context is cancelled", func() { - fake.lyrics["s1"] = model.LyricList{ + fake.lyrics[testID("s1")] = model.LyricList{ {Kind: "main", Synced: true, Line: []model.Line{{Start: p(1000), Value: "hello"}}}, } ctx, cancel := context.WithCancel(context.Background()) cancel() - list := api.cachedLyrics(ctx, &model.MediaFile{ID: "s1"}) + list := api.cachedLyrics(ctx, &model.MediaFile{ID: testID("s1")}) Expect(list).ToNot(BeEmpty()) - Expect(doRequest("s1").Code).To(Equal(http.StatusOK)) + Expect(doRequest(testID("s1")).Code).To(Equal(http.StatusOK)) Expect(fake.calls).To(Equal(1)) }) It("bounds the detached fetch with a timeout", func() { - Expect(doRequest("s2").Code).To(Equal(http.StatusNotFound)) + Expect(doRequest(testID("s2")).Code).To(Equal(http.StatusNotFound)) Expect(fake.hadDeadline).To(BeTrue()) }) }) diff --git a/server/jellyfin/middlewares_test.go b/server/jellyfin/middlewares_test.go index f3aa65d6f..b17b9a4ec 100644 --- a/server/jellyfin/middlewares_test.go +++ b/server/jellyfin/middlewares_test.go @@ -24,12 +24,12 @@ var _ = Describe("authenticate middleware", 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()) + Expect(ur.Put(&model.User{ID: testID("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}) + t, err := auth.CreateToken(&model.User{ID: testID("u1"), UserName: name}) Expect(err).ToNot(HaveOccurred()) return t } @@ -129,7 +129,7 @@ var _ = Describe("withPlayer middleware", func() { }) It("injects the player's server-forced transcoding into the context", func() { - players.trc = &model.Transcoding{ID: "t1", TargetFormat: "opus"} + players.trc = &model.Transcoding{ID: testID("t1"), TargetFormat: "opus"} _, trc, hasTrc := callWith() Expect(hasTrc).To(BeTrue()) Expect(trc.TargetFormat).To(Equal("opus")) diff --git a/server/jellyfin/playlists.go b/server/jellyfin/playlists.go index 9afee5ac3..805b369c8 100644 --- a/server/jellyfin/playlists.go +++ b/server/jellyfin/playlists.go @@ -17,16 +17,11 @@ import ( "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), + Id: dto.PlaylistsFolderGUID, Name: "Playlists", Type: "ManualPlaylistsFolder", CollectionType: "playlists", @@ -61,7 +56,12 @@ func (api *Router) createPlaylist(w http.ResponseWriter, r *http.Request) { http.Error(w, "Bad Request", http.StatusBadRequest) return } - ids := api.expandContainerIDs(r.Context(), slice.Map(body.Ids, dto.DecodeID)) + decoded, ok := dto.DecodeIDs(body.Ids) + if !ok { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + ids := api.expandContainerIDs(r.Context(), decoded) id, err := api.playlists.Create(r.Context(), "", body.Name, ids) if err != nil { api.internalError(w, r, err) @@ -80,7 +80,10 @@ type updatePlaylistRequest struct { func (api *Router) updatePlaylist(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - id := dto.DecodeID(chi.URLParam(r, "playlistId")) + id, ok := itemIDParam(w, r, "playlistId") + if !ok { + return + } var body updatePlaylistRequest if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, "Bad Request", http.StatusBadRequest) @@ -96,7 +99,12 @@ func (api *Router) updatePlaylist(w http.ResponseWriter, r *http.Request) { return } } else { - ids := api.expandContainerIDs(ctx, slice.Map(*body.Ids, dto.DecodeID)) + decoded, ok := dto.DecodeIDs(*body.Ids) + if !ok { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + ids := api.expandContainerIDs(ctx, decoded) if _, err := api.playlists.Create(ctx, id, "", ids); err != nil { api.playlistError(w, r, err) return @@ -146,7 +154,7 @@ func (api *Router) playlistTrackPage(repo model.PlaylistTrackRepository, fields // individually removable. func trackToBaseItem(t model.PlaylistTrack, fields dto.Fields) dto.BaseItemDto { item := dto.SongToBaseItem(t.MediaFile, fields) - item.PlaylistItemId = dto.EncodeID(t.ID) + item.PlaylistItemId = dto.EncodePlaylistEntryID(t.ID) return item } @@ -155,7 +163,10 @@ func trackToBaseItem(t model.PlaylistTrack, fields dto.Fields) dto.BaseItemDto { // be probed. func (api *Router) getPlaylist(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - id := dto.DecodeID(chi.URLParam(r, "playlistId")) + id, ok := itemIDParam(w, r, "playlistId") + if !ok { + return + } pls, err := api.playlists.Get(ctx, id) if err != nil { http.Error(w, "Not Found", http.StatusNotFound) @@ -184,7 +195,10 @@ func (api *Router) getPlaylist(w http.ResponseWriter, r *http.Request) { // 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")) + id, ok := itemIDParam(w, r, "playlistId") + if !ok { + return + } repo, err := api.playlists.Tracks(ctx, id) if err != nil { http.Error(w, "Not Found", http.StatusNotFound) @@ -232,7 +246,7 @@ func (api *Router) expandContainerIDs(ctx context.Context, ids []string) []strin } 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 + out = append(out, id) // well-formed but unresolved — left for the caller's write to handle } } return out @@ -251,8 +265,16 @@ func (api *Router) songIDs(ctx context.Context, opts model.QueryOptions) []strin // 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)) + id, ok := itemIDParam(w, r, "playlistId") + if !ok { + return + } + decoded, ok := dto.DecodeIDs(queryIDs(r, "ids")) + if !ok { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + ids := api.expandContainerIDs(ctx, decoded) if _, err := api.playlists.AddTracks(ctx, id, ids); err != nil { http.Error(w, "Not Found", http.StatusNotFound) return @@ -265,8 +287,20 @@ func (api *Router) addToPlaylist(w http.ResponseWriter, r *http.Request) { // 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) + id, ok := itemIDParam(w, r, "playlistId") + if !ok { + return + } + raw := queryIDs(r, "entryids") + ids := make([]string, 0, len(raw)) + for _, entryGUID := range raw { + entry, ok := dto.DecodePlaylistEntryID(entryGUID) + if !ok { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + ids = append(ids, entry) + } if err := api.playlists.RemoveTracks(ctx, id, ids); err != nil { http.Error(w, "Not Found", http.StatusNotFound) return diff --git a/server/jellyfin/playlists_test.go b/server/jellyfin/playlists_test.go index 7770cb6db..3599cc784 100644 --- a/server/jellyfin/playlists_test.go +++ b/server/jellyfin/playlists_test.go @@ -67,7 +67,7 @@ func (f *fakePlaylists) Create(_ context.Context, _ string, name string, ids []s if f.createErr != nil { return "", f.createErr } - return "pl-new", nil + return testID("pl-new"), nil } // Get defaults to model.ErrNotFound when getByIDPls/getByIDErr aren't set, matching the real @@ -145,15 +145,15 @@ var _ = Describe("Playlists", func() { 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"]}`)). + r := httptest.NewRequest("POST", "/Playlists", strings.NewReader(`{"Name":"Mix","Ids":["`+dto.EncodeID(testID("s1"))+`","`+dto.EncodeID(testID("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(res["Id"]).To(Equal(dto.EncodeID(testID("pl-new")))) Expect(fp.createdName).To(Equal("Mix")) - Expect(fp.createdIds).To(Equal([]string{"s1", "s2"})) + Expect(fp.createdIds).To(Equal([]string{testID("s1"), testID("s2")})) }) It("returns 400 on an invalid JSON body", func() { @@ -177,48 +177,48 @@ var _ = Describe("Playlists", func() { Describe("getPlaylistItems", func() { It("maps playlist tracks to Audio BaseItemDtos, tagging each with its PlaylistItemId", func() { fp.getPls = &model.Playlist{ - ID: "pl1", + ID: testID("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"}}, + {ID: "1", MediaFileID: testID("s1"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s1"), Title: "Song One"}}, + {ID: "2", MediaFileID: testID("s2"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s2"), Title: "Song Two"}}, }, } w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Playlists/pl1/Items", nil).WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", "pl1") + r := httptest.NewRequest("GET", "/Playlists/"+testID("pl1")+"/Items", nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("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].Id).To(Equal(dto.EncodeID(testID("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"))) + Expect(res.Items[0].PlaylistItemId).To(Equal(dto.EncodePlaylistEntryID("1"))) + Expect(res.Items[1].Id).To(Equal(dto.EncodeID(testID("s2")))) + Expect(res.Items[1].PlaylistItemId).To(Equal(dto.EncodePlaylistEntryID("2"))) }) It("pages with StartIndex/Limit, pushing them down to the query", func() { fp.getPls = &model.Playlist{ - ID: "pl1", + ID: testID("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"}}, + {ID: "1", MediaFileID: testID("s1"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s1")}}, + {ID: "2", MediaFileID: testID("s2"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s2")}}, + {ID: "3", MediaFileID: testID("s3"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s3")}}, }, } w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Playlists/pl1/Items?StartIndex=1&Limit=1", nil). + r := httptest.NewRequest("GET", "/Playlists/"+testID("pl1")+"/Items?StartIndex=1&Limit=1", nil). WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", "pl1") + r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("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(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s2")))) Expect(fp.tracksRepo.Options.Offset).To(Equal(1)) Expect(fp.tracksRepo.Options.Max).To(Equal(1)) }) @@ -227,7 +227,7 @@ var _ = Describe("Playlists", func() { fp.getErr = model.ErrNotFound w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Playlists/missing/Items", nil).WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", "missing") + r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("missing"))) api.getPlaylistItems(w, r) Expect(w.Code).To(Equal(http.StatusNotFound)) }) @@ -245,73 +245,73 @@ var _ = Describe("Playlists", func() { createWith := func(id string) { w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Playlists", strings.NewReader(`{"Name":"Mix","Ids":["`+id+`"]}`)). + r := httptest.NewRequest("POST", "/Playlists", strings.NewReader(`{"Name":"Mix","Ids":["`+dto.EncodeID(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"})) + ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1")}}) + createWith(testID("s1")) + Expect(fp.createdIds).To(Equal([]string{testID("s1")})) }) It("expands an album id into its songs, filtered by album", func() { - ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al1"}}) + ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("al1")}}) ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "s1", AlbumID: "al1"}, {ID: "s2", AlbumID: "al1"}, + {ID: testID("s1"), AlbumID: testID("al1")}, {ID: testID("s2"), AlbumID: testID("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)) + createWith(testID("al1")) + Expect(fp.createdIds).To(Equal([]string{testID("s1"), testID("s2")})) + Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Options.Filters).To(Equal(filter.SongsByAlbum(testID("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)) + ds.Artist(ctx).(*tests.MockArtistRepo).SetData(model.Artists{{ID: testID("ar1")}}) + ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1")}, {ID: testID("s2")}}) + createWith(testID("ar1")) + Expect(fp.createdIds).To(Equal([]string{testID("s1"), testID("s2")})) + Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Options.Filters).To(Equal(filter.SongsByArtistID(testID("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"}, + fp.getPls = &model.Playlist{ID: testID("pl9"), Tracks: model.PlaylistTracks{ + {ID: "1", MediaFileID: testID("s3")}, {ID: "2", MediaFileID: testID("s4")}, }} - createWith("pl9") - Expect(fp.createdIds).To(Equal([]string{"s3", "s4"})) + createWith(testID("pl9")) + Expect(fp.createdIds).To(Equal([]string{testID("s3"), testID("s4")})) }) }) Describe("getPlaylist", func() { It("returns OpenAccess from Public and item ids (encoded media file ids, not entry ids)", func() { pls := &model.Playlist{ - ID: "pl1", + ID: testID("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"}}, + {ID: "1", MediaFileID: testID("s1"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s1")}}, + {ID: "2", MediaFileID: testID("s2"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("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") + r := httptest.NewRequest("GET", "/Playlists/"+testID("pl1"), nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("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")})) + Expect(res.ItemIds).To(Equal([]string{dto.EncodeID(testID("s1")), dto.EncodeID(testID("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") + r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("missing"))) invoke(api.getPlaylist, w, r) Expect(w.Code).To(Equal(http.StatusNotFound)) }) @@ -325,29 +325,29 @@ var _ = Describe("Playlists", func() { It("deletes the playlist and returns 204", func() { w := httptest.NewRecorder() - api.deleteItem(w, deleteReq("pl1")) + api.deleteItem(w, deleteReq(testID("pl1"))) Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(fp.deletePlaylistID).To(Equal("pl1")) + Expect(fp.deletePlaylistID).To(Equal(testID("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")) + api.deleteItem(w, deleteReq(testID("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")) + api.deleteItem(w, deleteReq(testID("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")) + api.deleteItem(w, deleteReq(testID("pl1"))) Expect(w.Code).To(Equal(http.StatusInternalServerError)) }) }) @@ -355,39 +355,39 @@ var _ = Describe("Playlists", func() { 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") + r := httptest.NewRequest("POST", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items?ids="+dto.EncodeID(testID("s1"))+","+dto.EncodeID(testID("s2")), nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("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"})) + Expect(fp.addPlaylistID).To(Equal(testID("pl1"))) + Expect(fp.addIds).To(Equal([]string{testID("s1"), testID("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") + r := httptest.NewRequest("POST", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items?Ids="+dto.EncodeID(testID("s1"))+","+dto.EncodeID(testID("s2")), nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) invoke(api.addToPlaylist, w, r) Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(fp.addIds).To(Equal([]string{"s1", "s2"})) + Expect(fp.addIds).To(Equal([]string{testID("s1"), testID("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") + r := httptest.NewRequest("POST", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items?ids="+dto.EncodeID(testID("s1")), nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("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") + r := httptest.NewRequest("POST", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items", nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) invoke(api.addToPlaylist, w, r) Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(fp.addPlaylistID).To(Equal("pl1")) + Expect(fp.addPlaylistID).To(Equal(testID("pl1"))) Expect(fp.addIds).To(BeEmpty()) }) }) @@ -395,39 +395,48 @@ var _ = Describe("Playlists", func() { 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") + r := httptest.NewRequest("DELETE", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items?entryIds="+dto.EncodePlaylistEntryID("1")+","+dto.EncodePlaylistEntryID("2"), nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) invoke(api.removeFromPlaylist, w, r) Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(fp.removePlaylistID).To(Equal("pl1")) + Expect(fp.removePlaylistID).To(Equal(testID("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") + r := httptest.NewRequest("DELETE", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items?EntryIds="+dto.EncodePlaylistEntryID("1")+","+dto.EncodePlaylistEntryID("2"), nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) invoke(api.removeFromPlaylist, w, r) Expect(w.Code).To(Equal(http.StatusNoContent)) Expect(fp.removeIds).To(Equal([]string{"1", "2"})) }) + It("404s an entryId that is a song id rather than a playlist-entry position", func() { + w := httptest.NewRecorder() + r := httptest.NewRequest("DELETE", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items?entryIds="+dto.EncodeID(testID("s1")), nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) + invoke(api.removeFromPlaylist, w, r) + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(fp.removeIds).To(BeEmpty()) + }) + 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") + r := httptest.NewRequest("DELETE", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items?entryIds="+dto.EncodePlaylistEntryID("1"), nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("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") + r := httptest.NewRequest("DELETE", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items", nil).WithContext(context.Background()) + r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) invoke(api.removeFromPlaylist, w, r) Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(fp.removePlaylistID).To(Equal("pl1")) + Expect(fp.removePlaylistID).To(Equal(testID("pl1"))) Expect(fp.removeIds).To(BeEmpty()) }) }) @@ -435,30 +444,30 @@ var _ = Describe("Playlists", func() { 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") + ctx := request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice"}) + r := httptest.NewRequest("GET", "/Playlists/"+testID("pl1")+"/Users", nil).WithContext(ctx) + r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("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: dto.EncodeID("u1"), CanEdit: true}})) + Expect(res).To(Equal([]dto.PlaylistUserPermissions{{UserId: dto.EncodeID(testID("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()) + r := httptest.NewRequest("GET", "/Playlists/"+testID("pl1")+"/Users/"+testID("u1"), nil).WithContext(context.Background()) rctx := chi.NewRouteContext() - rctx.URLParams.Add("playlistId", "pl1") - rctx.URLParams.Add("userId", "u1") + rctx.URLParams.Add("playlistId", testID("pl1")) + rctx.URLParams.Add("userId", testID("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})) + Expect(res).To(Equal(dto.PlaylistUserPermissions{UserId: testID("u1"), CanEdit: true})) }) }) }) diff --git a/server/jellyfin/sessions.go b/server/jellyfin/sessions.go index f83e5182d..a145e7359 100644 --- a/server/jellyfin/sessions.go +++ b/server/jellyfin/sessions.go @@ -21,14 +21,19 @@ type playbackReport struct { // 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. +// These endpoints always answer 204 (see reportPlaybackStart), so a malformed id isn't a request +// failure; it just can't identify a track, same as an absent one. 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)) + if id, ok := dto.DecodeID(body.ItemId); ok { + body.ItemId = id + } else { + body.ItemId = "" + } return body } diff --git a/server/jellyfin/sessions_test.go b/server/jellyfin/sessions_test.go index 24f945efd..05151c39a 100644 --- a/server/jellyfin/sessions_test.go +++ b/server/jellyfin/sessions_test.go @@ -11,6 +11,7 @@ import ( "github.com/navidrome/navidrome/core/scrobbler" "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" @@ -57,8 +58,8 @@ var _ = Describe("Sessions", func() { 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"}) + ctx := request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice"}) + ctx = request.WithPlayer(ctx, model.Player{ID: testID("p1"), Client: "Finamp"}) return r.WithContext(ctx) } @@ -70,35 +71,46 @@ var _ = Describe("Sessions", func() { 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}`))) + r := authed(httptest.NewRequest("POST", "/Sessions/Playing", strings.NewReader(`{"ItemId":"`+dto.EncodeID(testID("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].MediaId).To(Equal(testID("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].ClientId).To(Equal(testID("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)) + r := authed(httptest.NewRequest("POST", "/Sessions/Playing?ItemId="+dto.EncodeID(testID("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")) + Expect(pt.reported[0].MediaId).To(Equal(testID("s2"))) + }) + + It("still reports (with an empty MediaId) for a malformed ItemId, rather than failing the request", func() { + w := httptest.NewRecorder() + r := authed(httptest.NewRequest("POST", "/Sessions/Playing", strings.NewReader(`{"ItemId":"not-a-valid-id","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(BeEmpty()) }) }) 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}`))) + r := authed(httptest.NewRequest("POST", "/Sessions/Playing/Progress", strings.NewReader(`{"ItemId":"`+dto.EncodeID(testID("s1"))+`","PositionTicks":20000000,"IsPaused":false}`))) invoke(api.reportPlaybackProgress, w, r) @@ -110,7 +122,7 @@ var _ = Describe("Sessions", func() { 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}`))) + r := authed(httptest.NewRequest("POST", "/Sessions/Playing/Progress", strings.NewReader(`{"ItemId":"`+dto.EncodeID(testID("s1"))+`","PositionTicks":20000000,"IsPaused":true}`))) invoke(api.reportPlaybackProgress, w, r) @@ -123,14 +135,14 @@ var _ = Describe("Sessions", func() { 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}`))) + r := authed(httptest.NewRequest("POST", "/Sessions/Playing/Stopped", strings.NewReader(`{"ItemId":"`+dto.EncodeID(testID("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].MediaId).To(Equal(testID("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 diff --git a/server/jellyfin/similar.go b/server/jellyfin/similar.go index 2698073bf..3e0b3b11b 100644 --- a/server/jellyfin/similar.go +++ b/server/jellyfin/similar.go @@ -6,7 +6,6 @@ import ( "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" @@ -56,7 +55,10 @@ func (api *Router) awaitSimilar(ctx context.Context, id string, limit int, fetch // 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"))) + id, ok := itemIDParam(w, r, "itemId") + if !ok { + return + } 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) @@ -68,7 +70,10 @@ func (api *Router) getSimilarArtists(w http.ResponseWriter, r *http.Request) { // 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"))) + id, ok := itemIDParam(w, r, "itemId") + if !ok { + return + } limit := clampLimit(req.Params(r).IntOr("limit", 0), defaultSimilarLimit, maxSimilarLimit) entity, err := model.GetEntityByID(ctx, api.ds, id) @@ -93,7 +98,10 @@ func (api *Router) getSimilarItems(w http.ResponseWriter, r *http.Request) { // 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"))) + id, ok := itemIDParam(w, r, "itemId") + if !ok { + return + } limit := clampLimit(req.Params(r).IntOr("limit", 0), defaultSimilarLimit, maxInstantMixLimit) entity, err := model.GetEntityByID(ctx, api.ds, id) diff --git a/server/jellyfin/similar_test.go b/server/jellyfin/similar_test.go index 302566195..dc8c1e605 100644 --- a/server/jellyfin/similar_test.go +++ b/server/jellyfin/similar_test.go @@ -34,7 +34,7 @@ var _ = Describe("awaitSimilar", func() { }) It("returns the fetch result when it completes within the wait", func() { - res := api.awaitSimilar(ctxFor("u1"), "id1", 20, func(context.Context) dto.QueryResult { + res := api.awaitSimilar(ctxFor(testID("u1")), "id1", 20, func(context.Context) dto.QueryResult { return result([]dto.BaseItemDto{{Name: "fast"}}, 1, 0) }) Expect(res.Items).To(HaveLen(1)) @@ -45,7 +45,7 @@ var _ = Describe("awaitSimilar", func() { shortenWait() release := make(chan struct{}) DeferCleanup(func() { close(release) }) - res := api.awaitSimilar(ctxFor("u1"), "id2", 20, func(context.Context) dto.QueryResult { + res := api.awaitSimilar(ctxFor(testID("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) }) @@ -64,8 +64,8 @@ var _ = Describe("awaitSimilar", func() { } // 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) + api.awaitSimilar(ctxFor(testID("u1")), "id3", 20, fetch) + api.awaitSimilar(ctxFor(testID("u1")), "id3", 20, fetch) close(release) Eventually(calls.Load).Should(Equal(int32(1))) Consistently(calls.Load, "50ms").Should(Equal(int32(1))) @@ -77,15 +77,15 @@ var _ = Describe("awaitSimilar", func() { calls.Add(1) return result(nil, 0, 0) } - api.awaitSimilar(ctxFor("u1"), "id4", 20, fetch) - api.awaitSimilar(ctxFor("u2"), "id4", 20, fetch) + api.awaitSimilar(ctxFor(testID("u1")), "id4", 20, fetch) + api.awaitSimilar(ctxFor(testID("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 { + api.awaitSimilar(ctxFor(testID("u1")), "id5", 20, func(ctx context.Context) dto.QueryResult { deadline, hasDeadline = ctx.Deadline() return result(nil, 0, 0) }) @@ -123,16 +123,16 @@ var _ = Describe("getInstantMix", func() { ds := &tests.MockDataStore{} ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "s1", Title: "Seed Song", LibraryID: 1}, + {ID: testID("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")) + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("s1"))+"/InstantMix", nil). + WithContext(request.WithUser(context.Background(), model.User{ID: testID("u1"), Libraries: model.Libraries{{ID: 1}}})) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) api.getInstantMix(w, r) var res dto.QueryResult @@ -145,18 +145,18 @@ var _ = Describe("getInstantMix", func() { // 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}} + songs := model.MediaFiles{{ID: testID("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}) + songs = append(songs, model.MediaFile{ID: testID(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")) + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("s1"))+"/InstantMix?limit="+strconv.Itoa(want), nil). + WithContext(request.WithUser(context.Background(), model.User{ID: testID("u1"), Libraries: model.Libraries{{ID: 1}}})) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) api.getInstantMix(w, r) var res dto.QueryResult diff --git a/server/jellyfin/socket_test.go b/server/jellyfin/socket_test.go index 8c509fe91..401c791fd 100644 --- a/server/jellyfin/socket_test.go +++ b/server/jellyfin/socket_test.go @@ -88,9 +88,9 @@ var _ = Describe("handleSocket", 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()) + Expect(ur.Put(&model.User{ID: testID("u1"), UserName: "alice", NewPassword: "secret"})).To(Succeed()) - t, err := auth.CreateToken(&model.User{ID: "u1", UserName: "alice"}) + t, err := auth.CreateToken(&model.User{ID: testID("u1"), UserName: "alice"}) Expect(err).ToNot(HaveOccurred()) token = t diff --git a/server/jellyfin/stream.go b/server/jellyfin/stream.go index 022c00c96..86746f89a 100644 --- a/server/jellyfin/stream.go +++ b/server/jellyfin/stream.go @@ -23,7 +23,10 @@ import ( // 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"))) + id, ok := itemIDParam(w, r, "itemId") + if !ok { + return nil, false + } mf, err := api.ds.MediaFile(ctx).Get(id) if err != nil { http.Error(w, "Not Found", http.StatusNotFound) @@ -62,7 +65,7 @@ func (api *Router) getPlaybackInfo(w http.ResponseWriter, r *http.Request) { 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}) + api.ok(w, r, dto.PlaybackInfoResponse{MediaSources: []dto.MediaSourceInfo{src}, PlaySessionId: dto.EncodeID(mf.ID)}) } // streamAudio serves /Audio/{itemId}/stream[.container] and /Audio/{itemId}/universal, diff --git a/server/jellyfin/stream_test.go b/server/jellyfin/stream_test.go index f77c10ec1..d6220d33f 100644 --- a/server/jellyfin/stream_test.go +++ b/server/jellyfin/stream_test.go @@ -26,7 +26,7 @@ var _ = Describe("Stream", func() { // 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"}}}) + return request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice", Libraries: model.Libraries{{ID: 1, Name: "Music"}}}) } BeforeEach(func() { @@ -43,30 +43,30 @@ var _ = Describe("Stream", func() { 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}, + {ID: testID("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")) + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("s1"))+"/PlaybackInfo", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("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].Id).To(Equal(dto.EncodeID(testID("s1")))) Expect(res.MediaSources[0].Container).To(Equal("mp3")) Expect(res.MediaSources[0].Size).To(Equal(int64(1000))) - Expect(res.PlaySessionId).ToNot(BeEmpty()) + Expect(res.PlaySessionId).To(MatchRegexp("^[0-9a-f]{32}$")) }) 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}, + {ID: testID("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") + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("s1"))+"/PlaybackInfo", nil).WithContext(ctxUser()) // only has access to library 1 + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) api.getPlaybackInfo(w, r) Expect(w.Code).To(Equal(http.StatusNotFound)) @@ -75,7 +75,7 @@ var _ = Describe("Stream", func() { 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") + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("missing"))) api.getPlaybackInfo(w, r) Expect(w.Code).To(Equal(http.StatusNotFound)) @@ -83,8 +83,8 @@ var _ = Describe("Stream", func() { playbackInfo := func() dto.PlaybackInfoResponse { w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("s1")+"/PlaybackInfo", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID("s1")) + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("s1"))+"/PlaybackInfo", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) api.getPlaybackInfo(w, r) var res dto.PlaybackInfoResponse Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) @@ -103,10 +103,10 @@ var _ = Describe("Stream", func() { It("advertises a Lyric stream for plugin/sidecar-sourced lyrics not embedded in the file", func() { ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 1}, + {ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 1}, }) api.lyrics = &fakeLyricsService{lyrics: map[string]model.LyricList{ - "s1": {{Kind: "main", Synced: true, Line: []model.Line{{Value: "hello"}}}}, + testID("s1"): {{Kind: "main", Synced: true, Line: []model.Line{{Value: "hello"}}}}, }} Expect(lyricStreams(playbackInfo())).To(HaveLen(1)) @@ -114,7 +114,7 @@ var _ = Describe("Stream", func() { It("advertises no Lyric stream when the pipeline finds nothing", func() { ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 1}, + {ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 1}, }) Expect(lyricStreams(playbackInfo())).To(BeEmpty()) @@ -122,10 +122,10 @@ var _ = Describe("Stream", func() { It("advertises no Lyric stream when the lyrics endpoint would 404 (main lyric has no lines)", func() { ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 1}, + {ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 1}, }) api.lyrics = &fakeLyricsService{lyrics: map[string]model.LyricList{ - "s1": {{Kind: "main", Lang: "eng"}}, + testID("s1"): {{Kind: "main", Lang: "eng"}}, }} Expect(lyricStreams(playbackInfo())).To(BeEmpty()) @@ -133,10 +133,10 @@ var _ = Describe("Stream", func() { It("doesn't duplicate the Lyric stream when lyrics are already embedded", func() { ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "s1", Title: "Song", Suffix: "mp3", LibraryID: 1, Lyrics: `[{"lang":"xxx","line":[]}]`}, + {ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 1, Lyrics: `[{"lang":"xxx","line":[]}]`}, }) api.lyrics = &fakeLyricsService{lyrics: map[string]model.LyricList{ - "s1": {{Kind: "main", Synced: true, Line: []model.Line{{Value: "hello"}}}}, + testID("s1"): {{Kind: "main", Synced: true, Line: []model.Line{{Value: "hello"}}}}, }} Expect(lyricStreams(playbackInfo())).To(HaveLen(1)) @@ -146,20 +146,20 @@ var _ = Describe("Stream", func() { // Own ID: an erroring loader isn't cached, but a shared ID could still pick up // another test's cached (non-error) result and mask this assertion. ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "s-err", Title: "Song", Suffix: "mp3", Duration: 100, Size: 1000, LibraryID: 1}, + {ID: testID("s-err"), Title: "Song", Suffix: "mp3", Duration: 100, Size: 1000, LibraryID: 1}, }) api.lyrics = &fakeLyricsService{err: errors.New("boom")} w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID("s-err")+"/PlaybackInfo", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID("s-err")) + r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("s-err"))+"/PlaybackInfo", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s-err"))) 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("s-err"))) + Expect(res.MediaSources[0].Id).To(Equal(dto.EncodeID(testID("s-err")))) Expect(lyricStreams(res)).To(BeEmpty()) }) }) @@ -167,12 +167,12 @@ var _ = Describe("Stream", func() { 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}, + {ID: testID("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") + r := httptest.NewRequest("GET", "/Audio/"+dto.EncodeID(testID("s1"))+"/stream", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) invoke(api.streamAudio, w, r) Expect(w.Code).To(Equal(http.StatusOK)) @@ -183,11 +183,11 @@ var _ = Describe("Stream", func() { 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}, + {ID: testID("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") + r := httptest.NewRequest("GET", "/Audio/"+dto.EncodeID(testID("s1"))+"/stream", nil).WithContext(ctxUser()) // only has access to library 1 + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) invoke(api.streamAudio, w, r) Expect(w.Code).To(Equal(http.StatusNotFound)) @@ -198,7 +198,7 @@ var _ = Describe("Stream", func() { 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") + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("missing"))) invoke(api.streamAudio, w, r) Expect(w.Code).To(Equal(http.StatusNotFound)) @@ -208,11 +208,11 @@ var _ = Describe("Stream", func() { 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}, + {ID: testID("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") + r := httptest.NewRequest("GET", "/Audio/"+dto.EncodeID(testID("s1"))+"/stream?audiobitrate=320000", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) invoke(api.streamAudio, w, r) Expect(decider.req.BitRate).To(Equal(320)) @@ -220,11 +220,11 @@ var _ = Describe("Stream", func() { 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}, + {ID: testID("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") + r := httptest.NewRequest("GET", "/Audio/"+dto.EncodeID(testID("s1"))+"/stream?audiocodec=aac", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) invoke(api.streamAudio, w, r) Expect(decider.req.Format).To(Equal("aac")) @@ -232,12 +232,12 @@ var _ = Describe("Stream", func() { 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}, + {ID: testID("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") + r := httptest.NewRequest("GET", "/Audio/"+dto.EncodeID(testID("s1"))+"/stream", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) invoke(api.streamAudio, w, r) Expect(w.Code).To(Equal(http.StatusInternalServerError)) @@ -247,14 +247,14 @@ var _ = Describe("Stream", func() { 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}, + {ID: testID("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") + r := httptest.NewRequest("GET", "/Audio/"+dto.EncodeID(testID("s1"))+"/main.m3u8"+query, nil).WithContext(ctx) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) invoke(api.streamHls, w, r) return w } @@ -302,7 +302,7 @@ var _ = Describe("Stream", func() { 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}, + {ID: testID("s1"), Title: "Song", Suffix: "dsf", LibraryID: 2}, }) Expect(hls("", ctxUser()).Code).To(Equal(http.StatusNotFound)) }) @@ -316,12 +316,12 @@ var _ = Describe("Stream", func() { 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}, + {ID: testID("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") + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("s1"))+"/File", nil).WithContext(ctxUser()) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) api.streamFile(w, r) Expect(w.Code).To(Equal(http.StatusOK)) @@ -333,11 +333,11 @@ var _ = Describe("Stream", func() { 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}, + {ID: testID("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") + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("s1"))+"/File", nil).WithContext(ctxUser()) // only has access to library 1 + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) api.streamFile(w, r) Expect(w.Code).To(Equal(http.StatusNotFound)) @@ -348,7 +348,7 @@ var _ = Describe("Stream", func() { 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") + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("missing"))) api.streamFile(w, r) Expect(w.Code).To(Equal(http.StatusNotFound)) diff --git a/server/jellyfin/system.go b/server/jellyfin/system.go index 6f0c359d4..ea553fd87 100644 --- a/server/jellyfin/system.go +++ b/server/jellyfin/system.go @@ -2,10 +2,12 @@ package jellyfin import ( "context" + "encoding/hex" "errors" "fmt" "net/http" "path" + "strings" "github.com/google/uuid" "github.com/navidrome/navidrome/conf" @@ -42,25 +44,32 @@ func (api *Router) serverID(ctx context.Context) string { return api.serverIDVal } if api.ds == nil { - api.serverIDVal = uuid.NewString() + api.serverIDVal = newServerID() return api.serverIDVal } id, err := api.ds.Property(ctx).Get(consts.JellyfinServerIDKey) switch { case errors.Is(err, model.ErrNotFound): - id = uuid.NewString() + id = newServerID() 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() + return newServerID() } - api.serverIDVal = id + // Ids persisted before this change are dashed; normalize on read rather than rewriting the DB. + api.serverIDVal = strings.ReplaceAll(id, "-", "") return api.serverIDVal } +// newServerID returns a UUID in Jellyfin's no-dash GUID form (Guid.ToString("N")). +func newServerID() string { + u := uuid.New() + return hex.EncodeToString(u[:]) +} + func (api *Router) publicInfo(r *http.Request) dto.PublicSystemInfo { return dto.PublicSystemInfo{ LocalAddress: localAddress(r), diff --git a/server/jellyfin/system_test.go b/server/jellyfin/system_test.go index 7846e4a20..a7f7df6a4 100644 --- a/server/jellyfin/system_test.go +++ b/server/jellyfin/system_test.go @@ -125,21 +125,33 @@ var _ = Describe("System", func() { }) 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()) + Expect(ds.Property(ctx).Put(consts.JellyfinServerIDKey, "6ba7b8109dad11d180b400c04fd430c8")).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 + Expect(degraded).ToNot(Equal("6ba7b8109dad11d180b400c04fd430c8")) // 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")) + Expect(r.serverID(ctx)).To(Equal("6ba7b8109dad11d180b400c04fd430c8")) stored, err := ds.Property(ctx).Get(consts.JellyfinServerIDKey) Expect(err).ToNot(HaveOccurred()) - Expect(stored).To(Equal("stable-id")) + Expect(stored).To(Equal("6ba7b8109dad11d180b400c04fd430c8")) + }) + + It("returns Jellyfin's no-dash GUID form", func() { + r := &Router{ds: ds} + Expect(r.serverID(ctx)).To(MatchRegexp("^[0-9a-f]{32}$")) + }) + + It("strips dashes from an already-persisted id", func() { + Expect(ds.Property(ctx).Put( + consts.JellyfinServerIDKey, "1b4e28ba-2fa1-11d2-883f-0016d3cca427")).To(Succeed()) + r := &Router{ds: ds} + Expect(r.serverID(ctx)).To(Equal("1b4e28ba2fa111d2883f0016d3cca427")) }) }) }) diff --git a/server/jellyfin/truncated_ids.go b/server/jellyfin/truncated_ids.go deleted file mode 100644 index bb3e4ce9b..000000000 --- a/server/jellyfin/truncated_ids.go +++ /dev/null @@ -1,113 +0,0 @@ -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). All Navidrome ids are 22 chars (share ids 10), so length alone flags 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_test.go b/server/jellyfin/users_test.go index 6a1597b70..3e53d598f 100644 --- a/server/jellyfin/users_test.go +++ b/server/jellyfin/users_test.go @@ -19,7 +19,7 @@ import ( 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}) + ctx := request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice", Libraries: libs}) return r.WithContext(ctx) } BeforeEach(func() { api = &Router{ds: &tests.MockDataStore{}} }) @@ -35,13 +35,13 @@ var _ = Describe("Users", func() { 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].Id).To(Equal(dto.EncodeLibraryID(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].Id).To(Equal(dto.EncodeLibraryID(2))) Expect(res.Items[1].Name).To(Equal("Podcasts")) }) @@ -53,7 +53,7 @@ var _ = Describe("Users", func() { 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"))) + Expect(res.Items[0].Id).To(Equal(dto.EncodeLibraryID(1))) }) It("returns no views for a user with no library access", func() { @@ -92,8 +92,8 @@ var _ = Describe("Users", func() { 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()) + Expect(ur.Put(&model.User{ID: testID("u1"), UserName: "alice"})).To(Succeed()) + Expect(ur.Put(&model.User{ID: testID("u2"), UserName: "bob"})).To(Succeed()) }) It("returns an empty list when the config is unset", func() { @@ -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(dto.EncodeID("u2"))) + Expect(users[0].Id).To(Equal(dto.EncodeID(testID("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 6c3e7e268be20f7a090542dfd2a65781b3ef2ab7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Wed, 12 Aug 2026 23:02:13 -0400 Subject: [PATCH 71/71] feat(instant-mix): support album, playlist and genre sources (#5948) * feat(agents): local agent genre-hint similar songs fallback * feat(external): playlist instant mix via seed-track sampling * test(external): cover playlist mix never-empty fallback and maxSeeds cap Adds coverage for the empty-match seed fallback and the maxSeeds call cap on GetSimilarSongsByTrack, per code review finding. * feat(external): genre instant mix via seed-track sampling * feat(external): album instant mix falls back to AudioMuse track similarity * feat(external): artist instant mix falls back to seed-track sampling * fix(jellyfin): route genre seeds through instant mix instead of empty * feat(jellyfin): add /Albums/{id}/Similar route for albumMix radio * perf(external): bound playlist seed sampling to a random N samplePlaylistTracks loaded an entire playlist's joined rows just to keep 5 random seeds; push the bound and randomization into the query instead, matching the other samplers (GetRandom/GetAllByTags with Max). Fixing this surfaced a real bug: resetSeededRandom's SEEDEDRAND rewrite assumed every table's id is TEXT, but playlist_tracks.id is an INTEGER position, so the random sort silently dropped every row. Cast the id to TEXT before hashing (no-op for the other, TEXT-id tables). Also trims a changelog-flavored comment and a duplicated rationale in server/jellyfin/similar_test.go. * refactor(external): parallelize seed mix and dedup mix helpers Run the up-to-5 per-seed GetSimilarSongsByTrack calls concurrently (errgroup), route the four container cases through a shared seedMix helper, flatten the genre lookup, and sample playlist seeds without forcing a smart-playlist rebuild. Share the media-file->Song mapping in the local agent. * perf(agents): use the indexed genre filter for local similarity Replace GetAllByTags (a json_tree scan of every media_file row) with the media_file_tags semi-join from #5940, deriving the seed's genre tag ids locally since they hash from (name, value). Also carry the library id and the recording MBID on the returned songs: the matcher resolves by id first and looks up mbz_recording_id, so the release-track id it got before matched nothing and the local fallback silently returned no songs. * refactor: drop redundant MBID and fold mixFromSeeds into seedMix The local agent returns library tracks, so the id alone resolves them in the matcher's first phase; the MBID was never consulted. mixFromSeeds had no caller other than seedMix. * docs: trim redundant comments * fix(jellyfin): adopt the GUID id codec in the merged similar routes getSimilarAlbums still used resolveItemID/DecodeID, which #5942 replaced with itemIDParam; its tests passed raw ids that the strict codec now rejects. * fix(external): guard non-positive counts and blend every seed A negative Subsonic count reached matched[:count] and panicked. The matcher also keeps input order and stops at count, so seed-grouped results let the first seed fill the whole mix; interleaving gives every seed a share. Drops the duplicate playlist-track mock in favour of tests.MockPlaylistTrackRepo, which pages like the real repository and records the query options. * fix(external): refresh smart playlists before sampling seeds A smart playlist materializes no playlist_tracks until it is evaluated, so sampling without the refresh mixed an empty seed set. The refresh is a no-op for regular playlists, inside the refresh delay, and for non-owners. * fix(external): skip missing tracks and a nil playlist-track repo when sampling Tracks() logs and returns a nil repository when its own lookup fails, so the chained GetAll panicked. Seeds can also reach the mix verbatim when the agents find nothing, so a missing file would surface as an unplayable entry. * fix(jellyfin): never report the seed album as its own similar album The sampled-seed fallback returns the album's own tracks, which similarAlbums mapped straight back to the requested album, often as the only result. * test(agents): assert the genre predicate instead of relying on the mock MockMediaFileRepo ignores QueryOptions.Filters, so the spec passed even with no genre filter at all. It now checks the generated predicate carries the seed's own tag id, the indexed join and the missing exclusion. * fix(external): clamp the requested count before it becomes a query limit Subsonic passes the client's count through unbounded. At MaxInt64 the local agent's count+1 overflows negative, and GetRandom omits the SQL limit unless Max is positive, so one request would hydrate every matching track. 500 is what the widest caller (similarAlbums, limit*5) legitimately asks for. * fix(external): deduplicate playlist seeds by media file A playlist can hold the same file at several positions, so sampling its rows could seed the mix twice: a wasted agent call, and a duplicate track whenever the seed fallback kicks in. * fix(external): drop tracks two seeds both recommend The matcher re-emits a track when two inputs are identical, so overlapping recommendations took several slots in the mix. Match the whole merged set and dedup before trimming. Playlist sampling now over-fetches before its own dedup, so repeated positions cannot collapse the seed count. * test(external): make the seed-blend assertion independent of the shuffle It matched four tracks and kept two at random, so both could come from the first seed once in six runs. Keeping three of the four makes a seed-two track unavoidable. * fix(external): seed artist mixes from every credited role media_file.artist_id is the deprecated primary artist, so an artist credited only on the album, as on compilations, sampled no seeds at all. Use the same participant filter the artist listings use. * refactor(external): drop the now-vestigial seed interleaving Matching the whole merged set removed the early truncation the interleave guarded against, and the shuffle before the trim makes input order irrelevant. Its comment described the old behaviour. * test(agents): give the id-mapping fixture a matching genre The related track carried no genre, so the real query would never return it; the spec only passed because the mock ignores QueryOptions.Filters. * test(agents): drop the MBID from the id-mapping fixture Local agent candidates are non-missing library rows, so the matcher always resolves them in its id phase and never reads the MBID. The field guarded a regression that could not change behaviour. * test(agents): remove unnecessary comment about MBID in GetArtistTopSongs test * fix(jellyfin): only let a not-found entity fall through in getInstantMix Discarding the error conflated a genre id, which never resolves, with a real lookup failure, which then made a provider call that fails the same way. * test: pin the invariants the specs only appeared to cover The missing filter was asserted by substring, so flipping it to true passed everywhere, including the spec named for it. Matching the whole merged set, the local agent's over-fetch, and its no-genres early return had no coverage at all; each is now pinned by a spec that fails when the code is broken. * test: make the remaining specs say what they actually guard The playlist-track spec named a sort whitelist it does not exercise; it guards the integer-id CAST, so it now asserts no rows are dropped. The maxSeeds cap passed with either bound removed, and the over-fetch was pinned by its literal value rather than the duplicate positions it exists for. Also drops setup the count guard returns before reaching. * fix(external): fall back when the agent's picks are not in this library A non-empty answer whose songs are all absent locally matched nothing and was returned as-is, so the mix came back empty with sampleable source tracks sitting right there. * refactor(external): name the agent-then-fallback flow once Each entity case repeated the same error and emptiness plumbing around the matcher. mixFromAgent states it once and each case supplies only what differs: how to ask, and what to do when the answer is unusable. --- core/agents/agents_test.go | 4 +- core/agents/local_agent.go | 53 +- core/agents/local_agent_test.go | 96 ++++ core/external/extdata_helper_test.go | 13 + core/external/provider.go | 186 ++++++- core/external/provider_similarsongs_test.go | 468 +++++++++++++++++- persistence/playlist_track_repository.go | 1 + persistence/playlist_track_repository_test.go | 14 + persistence/sql_base_repository.go | 4 +- server/jellyfin/api.go | 1 + server/jellyfin/similar.go | 21 +- server/jellyfin/similar_test.go | 67 +++ tests/mock_playlist_repo.go | 24 +- 13 files changed, 891 insertions(+), 61 deletions(-) create mode 100644 core/agents/local_agent_test.go diff --git a/core/agents/agents_test.go b/core/agents/agents_test.go index e3087f995..e79b2b3c8 100644 --- a/core/agents/agents_test.go +++ b/core/agents/agents_test.go @@ -34,10 +34,10 @@ var _ = Describe("Agents", func() { }) It("calls the placeholder GetArtistImages", func() { - mfRepo.SetData(model.MediaFiles{{ID: "1", Title: "One", MbzReleaseTrackID: "111"}, {ID: "2", Title: "Two", MbzReleaseTrackID: "222"}}) + mfRepo.SetData(model.MediaFiles{{ID: "1", Title: "One"}, {ID: "2", Title: "Two"}}) songs, err := ag.GetArtistTopSongs(ctx, "123", "John Doe", "mb123", 2) Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(ConsistOf([]Song{{Name: "One", MBID: "111"}, {Name: "Two", MBID: "222"}})) + Expect(songs).To(ConsistOf([]Song{{ID: "1", Name: "One"}, {ID: "2", Name: "Two"}})) }) }) diff --git a/core/agents/local_agent.go b/core/agents/local_agent.go index ce8f9f07c..1cb9060a1 100644 --- a/core/agents/local_agent.go +++ b/core/agents/local_agent.go @@ -5,6 +5,8 @@ import ( "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/persistence" + "github.com/navidrome/navidrome/utils/slice" ) const LocalAgentName = "local" @@ -37,14 +39,51 @@ func (p *localAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid if err != nil { return nil, err } - var result []Song - for _, s := range top { - result = append(result, Song{ - Name: s.Title, - MBID: s.MbzReleaseTrackID, - }) + return songsFrom(top), nil +} + +func (p *localAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]Song, error) { + seed, err := p.ds.MediaFile(ctx).Get(id) + if err != nil { + return nil, err } - return result, nil + // Tag ids derive from (name, value), so the seed's genre ids need no extra query. + genreIDs := slice.Map(seed.Tags.Flatten(model.TagGenre), func(t model.Tag) string { return t.ID }) + if len(genreIDs) == 0 { + return nil, nil + } + // Ask for extra so we can drop the seed itself and still fill the count. + candidates, err := p.ds.MediaFile(ctx).GetRandom(model.QueryOptions{ + Filters: squirrel.And{ + persistence.SongGenres.ByID(genreIDs), + squirrel.Eq{"missing": false}, + }, + Max: count + 1, + }) + if err != nil { + return nil, err + } + filtered := make(model.MediaFiles, 0, len(candidates)) + for _, s := range candidates { + if s.ID == id { + continue + } + filtered = append(filtered, s) + if len(filtered) >= count { + break + } + } + return songsFrom(filtered), nil +} + +func songsFrom(mfs model.MediaFiles) []Song { + if len(mfs) == 0 { + return nil + } + + return slice.Map(mfs, func(mf model.MediaFile) Song { + return Song{ID: mf.ID, Name: mf.Title} + }) } func init() { diff --git a/core/agents/local_agent_test.go b/core/agents/local_agent_test.go new file mode 100644 index 000000000..50a0ce297 --- /dev/null +++ b/core/agents/local_agent_test.go @@ -0,0 +1,96 @@ +package agents + +import ( + "context" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/utils/slice" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("localAgent GetSimilarSongsByTrack", func() { + var ds *tests.MockDataStore + var mfRepo *tests.MockMediaFileRepo + var agent *localAgent + var ctx context.Context + + BeforeEach(func() { + ctx = context.Background() + mfRepo = &tests.MockMediaFileRepo{} + ds = &tests.MockDataStore{MockedMediaFile: mfRepo} + agent = &localAgent{ds: ds} + }) + + It("excludes the seed track from its own similars", func() { + seed := model.MediaFile{ID: "seed-1", Title: "Seed", Tags: model.Tags{model.TagGenre: []string{"Rock"}}} + related := model.MediaFile{ID: "rel-1", Title: "Related", Tags: model.Tags{model.TagGenre: []string{"Rock"}}} + // SetData keys by ID; a duplicate "seed-1" entry would clobber the real seed. + mfRepo.SetData(model.MediaFiles{seed, related}) + + songs, err := agent.GetSimilarSongsByTrack(ctx, "seed-1", "Seed", "", "", 10) + + Expect(err).ToNot(HaveOccurred()) + names := slice.Map(songs, func(s Song) string { return s.Name }) + Expect(names).ToNot(ContainElement("Seed")) + }) + + // The mock ignores QueryOptions.Filters, so assert the predicate itself: otherwise this spec + // would pass just as well with no genre filter at all. + It("queries the indexed genre join for the seed's own genres, skipping missing files", func() { + rock := model.NewTag(model.TagGenre, "Rock") + seed := model.MediaFile{ID: "seed-4", Title: "Seed", Tags: model.Tags{model.TagGenre: []string{"Rock"}}} + mfRepo.SetData(model.MediaFiles{seed}) + + _, err := agent.GetSimilarSongsByTrack(ctx, "seed-4", "Seed", "", "", 10) + Expect(err).ToNot(HaveOccurred()) + + sql, args, sqlErr := mfRepo.Options.Filters.ToSql() + Expect(sqlErr).ToNot(HaveOccurred()) + Expect(sql).To(ContainSubstring("media_file_tags"), "must use the indexed join, not a json_tree scan") + Expect(sql).To(ContainSubstring("missing")) + Expect(args).To(ContainElement(false), "must exclude missing files, not select them") + Expect(args).To(ContainElement(rock.ID), "must filter on the seed's own genre tag id") + Expect(args).ToNot(ContainElement(model.NewTag(model.TagGenre, "Jazz").ID)) + }) + + It("returns the library id so the matcher can resolve the song", func() { + seed := model.MediaFile{ID: "seed-3", Title: "Seed", Tags: model.Tags{model.TagGenre: []string{"Rock"}}} + // Without the id the matcher falls through to its MBID/title phases and resolves nothing, + // so the local fallback silently returns an empty mix. + related := model.MediaFile{ID: "rel-3", Title: "Related", Tags: model.Tags{model.TagGenre: []string{"Rock"}}} + mfRepo.SetData(model.MediaFiles{seed, related}) + + songs, err := agent.GetSimilarSongsByTrack(ctx, "seed-3", "Seed", "", "", 10) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(ContainElement(Song{ID: "rel-3", Name: "Related"})) + }) + + It("asks for one extra candidate so dropping the seed still fills the count", func() { + // The mock returns rows sorted by id, so the seed comes first and would consume the only + // slot if the query did not over-fetch. + seed := model.MediaFile{ID: "a-seed", Title: "Seed", Tags: model.Tags{model.TagGenre: []string{"Rock"}}} + related := model.MediaFile{ID: "b-rel", Title: "Related", Tags: model.Tags{model.TagGenre: []string{"Rock"}}} + mfRepo.SetData(model.MediaFiles{seed, related}) + + songs, err := agent.GetSimilarSongsByTrack(ctx, "a-seed", "Seed", "", "", 1) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(HaveLen(1)) + Expect(songs[0].Name).To(Equal("Related")) + }) + + It("returns nil when the seed track has no genres", func() { + seed := model.MediaFile{ID: "seed-2", Title: "NoGenre"} + mfRepo.SetData(model.MediaFiles{seed}) + + songs, err := agent.GetSimilarSongsByTrack(ctx, "seed-2", "NoGenre", "", "", 10) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(BeEmpty()) + // Without the early return an empty tag filter would scan the whole library. + Expect(mfRepo.Options).To(Equal(model.QueryOptions{}), "must not query at all") + }) +}) diff --git a/core/external/extdata_helper_test.go b/core/external/extdata_helper_test.go index 8fabf4490..d68147168 100644 --- a/core/external/extdata_helper_test.go +++ b/core/external/extdata_helper_test.go @@ -110,6 +110,19 @@ func (m *mockMediaFileRepo) GetAll(options ...model.QueryOptions) (model.MediaFi return args.Get(0).(model.MediaFiles), args.Error(1) } +// GetRandom implements model.MediaFileRepository. +func (m *mockMediaFileRepo) GetRandom(options ...model.QueryOptions) (model.MediaFiles, error) { + argsSlice := make([]any, len(options)) + for i, v := range options { + argsSlice[i] = v + } + args := m.Called(argsSlice...) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(model.MediaFiles), args.Error(1) +} + // SetError is a helper to set up a generic error for GetAll. func (m *mockMediaFileRepo) SetError(hasError bool) { if hasError { diff --git a/core/external/provider.go b/core/external/provider.go index efd061c0c..383e7d4c9 100644 --- a/core/external/provider.go +++ b/core/external/provider.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "math/rand/v2" "sort" "strings" "time" @@ -14,6 +15,7 @@ import ( "github.com/navidrome/navidrome/core/matcher" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/persistence" "github.com/navidrome/navidrome/utils" . "github.com/navidrome/navidrome/utils/gg" "github.com/navidrome/navidrome/utils/random" @@ -23,7 +25,11 @@ import ( ) const ( - maxSimilarArtists = 100 + maxSimilarArtists = 100 + maxSeeds = 5 + // Subsonic passes the client's count through unbounded, and it ends up as a SQL limit. 500 is + // what the widest caller (similarAlbums, limit*5) legitimately asks for. + maxSimilarSongs = 500 refreshDelay = 5 * time.Second refreshTimeout = 15 * time.Second refreshQueueLength = 2000 @@ -278,32 +284,188 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au } func (e *provider) SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error) { + // Subsonic passes the client's count straight through: a non-positive one has no valid + // interpretation, and an enormous one overflows the +1 in the local agent's query limit. + if count <= 0 { + return nil, nil + } + count = min(count, maxSimilarSongs) entity, err := model.GetEntityByID(ctx, e.ds, id) if err != nil { - return nil, err + // Genre ids don't resolve via GetEntityByID; look them up before giving up. + if !errors.Is(err, model.ErrNotFound) { + return nil, err + } + genre, err := e.ds.Genre(ctx).Get(id) + if err != nil { + return nil, err + } + return e.seedMix(ctx, count, func() (model.MediaFiles, error) { + return e.sampleGenreTracks(ctx, genre, maxSeeds) + }) } - var songs []agents.Song - - // Try entity-specific similarity first + // Try entity-specific similarity first, then fall back to seed-track sampling. switch v := entity.(type) { case *model.MediaFile: - songs, err = e.ag.GetSimilarSongsByTrack(ctx, v.ID, v.Title, v.Artist, v.MbzRecordingID, count) + return e.mixFromAgent(ctx, count, + func() ([]agents.Song, error) { + return e.ag.GetSimilarSongsByTrack(ctx, v.ID, v.Title, v.Artist, v.MbzRecordingID, count) + }, + func() (model.MediaFiles, error) { + return e.similarSongsFallback(ctx, id, count) + }) case *model.Album: - songs, err = e.ag.GetSimilarSongsByAlbum(ctx, v.ID, v.Name, v.AlbumArtist, v.MbzAlbumID, count) + return e.mixFromAgent(ctx, count, + func() ([]agents.Song, error) { + return e.ag.GetSimilarSongsByAlbum(ctx, v.ID, v.Name, v.AlbumArtist, v.MbzAlbumID, count) + }, + func() (model.MediaFiles, error) { + return e.seedMix(ctx, count, func() (model.MediaFiles, error) { + return e.sampleAlbumTracks(ctx, v.ID, maxSeeds) + }) + }) case *model.Artist: - songs, err = e.ag.GetSimilarSongsByArtist(ctx, v.ID, v.Name, v.MbzArtistID, count) + return e.mixFromAgent(ctx, count, + func() ([]agents.Song, error) { + return e.ag.GetSimilarSongsByArtist(ctx, v.ID, v.Name, v.MbzArtistID, count) + }, + func() (model.MediaFiles, error) { + if res, ferr := e.similarSongsFallback(ctx, id, count); ferr == nil && len(res) > 0 { + return res, nil + } + return e.seedMix(ctx, count, func() (model.MediaFiles, error) { + return e.sampleArtistTracks(ctx, v.ID, maxSeeds) + }) + }) + case *model.Playlist: + return e.seedMix(ctx, count, func() (model.MediaFiles, error) { + return e.samplePlaylistTracks(ctx, v.ID, maxSeeds) + }) default: log.Warn(ctx, "Unknown entity type", "id", id, "type", fmt.Sprintf("%T", entity)) return nil, model.ErrNotFound } +} - if err == nil && len(songs) > 0 { - return e.matcher.MatchSongs(ctx, songs, count) +// mixFromAgent returns the agent's recommendations matched to library tracks, or the fallback +// when the agent errors or none of its picks are in the library. +func (e *provider) mixFromAgent(ctx context.Context, count int, fetch func() ([]agents.Song, error), fallback func() (model.MediaFiles, error)) (model.MediaFiles, error) { + songs, err := fetch() + if err == nil { + matched, merr := e.matcher.MatchSongs(ctx, songs, count) + if merr != nil { + return nil, merr + } + if len(matched) > 0 { + return matched, nil + } } + return fallback() +} - // Fallback to existing similar artists + top songs algorithm - return e.similarSongsFallback(ctx, id, count) +// seedMix samples seed tracks, runs each through the agent chain's per-track similarity and merges +// the results, falling back to the seeds themselves so the result is never empty. +func (e *provider) seedMix(ctx context.Context, count int, sample func() (model.MediaFiles, error)) (model.MediaFiles, error) { + seeds, err := sample() + if err != nil { + return nil, err + } + if len(seeds) == 0 { + return nil, nil + } + seeds = seeds[:min(len(seeds), maxSeeds)] + + // The per-seed similarity calls are independent and hit the (possibly remote) agent chain, so + // run them concurrently. Best-effort: a seed that errors just contributes nothing. + perSeed := make([][]agents.Song, len(seeds)) + var g errgroup.Group + for i, seed := range seeds { + g.Go(func() error { + if s, err := e.ag.GetSimilarSongsByTrack(ctx, seed.ID, seed.Title, seed.Artist, seed.MbzRecordingID, count); err == nil { + perSeed[i] = s + } + return nil + }) + } + _ = g.Wait() + + var songs []agents.Song + for _, s := range perSeed { + songs = append(songs, s...) + } + // Match the whole merged set, not just count of it: the matcher re-emits a track when two + // seeds recommend it identically, so the duplicates have to be dropped before trimming. Every + // seed reaches the shuffle, so no seed can crowd out the others. + matched, err := e.matcher.MatchSongs(ctx, songs, len(songs)) + if err != nil { + return nil, err + } + matched = dedupByID(matched) + if len(matched) == 0 { + matched = seeds + } + rand.Shuffle(len(matched), func(i, j int) { matched[i], matched[j] = matched[j], matched[i] }) + if len(matched) > count { + matched = matched[:count] + } + return matched, nil +} + +func (e *provider) samplePlaylistTracks(ctx context.Context, playlistID string, n int) (model.MediaFiles, error) { + // Refresh: a smart playlist materializes no tracks until it is evaluated, so skipping it would + // mix an empty seed set. It is a no-op for regular playlists and inside the refresh delay. + repo := e.ds.Playlist(ctx).Tracks(playlistID, true) + if repo == nil { + return nil, model.ErrNotFound + } + // A playlist can hold the same file at several positions, so over-fetch and dedup: a repeated + // seed wastes an agent call and can reach the mix twice through the seed fallback. + tracks, err := repo.GetAll(model.QueryOptions{ + Sort: "random", + Max: n * 4, + Filters: squirrel.Eq{"missing": false}, + }) + if err != nil { + return nil, err + } + mfs := dedupByID(tracks.MediaFiles()) + return mfs[:min(len(mfs), n)], nil +} + +func dedupByID(mfs model.MediaFiles) model.MediaFiles { + seen := make(map[string]struct{}, len(mfs)) + return slice.Filter(mfs, func(mf model.MediaFile) bool { + if _, dup := seen[mf.ID]; dup { + return false + } + seen[mf.ID] = struct{}{} + return true + }) +} + +func (e *provider) sampleAlbumTracks(ctx context.Context, albumID string, n int) (model.MediaFiles, error) { + return e.sampleTracks(ctx, squirrel.Eq{"album_id": albumID}, n) +} + +func (e *provider) sampleArtistTracks(ctx context.Context, artistID string, n int) (model.MediaFiles, error) { + // media_file.artist_id is the deprecated primary artist, so it misses an artist credited only + // on the album, as on compilations. Same filter the artist listings use. + filter := persistence.ParticipantIDFilter("media_file", artistID, model.RoleArtist, model.RoleAlbumArtist) + return e.sampleTracks(ctx, filter, n) +} + +func (e *provider) sampleGenreTracks(ctx context.Context, genre *model.Genre, n int) (model.MediaFiles, error) { + return e.sampleTracks(ctx, persistence.SongGenres.ByID(genre.ID), n) +} + +// sampleTracks returns up to n random present tracks. Seeds can end up in the mix verbatim, so +// missing files would surface as unplayable entries. +func (e *provider) sampleTracks(ctx context.Context, filter squirrel.Sqlizer, n int) (model.MediaFiles, error) { + return e.ds.MediaFile(ctx).GetRandom(model.QueryOptions{ + Filters: squirrel.And{filter, squirrel.Eq{"missing": false}}, + Max: n, + }) } // similarSongsFallback uses the original similar artists + top songs algorithm. The idea is to diff --git a/core/external/provider_similarsongs_test.go b/core/external/provider_similarsongs_test.go index 563003f83..5617269b3 100644 --- a/core/external/provider_similarsongs_test.go +++ b/core/external/provider_similarsongs_test.go @@ -3,6 +3,8 @@ package external_test import ( "context" "errors" + "math" + "slices" "strings" "github.com/Masterminds/squirrel" @@ -11,6 +13,7 @@ import ( "github.com/navidrome/navidrome/core/matcher" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/utils/slice" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/stretchr/testify/mock" @@ -26,6 +29,9 @@ var _ = Describe("Provider - SimilarSongs", func() { var artistRepo *mockArtistRepo var mediaFileRepo *mockMediaFileRepo var albumRepo *mockAlbumRepo + var playlistRepo *tests.MockPlaylistRepo + var playlistTrackRepo *tests.MockPlaylistTrackRepo + var genreRepo *tests.MockedGenreRepo var ctx context.Context BeforeEach(func() { @@ -34,11 +40,17 @@ var _ = Describe("Provider - SimilarSongs", func() { artistRepo = newMockArtistRepo() mediaFileRepo = newMockMediaFileRepo() albumRepo = newMockAlbumRepo() + playlistTrackRepo = &tests.MockPlaylistTrackRepo{} + playlistRepo = tests.CreateMockPlaylistRepo() + playlistRepo.TracksRepo = playlistTrackRepo + genreRepo = &tests.MockedGenreRepo{} ds = &tests.MockDataStore{ MockedArtist: artistRepo, MockedMediaFile: mediaFileRepo, MockedAlbum: albumRepo, + MockedPlaylist: playlistRepo, + MockedGenre: genreRepo, } mockAgent = &mockSimilarArtistAgent{} @@ -203,46 +215,68 @@ var _ = Describe("Provider - SimilarSongs", func() { Expect(songs[0].ID).To(Equal("matched-1")) }) - It("falls back when GetSimilarSongsByAlbum returns ErrNotFound", func() { + It("falls back to sampled album tracks when GetSimilarSongsByAlbum returns ErrNotFound", func() { album := model.Album{ID: "album-1", Name: "Album", AlbumArtist: "Artist", AlbumArtistID: "artist-1"} - artist := model.Artist{ID: "artist-1", Name: "Artist"} - song := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"} + seed := model.MediaFile{ID: "seed-1", Title: "Seed", Artist: "Artist"} - // GetEntityByID for the initial call tries Artist, Album, Playlist, then MediaFile artistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() albumRepo.On("Get", "album-1").Return(&album, nil).Once() agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "album-1", "Album", "Artist", "", mock.Anything). Return(nil, agents.ErrNotFound).Once() - // Fallback calls getArtist(id) which calls GetEntityByID again - this time it finds the album - // and recursively calls getArtist(v.AlbumArtistID) - artistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() - albumRepo.On("Get", "album-1").Return(&album, nil).Once() + mediaFileRepo.On("GetRandom", mock.MatchedBy(func(opt model.QueryOptions) bool { + sql, args, err := opt.Filters.ToSql() + return err == nil && strings.Contains(sql, "album_id") && + strings.Contains(sql, "missing") && slices.Contains(args, any(false)) && slices.Contains(args, any("album-1")) + })).Return(model.MediaFiles{seed}, nil).Once() - // Then it recurses with the artist-1 ID - artistRepo.On("Get", "artist-1").Return(&artist, nil).Maybe() - artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { - return opt.Max == 1 && opt.Filters != nil - })).Return(model.Artists{artist}, nil).Maybe() - - mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist", "", 15). - Return([]agents.Artist{}, nil).Once() - - artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { - return opt.Max == 0 && opt.Filters != nil - })).Return(model.Artists{}, nil).Once() - - mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist", "", mock.Anything). - Return([]agents.Song{{Name: "Song One", MBID: "mbid-1"}}, nil).Once() - - mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song}, nil).Once() + // seedMix falls back to the seed itself when the agent finds nothing. + agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "seed-1", "Seed", "Artist", "", mock.Anything). + Return([]agents.Song{}, nil).Once() songs, err := provider.SimilarSongs(ctx, "album-1", 5) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("song-1")) + Expect(songs[0].ID).To(Equal("seed-1")) + }) + }) + + Context("when ID is an Album and the album agent returns nothing (AudioMuse-only)", func() { + It("samples the album's tracks and returns their track-similars", func() { + album := model.Album{ID: "al-1", Name: "The Album", AlbumArtist: "A"} + artistRepo.On("Get", "al-1").Return(nil, model.ErrNotFound).Once() + albumRepo.On("Get", "al-1").Return(&album, nil).Once() + + // AudioMuse doesn't implement album similarity -> empty. + agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "al-1", "The Album", "A", "", 5). + Return([]agents.Song{}, nil).Once() + + // sampleAlbumTracks -> GetRandom(album_id) -> one seed track + mediaFileRepo.On("GetRandom", mock.MatchedBy(func(opt model.QueryOptions) bool { + sql, args, err := opt.Filters.ToSql() + return err == nil && strings.Contains(sql, "album_id") && + strings.Contains(sql, "missing") && slices.Contains(args, any(false)) && slices.Contains(args, any("al-1")) + })).Return(model.MediaFiles{{ID: "s1", Title: "Seed", Artist: "A"}}, nil).Once() + + agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed", "A", "", 5). + Return([]agents.Song{{Name: "AudioMuseResult", Artists: []agents.Artist{{Name: "A"}}}}, nil).Once() + + // Matcher resolves "AudioMuseResult" -> a real MediaFile credited to artist "A". + aArtist := model.Artist{ID: "a1", Name: "A", OrderArtistName: "a"} + matchedTrack := model.MediaFile{ + ID: "m1", Title: "AudioMuseResult", Artist: "A", + Participants: model.Participants{model.RoleArtist: model.ParticipantList{{Artist: aArtist}}}, + } + artistRepo.On("GetAll", mock.Anything).Return(model.Artists{aArtist}, nil).Maybe() + mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{matchedTrack}, nil).Maybe() + + songs, err := provider.SimilarSongs(ctx, "al-1", 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).ToNot(BeEmpty()) + Expect(songs[0].ID).To(Equal("m1")) }) }) @@ -280,6 +314,381 @@ var _ = Describe("Provider - SimilarSongs", func() { Expect(songs[0].ID).To(Equal("matched-1")) }) }) + + Context("when ID is an Artist and both the artist agent and the similar-artists fallback are empty", func() { + It("samples the artist's tracks and returns their track-similars", func() { + artist := model.Artist{ID: "ar-1", Name: "The Artist"} + // Get is called twice: once to resolve the entity, once inside similarSongsFallback. + artistRepo.On("Get", "ar-1").Return(&artist, nil).Maybe() + + agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "ar-1", "The Artist", "", 5). + Return([]agents.Song{}, nil).Once() + // similarSongsFallback: no similar artists, no top songs -> empty (allow its lookups). + mockAgent.On("GetSimilarArtists", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return([]agents.Artist{}, nil).Maybe() + mockAgent.On("GetArtistTopSongs", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return([]agents.Song{}, nil).Maybe() + + // Seeds come from the participant join covering both roles, so an artist credited + // only on the album (compilations, classical) still yields seeds. + mediaFileRepo.On("GetRandom", mock.MatchedBy(func(opt model.QueryOptions) bool { + sql, args, err := opt.Filters.ToSql() + return err == nil && strings.Contains(sql, "media_file_artists") && + strings.Contains(sql, "missing") && slices.Contains(args, any(false)) && slices.Contains(args, any("ar-1")) && + slices.Contains(args, any(model.RoleAlbumArtist.String())) && slices.Contains(args, any(model.RoleArtist.String())) + })).Return(model.MediaFiles{{ID: "s1", Title: "Seed"}}, nil).Once() + agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed", "", "", 5). + Return([]agents.Song{{Name: "Result"}}, nil).Once() + mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{{ID: "m1", Title: "Result"}}, nil).Maybe() + + songs, err := provider.SimilarSongs(ctx, "ar-1", 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).ToNot(BeEmpty()) + }) + }) + + Context("when ID is a Playlist", func() { + It("samples playlist tracks and returns their track-similars", func() { + pls := model.Playlist{ID: "pl-1", Name: "My List"} + seedTrack := model.MediaFile{ID: "s1", Title: "Seed One", Artist: "A"} + + // GetEntityByID order: Artist, Album, Playlist(hit) + artistRepo.On("Get", "pl-1").Return(nil, model.ErrNotFound).Once() + albumRepo.On("Get", "pl-1").Return(nil, model.ErrNotFound).Once() + playlistRepo.SetData(model.Playlists{pls}) + + // samplePlaylistTracks -> Tracks(...).GetAll -> one seed track, bounded+randomized in SQL + playlistTrackRepo.SetData(model.PlaylistTracks{ + {MediaFile: seedTrack}, + }) + + // seedMix -> GetSimilarSongsByTrack for the seed + agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed One", "A", "", 5). + Return([]agents.Song{{Name: "Similar", Artists: []agents.Artist{{Name: "A"}}}}, nil).Once() + + // Matcher resolves "Similar" -> a real MediaFile (allow the matcher's lookups). + artistRepo.On("GetAll", mock.Anything).Return(model.Artists{{ID: "a1", Name: "A"}}, nil).Maybe() + mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{{ID: "m1", Title: "Similar"}}, nil).Maybe() + + songs, err := provider.SimilarSongs(ctx, "pl-1", 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).ToNot(BeEmpty()) + }) + + It("asks for a smart-playlist refresh so an unevaluated one still yields seeds", func() { + // A smart playlist materializes no playlist_tracks until it is evaluated, so sampling + // without the refresh would mix an empty seed set. + pls := model.Playlist{ID: "pl-smart", Name: "Smart"} + artistRepo.On("Get", "pl-smart").Return(nil, model.ErrNotFound).Once() + albumRepo.On("Get", "pl-smart").Return(nil, model.ErrNotFound).Once() + playlistRepo.SetData(model.Playlists{pls}) + playlistTrackRepo.SetData(model.PlaylistTracks{ + {MediaFile: model.MediaFile{ID: "s1", Title: "Seed One"}}, + }) + agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return([]agents.Song{}, nil).Maybe() + + songs, err := provider.SimilarSongs(ctx, "pl-smart", 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).ToNot(BeEmpty()) + Expect(playlistRepo.TracksRefreshed).To(BeTrue()) + }) + + It("returns an error instead of panicking when the track repository is unavailable", func() { + // Tracks() logs and returns a nil repository when its own lookup fails. + pls := model.Playlist{ID: "pl-nil", Name: "Gone"} + artistRepo.On("Get", "pl-nil").Return(nil, model.ErrNotFound).Once() + albumRepo.On("Get", "pl-nil").Return(nil, model.ErrNotFound).Once() + playlistRepo.SetData(model.Playlists{pls}) + playlistRepo.TracksRepo = nil + + _, err := provider.SimilarSongs(ctx, "pl-nil", 5) + + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("does not seed a mix with missing tracks", func() { + pls := model.Playlist{ID: "pl-missing", Name: "Missing"} + artistRepo.On("Get", "pl-missing").Return(nil, model.ErrNotFound).Once() + albumRepo.On("Get", "pl-missing").Return(nil, model.ErrNotFound).Once() + playlistRepo.SetData(model.Playlists{pls}) + playlistTrackRepo.SetData(model.PlaylistTracks{ + {MediaFile: model.MediaFile{ID: "s1", Title: "Seed One"}}, + }) + agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return([]agents.Song{}, nil).Maybe() + + _, err := provider.SimilarSongs(ctx, "pl-missing", 5) + + Expect(err).ToNot(HaveOccurred()) + sql, args, sqlErr := playlistTrackRepo.Options.Filters.ToSql() + Expect(sqlErr).ToNot(HaveOccurred()) + Expect(sql).To(ContainSubstring("missing")) + Expect(args).To(ContainElement(false), "must exclude missing files, not select them") + }) + + It("keeps a later seed's picks when an earlier seed overlaps it", func() { + // The matcher stops once it has count matches, and it re-emits the shared track, so + // matching only count of the merged set would spend slots on the duplicate. + pls := model.Playlist{ID: "pl-overlap2", Name: "Overlap2"} + artistRepo.On("Get", "pl-overlap2").Return(nil, model.ErrNotFound).Once() + albumRepo.On("Get", "pl-overlap2").Return(nil, model.ErrNotFound).Once() + playlistRepo.SetData(model.Playlists{pls}) + playlistTrackRepo.SetData(model.PlaylistTracks{ + {MediaFile: model.MediaFile{ID: "s1", Title: "Seed One"}}, + {MediaFile: model.MediaFile{ID: "s2", Title: "Seed Two"}}, + }) + shared := agents.Song{ID: "x", Name: "X"} + agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed One", "", "", 3). + Return([]agents.Song{shared}, nil).Once() + agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s2", "Seed Two", "", "", 3). + Return([]agents.Song{shared, {ID: "y", Name: "Y"}, {ID: "z", Name: "Z"}}, nil).Once() + mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{ + {ID: "x", Title: "X"}, {ID: "y", Title: "Y"}, {ID: "z", Title: "Z"}, + }, nil).Maybe() + + songs, err := provider.SimilarSongs(ctx, "pl-overlap2", 3) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(HaveLen(3), "the duplicate must not cost a slot") + }) + + It("returns a track once when two seeds recommend it", func() { + // The matcher re-emits a track when two inputs are identical, so overlapping + // recommendations would otherwise take two slots in the mix. + pls := model.Playlist{ID: "pl-overlap", Name: "Overlap"} + artistRepo.On("Get", "pl-overlap").Return(nil, model.ErrNotFound).Once() + albumRepo.On("Get", "pl-overlap").Return(nil, model.ErrNotFound).Once() + playlistRepo.SetData(model.Playlists{pls}) + playlistTrackRepo.SetData(model.PlaylistTracks{ + {MediaFile: model.MediaFile{ID: "s1", Title: "Seed One"}}, + {MediaFile: model.MediaFile{ID: "s2", Title: "Seed Two"}}, + }) + shared := agents.Song{ID: "m1", Name: "Shared"} + agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed One", "", "", 5). + Return([]agents.Song{shared}, nil).Once() + agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s2", "Seed Two", "", "", 5). + Return([]agents.Song{shared}, nil).Once() + mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{{ID: "m1", Title: "Shared"}}, nil).Maybe() + + songs, err := provider.SimilarSongs(ctx, "pl-overlap", 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(HaveLen(1), "the shared recommendation must appear once") + }) + + It("does not seed a mix twice with a track the playlist repeats", func() { + pls := model.Playlist{ID: "pl-dup", Name: "Dupes"} + artistRepo.On("Get", "pl-dup").Return(nil, model.ErrNotFound).Once() + albumRepo.On("Get", "pl-dup").Return(nil, model.ErrNotFound).Once() + playlistRepo.SetData(model.Playlists{pls}) + // The same file at two positions, which playlists allow. + dup := model.MediaFile{ID: "s1", Title: "Seed One"} + playlistTrackRepo.SetData(model.PlaylistTracks{{MediaFile: dup}, {MediaFile: dup}}) + agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed One", "", "", 5). + Return([]agents.Song{}, nil).Once() + + songs, err := provider.SimilarSongs(ctx, "pl-dup", 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(HaveLen(1), "the repeated track must appear once") + agentsCombined.AssertNumberOfCalls(GinkgoT(), "GetSimilarSongsByTrack", 1) + }) + + It("clamps an enormous count before it reaches the queries", func() { + // count+1 in the local agent overflows on MaxInt64, and GetRandom omits the SQL + // limit unless Max is positive, so the query would hydrate the whole library. + pls := model.Playlist{ID: "pl-huge", Name: "Huge"} + artistRepo.On("Get", "pl-huge").Return(nil, model.ErrNotFound).Once() + albumRepo.On("Get", "pl-huge").Return(nil, model.ErrNotFound).Once() + playlistRepo.SetData(model.Playlists{pls}) + playlistTrackRepo.SetData(model.PlaylistTracks{ + {MediaFile: model.MediaFile{ID: "s1", Title: "Seed One"}}, + }) + agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed One", "", "", 500). + Return([]agents.Song{}, nil).Once() + + _, err := provider.SimilarSongs(ctx, "pl-huge", math.MaxInt64) + + Expect(err).ToNot(HaveOccurred()) + agentsCombined.AssertExpectations(GinkgoT()) + }) + + It("does not panic when the caller asks for a non-positive count", func() { + // Subsonic passes count straight through, so a negative one reaches the provider. + // The guard returns before any lookup, so no repository setup is needed. + songs, err := provider.SimilarSongs(ctx, "pl-neg", -1) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(BeEmpty()) + }) + + It("blends results from every seed, not just the first", func() { + pls := model.Playlist{ID: "pl-blend", Name: "Blend"} + artistRepo.On("Get", "pl-blend").Return(nil, model.ErrNotFound).Once() + albumRepo.On("Get", "pl-blend").Return(nil, model.ErrNotFound).Once() + playlistRepo.SetData(model.Playlists{pls}) + playlistTrackRepo.SetData(model.PlaylistTracks{ + {MediaFile: model.MediaFile{ID: "s1", Title: "Seed One"}}, + {MediaFile: model.MediaFile{ID: "s2", Title: "Seed Two"}}, + }) + + // Each seed returns a full count's worth, as a real similarity agent does. Asking for + // one more than seed one can supply makes this independent of the final shuffle: + // three of the four matches always include a seed-two track. + agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed One", "", "", 3). + Return([]agents.Song{{ID: "a1", Name: "A1"}, {ID: "a2", Name: "A2"}}, nil).Once() + agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s2", "Seed Two", "", "", 3). + Return([]agents.Song{{ID: "b1", Name: "B1"}, {ID: "b2", Name: "B2"}}, nil).Once() + + mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{ + {ID: "a1", Title: "A1"}, {ID: "a2", Title: "A2"}, + {ID: "b1", Title: "B1"}, {ID: "b2", Title: "B2"}, + }, nil).Maybe() + + songs, err := provider.SimilarSongs(ctx, "pl-blend", 3) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(HaveLen(3)) + ids := slice.Map(songs, func(mf model.MediaFile) string { return mf.ID }) + Expect(ids).To(ContainElement(BeElementOf("b1", "b2")), "seed two must be represented in the mix") + }) + + It("falls back to the seed tracks themselves when no similar songs are found", func() { + pls := model.Playlist{ID: "pl-2", Name: "Fallback List"} + seed1 := model.MediaFile{ID: "s1", Title: "Seed One", Artist: "A"} + seed2 := model.MediaFile{ID: "s2", Title: "Seed Two", Artist: "B"} + + artistRepo.On("Get", "pl-2").Return(nil, model.ErrNotFound).Once() + albumRepo.On("Get", "pl-2").Return(nil, model.ErrNotFound).Once() + playlistRepo.SetData(model.Playlists{pls}) + + playlistTrackRepo.SetData(model.PlaylistTracks{ + {MediaFile: seed1}, + {MediaFile: seed2}, + }) + + // Both seeds come back empty, so the mix must fall back to the seeds themselves. + agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed One", "A", "", 5). + Return([]agents.Song{}, nil).Once() + agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s2", "Seed Two", "B", "", 5). + Return([]agents.Song{}, nil).Once() + + songs, err := provider.SimilarSongs(ctx, "pl-2", 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(HaveLen(2)) + Expect([]string{songs[0].ID, songs[1].ID}).To(ConsistOf("s1", "s2")) + }) + + It("samples the album when the agent's picks are not in this library", func() { + // Last.fm answers from its own catalogue, so a small library can match none of it. + // An unmatched non-empty answer must not shortcut the sampling fallback. + album := model.Album{ID: "al-nm", Name: "NoMatch", AlbumArtist: "A"} + artistRepo.On("Get", "al-nm").Return(nil, model.ErrNotFound).Once() + albumRepo.On("Get", "al-nm").Return(&album, nil).Once() + agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "al-nm", "NoMatch", "A", "", 5). + Return([]agents.Song{{Name: "Not In Library"}}, nil).Once() + artistRepo.On("GetAll", mock.Anything).Return(model.Artists{}, nil).Maybe() + + // The matcher resolves nothing; the sampled seed is what reaches the mix. + mediaFileRepo.On("GetRandom", mock.Anything). + Return(model.MediaFiles{{ID: "s1", Title: "Album Track"}}, nil).Once() + agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Album Track", "", "", 5). + Return([]agents.Song{}, nil).Once() + mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{}, nil).Maybe() + + songs, err := provider.SimilarSongs(ctx, "al-nm", 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(HaveLen(1)) + Expect(songs[0].ID).To(Equal("s1")) + }) + + It("caps agent calls at maxSeeds when the repository ignores the bound", func() { + // Isolates seedMix's own cap: the album sampler bounds the query with Max, so this + // exercises the guard by having the repo hand back more rows than were asked for. + album := model.Album{ID: "al-cap", Name: "Cap", AlbumArtist: "A"} + artistRepo.On("Get", "al-cap").Return(nil, model.ErrNotFound).Once() + albumRepo.On("Get", "al-cap").Return(&album, nil).Once() + agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "al-cap", "Cap", "A", "", 5). + Return([]agents.Song{}, nil).Once() + + var overflow model.MediaFiles + for _, id := range []string{"t1", "t2", "t3", "t4", "t5", "t6", "t7", "t8"} { + overflow = append(overflow, model.MediaFile{ID: id, Title: id}) + } + mediaFileRepo.On("GetRandom", mock.Anything).Return(overflow, nil).Once() + agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, 5). + Return([]agents.Song{}, nil) + + _, err := provider.SimilarSongs(ctx, "al-cap", 5) + + Expect(err).ToNot(HaveOccurred()) + agentsCombined.AssertNumberOfCalls(GinkgoT(), "GetSimilarSongsByTrack", 5) + }) + + It("still finds maxSeeds distinct seeds when the leading positions repeat", func() { + // The sampler over-fetches for exactly this case: a page bounded at maxSeeds could + // be entirely one repeated file and collapse to a single seed. + pls := model.Playlist{ID: "pl-3", Name: "Big List"} + dup := model.MediaFile{ID: "dup", Title: "Dup", Artist: "A"} + tracks := model.PlaylistTracks{ + {MediaFile: dup}, {MediaFile: dup}, {MediaFile: dup}, {MediaFile: dup}, {MediaFile: dup}, + } + for _, id := range []string{"seed-1", "seed-2", "seed-3", "seed-4", "seed-5"} { + tracks = append(tracks, model.PlaylistTrack{ + MediaFile: model.MediaFile{ID: id, Title: id, Artist: "A"}, + }) + } + + artistRepo.On("Get", "pl-3").Return(nil, model.ErrNotFound).Once() + albumRepo.On("Get", "pl-3").Return(nil, model.ErrNotFound).Once() + playlistRepo.SetData(model.Playlists{pls}) + playlistTrackRepo.SetData(tracks) + + agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, 5). + Return([]agents.Song{}, nil) + + songs, err := provider.SimilarSongs(ctx, "pl-3", 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(HaveLen(5)) + Expect(playlistTrackRepo.Options.Sort).To(Equal("random")) + agentsCombined.AssertNumberOfCalls(GinkgoT(), "GetSimilarSongsByTrack", 5) + }) + }) + + Context("when ID is a Genre (not resolved by GetEntityByID)", func() { + It("samples genre songs and returns their track-similars", func() { + // GetEntityByID misses everywhere; the empty/auto-created mocks need no setup. + artistRepo.On("Get", "g-1").Return(nil, model.ErrNotFound).Once() + albumRepo.On("Get", "g-1").Return(nil, model.ErrNotFound).Once() + mediaFileRepo.On("Get", "g-1").Return(nil, model.ErrNotFound).Once() + genreRepo.Data = map[string]model.Genre{"g-1": {ID: "g-1", Name: "Jazz"}} + + // sampleGenreTracks -> GetRandom with the indexed media_file_tags semi-join (not a json_tree scan) + mediaFileRepo.On("GetRandom", mock.MatchedBy(func(opt model.QueryOptions) bool { + if opt.Filters == nil { + return false + } + sql, args, err := opt.Filters.ToSql() + return err == nil && strings.Contains(sql, "media_file_tags") && + !strings.Contains(sql, "json_tree") && strings.Contains(sql, "missing") && slices.Contains(args, any(false)) + })).Return(model.MediaFiles{{ID: "s1", Title: "Seed"}}, nil).Once() + agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed", "", "", 5). + Return([]agents.Song{{Name: "Similar"}}, nil).Once() + mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{{ID: "m1", Title: "Similar"}}, nil).Maybe() + + songs, err := provider.SimilarSongs(ctx, "g-1", 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).ToNot(BeEmpty()) + }) + }) }) It("returns similar songs from main artist and similar artists", func() { @@ -412,6 +821,13 @@ var _ = Describe("Provider - SimilarSongs", func() { mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything). Return(nil, errors.New("error getting top songs")).Once() + // Fallback yields nothing, so the sampling path is tried and also finds no tracks. + mediaFileRepo.On("GetRandom", mock.MatchedBy(func(opt model.QueryOptions) bool { + sql, args, err := opt.Filters.ToSql() + return err == nil && strings.Contains(sql, "artist_id") && + strings.Contains(sql, "missing") && slices.Contains(args, any(false)) && slices.Contains(args, any("artist-1")) + })).Return(model.MediaFiles{}, nil).Once() + songs, err := provider.SimilarSongs(ctx, "artist-1", 5) Expect(err).ToNot(HaveOccurred()) diff --git a/persistence/playlist_track_repository.go b/persistence/playlist_track_repository.go index 1a64efa20..c1f6fcf69 100644 --- a/persistence/playlist_track_repository.go +++ b/persistence/playlist_track_repository.go @@ -57,6 +57,7 @@ func (r *playlistRepository) Tracks(playlistId string, refreshSmartPlaylist bool "album_artist": "order_album_artist_name", "album": "order_album_name, album_id, disc_number, track_number, order_artist_name, title", "title": "order_title", + "random": "random()", // To make sure these fields will be whitelisted "duration": "duration", "year": "year", diff --git a/persistence/playlist_track_repository_test.go b/persistence/playlist_track_repository_test.go index 36f9ae4a9..a5c67b92c 100644 --- a/persistence/playlist_track_repository_test.go +++ b/persistence/playlist_track_repository_test.go @@ -37,6 +37,20 @@ var _ = Describe("PlaylistTrackRepository", func() { }) }) + Describe("GetAll", func() { + It("returns every row under a random sort, despite the integer id", func() { + // playlist_tracks.id is an INTEGER, so SEEDEDRAND drops every row unless it is cast to + // TEXT, and it fails silently: no error, just no rows. + all, err := repo.GetAll(model.QueryOptions{Sort: "random"}) + Expect(err).ToNot(HaveOccurred()) + Expect(all).To(HaveLen(2), "a random sort must not silently drop rows") + + got, err := repo.GetAll(model.QueryOptions{Sort: "random", Max: 1}) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(HaveLen(1)) + }) + }) + Describe("CountAll", func() { It("returns the number of tracks in the playlist", func() { Expect(repo.CountAll()).To(Equal(int64(2))) diff --git a/persistence/sql_base_repository.go b/persistence/sql_base_repository.go index 33450fe9f..428ba7a7b 100644 --- a/persistence/sql_base_repository.go +++ b/persistence/sql_base_repository.go @@ -284,7 +284,9 @@ func (r sqlRepository) resetSeededRandom(options []model.QueryOptions) { if len(options) == 0 || options[0].Sort != "random" { return } - options[0].Sort = fmt.Sprintf("SEEDEDRAND('%s', %s.id)", r.seedKey(), r.tableName) + // CAST: playlist_tracks.id is an INTEGER (unlike other tables' TEXT ids); passing it to + // SEEDEDRAND's string param uncast silently drops every row (go-sqlite3 binding gotcha). + options[0].Sort = fmt.Sprintf("SEEDEDRAND('%s', CAST(%s.id AS TEXT))", r.seedKey(), r.tableName) if options[0].Seed != "" { hasher.SetSeed(r.seedKey(), options[0].Seed) return diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go index 1f46c08b4..8a175df35 100644 --- a/server/jellyfin/api.go +++ b/server/jellyfin/api.go @@ -143,6 +143,7 @@ func (api *Router) routes() http.Handler { r.Get("/artists/{itemId}/similar", api.getSimilarArtists) r.Get("/items/{itemId}/similar", api.getSimilarItems) + r.Get("/albums/{itemId}/similar", api.getSimilarAlbums) r.Get("/items/{itemId}/instantmix", api.getInstantMix) r.Get("/genres", api.getGenres) r.Get("/musicgenres", api.getGenres) diff --git a/server/jellyfin/similar.go b/server/jellyfin/similar.go index 3e0b3b11b..82d12f52b 100644 --- a/server/jellyfin/similar.go +++ b/server/jellyfin/similar.go @@ -2,6 +2,7 @@ package jellyfin import ( "context" + "errors" "fmt" "net/http" "time" @@ -93,6 +94,19 @@ func (api *Router) getSimilarItems(w http.ResponseWriter, r *http.Request) { })) } +// getSimilarAlbums answers GET /Albums/{itemId}/Similar, powering Finamp's albumMix radio mode. +func (api *Router) getSimilarAlbums(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id, ok := itemIDParam(w, r, "itemId") + if !ok { + return + } + limit := clampLimit(req.Params(r).IntOr("limit", 0), defaultSimilarLimit, maxSimilarLimit) + api.ok(w, r, api.awaitSimilar(ctx, "albsim|"+id, limit, func(ctx context.Context) dto.QueryResult { + return api.similarAlbums(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. @@ -104,8 +118,10 @@ func (api *Router) getInstantMix(w http.ResponseWriter, r *http.Request) { } limit := clampLimit(req.Params(r).IntOr("limit", 0), defaultSimilarLimit, maxInstantMixLimit) + // Genre ids don't resolve via GetEntityByID, so a not-found entity is fine: it is just "not a + // song" and the provider knows what to do with it. A real lookup failure still stops here. entity, err := model.GetEntityByID(ctx, api.ds, id) - if err != nil { + if err != nil && !errors.Is(err, model.ErrNotFound) { api.ok(w, r, result(nil, 0, 0)) return } @@ -177,7 +193,8 @@ func (api *Router) similarAlbums(ctx context.Context, id string, limit int) dto. return result(nil, 0, 0) } u, _ := request.UserFrom(ctx) - seen := make(map[string]bool, limit) + // An album is not similar to itself, and the sampled-seed fallback returns its own tracks. + seen := map[string]bool{id: true} var items []dto.BaseItemDto for _, s := range songs { if s.AlbumID == "" || seen[s.AlbumID] { diff --git a/server/jellyfin/similar_test.go b/server/jellyfin/similar_test.go index dc8c1e605..cffcce0a8 100644 --- a/server/jellyfin/similar_test.go +++ b/server/jellyfin/similar_test.go @@ -164,4 +164,71 @@ var _ = Describe("getInstantMix", func() { 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") }) + + It("returns a mix for a genre id, which GetEntityByID can't resolve", func() { + ds := &tests.MockDataStore{} + songs := model.MediaFiles{ + {ID: testID("m1"), Title: "Track 1", LibraryID: 1}, + {ID: testID("m2"), Title: "Track 2", LibraryID: 1}, + } + api := &Router{ds: ds, provider: &fakeSimilarProvider{songs: songs}} + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("g1"))+"/InstantMix", nil). + WithContext(request.WithUser(context.Background(), model.User{ID: testID("u1"), Libraries: model.Libraries{{ID: 1}}})) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("g1"))) + api.getInstantMix(w, r) + + Expect(w.Code).To(Equal(200)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(HaveLen(2)) + }) +}) + +var _ = Describe("getSimilarAlbums", func() { + It("does not return the seed album as its own similar album", func() { + // With no external agent the provider falls back to the album's own tracks, which map + // straight back to the requested album. + ds := &tests.MockDataStore{} + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: testID("al-1"), Name: "Seed Album", LibraryID: 1}, + }) + api := &Router{ds: ds, provider: &fakeSimilarProvider{ + songs: model.MediaFiles{{ID: testID("m1"), AlbumID: testID("al-1"), LibraryID: 1}}, + }} + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Albums/"+dto.EncodeID(testID("al-1"))+"/Similar?limit=10", nil). + WithContext(request.WithUser(context.Background(), model.User{ID: testID("u1"), Libraries: model.Libraries{{ID: 1}}})) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("al-1"))) + api.getSimilarAlbums(w, r) + + Expect(w.Code).To(Equal(200)) + var res dto.QueryResult + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.Items).To(BeEmpty()) + }) + + It("returns albums derived from the provider's similar songs", func() { + ds := &tests.MockDataStore{} + ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: testID("al-2"), Name: "Other", LibraryID: 1}, + }) + api := &Router{ds: ds, provider: &fakeSimilarProvider{ + songs: model.MediaFiles{{ID: testID("m1"), AlbumID: testID("al-2"), LibraryID: 1}}, + }} + + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Albums/"+dto.EncodeID(testID("al-1"))+"/Similar?limit=10", nil). + WithContext(request.WithUser(context.Background(), model.User{ID: testID("u1"), Libraries: model.Libraries{{ID: 1}}})) + r = withChiURLParam(r, "itemId", dto.EncodeID(testID("al-1"))) + api.getSimilarAlbums(w, r) + + Expect(w.Code).To(Equal(200)) + 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("Other")) + }) }) diff --git a/tests/mock_playlist_repo.go b/tests/mock_playlist_repo.go index ee645e984..f04ed98c6 100644 --- a/tests/mock_playlist_repo.go +++ b/tests/mock_playlist_repo.go @@ -19,16 +19,17 @@ func CreateMockPlaylistRepo() *MockPlaylistRepo { type MockPlaylistRepo struct { model.PlaylistRepository - 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 - Ratings map[string]int // itemID -> rating - Err bool - TracksRepo model.PlaylistTrackRepository + 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 + Ratings map[string]int // itemID -> rating + Err bool + TracksRepo model.PlaylistTrackRepository + TracksRefreshed bool } func (m *MockPlaylistRepo) SetError(err bool) { @@ -163,7 +164,8 @@ func (m *MockPlaylistRepo) ReassignAnnotation(string, string) error { return nil } -func (m *MockPlaylistRepo) Tracks(_ string, _ bool) model.PlaylistTrackRepository { +func (m *MockPlaylistRepo) Tracks(_ string, refreshSmartPlaylist bool) model.PlaylistTrackRepository { + m.TracksRefreshed = refreshSmartPlaylist return m.TracksRepo }