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 01/22] 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 02/22] 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 03/22] 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 04/22] 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 05/22] 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 06/22] 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 07/22] 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 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 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 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 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 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 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 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 lkViXPNF6~_a0s1!3YZCG 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 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 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 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 08/22] 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 09/22] 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 10/22] 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 11/22] 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 12/22] 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 13/22] 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 14/22] 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 15/22] 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 16/22] 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 17/22] 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 18/22] 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 19/22] 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 20/22] 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 } From 757ca783d37ba6df1c2e186b3654bd01273d3b5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Thu, 13 Aug 2026 18:50:34 -0400 Subject: [PATCH 21/22] fix(instant-mix): top short mixes up instead of returning what the first source found (#5951) * fix(instant-mix): top short mixes up instead of returning what the first source found SimilarSongs returned the first non-empty source's tracks, however few. For a thinly-represented artist that meant a 3-track mix no matter the requested count: the artist agent found nothing, the similar-artists fallback matched 3 library tracks, and `len(res) > 0` kept seed-track sampling from ever running. Clients treat that as a failed mix and retry with a bigger limit forever. Finamp cycles limit 34 through 472 and starts over, ~1 request every 2s indefinitely, each one re-hitting Last.fm, Deezer and AudioMuse. Sources are now chained rather than raced: each one tops the mix up until it holds count tracks, so the agent's picks, the similar-artists fallback and seed-track sampling all contribute instead of the first one winning outright. * refactor(external): move similar-songs code to its own file provider.go held two distinct concerns: artist/album external metadata and the similar-songs mix pipeline. The mix code was already one contiguous block, and maxSeeds, maxSimilarSongs and dedupByID were used by nothing else. Moved SimilarSongs and its helpers to provider_similarsongs.go, matching the existing provider_similarsongs_test.go. Pure code motion: the moved block is byte-for-byte unchanged and provider.go has no additions, only deletions. * fix(instant-mix): dedup before deciding a mix is full topUp measured res before deduplicating it. Matcher.MatchSongs deliberately re-emits a library track when the same input song repeats, and the similar-artists fallback can reach one track through several artists, so len(res) could equal count while holding fewer unique tracks. That returned a mix with duplicates in it and stopped the top-up early; the caller then deduplicated and handed back a short mix, which is the client retry loop this branch set out to fix. Deduplicate first, so the length check counts what the client will actually receive. * perf(instant-mix): skip a fallback once the mix is already full The artist path nested one topUp inside another, so the inner one measured only similarSongsFallback's own result against the full count. With 49 agent matches and one fallback match for count=50 the mix was already full, yet seed-track sampling still ran and fired up to five GetSimilarSongsByTrack calls whose results the outer topUp then truncated away. topUp now takes the sources as a variadic list and re-checks the accumulated mix before each one, so a later, costlier source only runs while the mix is still short. That also flattens the artist case: the agent, the similar-artists fallback and seed-track sampling are now three peers in one chain instead of two nested calls. * fix(instant-mix): count distinct tracks when picking the fallback mix similarSongsFallback stopped after count picks from the weighted chooser, but a track can sit in that chooser once per artist listing it in their top songs, and Pick removes the entry it returns. Repeats therefore consumed pick slots and left unique candidates stranded, so the batch could come back short of count. On the track path this is the only source, so that short mix reached the client and kept the retry loop alive. Track the ids already picked and keep drawing until count distinct tracks are held or the chooser is empty. * fix(instant-mix): match the whole agent response before trimming MatchSongs was capped at count, and it re-emits a track when the same song repeats, so [A, A, B] with count=2 returned [A, A] and never reached B. topUp then shrank that to [A] and, with an empty or overlapping fallback, the mix stayed short even though B had been available all along. seedMix already matched its full merged set for this reason; mixFromAgent now does the same and leaves the trim to topUp. Also drop the capacity hint on the picked-ids map. It was sized from the caller's count, which CodeQL flags as an allocation sized by user input (go/uncontrolled- allocation-size). SimilarSongs clamps count to maxSimilarSongs long before this point, so the hint bought nothing worth the alert. * refactor(instant-mix): tidy the mix chain and its specs Quality pass over the new code, no behaviour change: - topUp: drop the first-vs-last error bookkeeping (the value is only read when the mix is empty, so the distinction is unobservable) and the redundant nil guard (dedupByID returns nil for an empty result, so both branches already agreed). - mixFromAgent: assign through the if-scoped err instead of a second error name. - Hoist the similar-artists fallback closure written verbatim in two switch arms. - Use map[string]struct{} in the pick loop, matching dedupByID in the same file. - Trim three comments back within budget; two restated the line below them and one carried commit-message rationale. - Tests: add an ids() helper for the ID assertion repeated seven times, and fold the track-entity stub block copied into three specs into stubTrackEntity. The block hard-coded .Twice() on GetEntityByID, which pinned an implementation detail no spec asserts. * revert(instant-mix): inline the similar-artists fallback closure again Hoisting it to a shared artistFallback var moved the call away from the arm that uses it and saved nothing: each arm reads better spelling out its own sources. --- core/external/extdata_helper_test.go | 5 + core/external/provider.go | 255 +---------------- core/external/provider_similarsongs.go | 299 ++++++++++++++++++++ core/external/provider_similarsongs_test.go | 219 +++++++++++++- 4 files changed, 515 insertions(+), 263 deletions(-) create mode 100644 core/external/provider_similarsongs.go diff --git a/core/external/extdata_helper_test.go b/core/external/extdata_helper_test.go index d68147168..f7a155cd9 100644 --- a/core/external/extdata_helper_test.go +++ b/core/external/extdata_helper_test.go @@ -6,6 +6,7 @@ import ( "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/slice" "github.com/stretchr/testify/mock" ) @@ -324,3 +325,7 @@ func (m *mockAgents) GetSimilarSongsByArtist(ctx context.Context, id, name, mbid } return nil, args.Error(1) } + +func ids(mfs model.MediaFiles) []string { + return slice.Map(mfs, func(mf model.MediaFile) string { return mf.ID }) +} diff --git a/core/external/provider.go b/core/external/provider.go index 383e7d4c9..782c7c3aa 100644 --- a/core/external/provider.go +++ b/core/external/provider.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "math/rand/v2" "sort" "strings" "time" @@ -15,21 +14,15 @@ 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" "github.com/navidrome/navidrome/utils/slice" "github.com/navidrome/navidrome/utils/str" "golang.org/x/sync/errgroup" ) const ( - 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 + maxSimilarArtists = 100 refreshDelay = 5 * time.Second refreshTimeout = 15 * time.Second refreshQueueLength = 2000 @@ -283,252 +276,6 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au return artist, nil } -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 { - // 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) - }) - } - - // Try entity-specific similarity first, then fall back to seed-track sampling. - switch v := entity.(type) { - case *model.MediaFile: - 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: - 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: - 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 - } -} - -// 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() -} - -// 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 -// get the artist of the given entity, retrieve similar artists, get their top songs, and pick -// a weighted random selection of songs to return as similar songs. -func (e *provider) similarSongsFallback(ctx context.Context, id string, count int) (model.MediaFiles, error) { - artist, err := e.getArtist(ctx, id) - if err != nil { - return nil, err - } - - e.callGetSimilarArtists(ctx, e.ag, &artist, 15, false) - if utils.IsCtxDone(ctx) { - log.Warn(ctx, "SimilarSongs call canceled", ctx.Err()) - return nil, ctx.Err() - } - - weightedSongs := random.NewWeightedChooser[model.MediaFile]() - addArtist := func(a model.Artist, weightedSongs *random.WeightedChooser[model.MediaFile], count, artistWeight int) error { - if utils.IsCtxDone(ctx) { - log.Warn(ctx, "SimilarSongs call canceled", ctx.Err()) - return ctx.Err() - } - - topCount := max(count, 20) - topSongs, err := e.getMatchingTopSongs(ctx, e.ag, &auxArtist{Artist: a}, topCount) - if err != nil { - log.Warn(ctx, "Error getting artist's top songs", "artist", a.Name, err) - return nil - } - - weight := topCount * (4 + artistWeight) - for _, mf := range topSongs { - weightedSongs.Add(mf, weight) - weight -= 4 - } - return nil - } - - err = addArtist(artist.Artist, weightedSongs, count, 10) - if err != nil { - return nil, err - } - for _, a := range artist.SimilarArtists { - err := addArtist(a, weightedSongs, count, 0) - if err != nil { - return nil, err - } - } - - var similarSongs model.MediaFiles - for len(similarSongs) < count && weightedSongs.Size() > 0 { - s, err := weightedSongs.Pick() - if err != nil { - log.Warn(ctx, "Error getting weighted song", err) - continue - } - similarSongs = append(similarSongs, s) - } - - return similarSongs, nil -} - 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 { diff --git a/core/external/provider_similarsongs.go b/core/external/provider_similarsongs.go new file mode 100644 index 000000000..22b20565b --- /dev/null +++ b/core/external/provider_similarsongs.go @@ -0,0 +1,299 @@ +package external + +import ( + "context" + "errors" + "fmt" + "math/rand/v2" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/core/agents" + "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/random" + "github.com/navidrome/navidrome/utils/slice" + "golang.org/x/sync/errgroup" +) + +const ( + 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 +) + +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 { + // 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) + }) + } + + // Try entity-specific similarity first, then fall back to seed-track sampling. + switch v := entity.(type) { + case *model.MediaFile: + 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: + 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: + 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) { + return e.similarSongsFallback(ctx, id, count) + }, + func() (model.MediaFiles, error) { + 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 + } +} + +// mixFromAgent returns the agent's recommendations matched to library tracks, topped up from the +// fallbacks in order when they don't fill the mix on their own. +func (e *provider) mixFromAgent(ctx context.Context, count int, fetch func() ([]agents.Song, error), fallbacks ...func() (model.MediaFiles, error)) (model.MediaFiles, error) { + var matched model.MediaFiles + if songs, err := fetch(); err == nil { + // Match the whole response: capping at count can stop before a later unique pick. + matched, err = e.matcher.MatchSongs(ctx, songs, len(songs)) + if err != nil { + return nil, err + } + } + return topUp(ctx, matched, count, fallbacks...) +} + +// topUp draws on each source in turn until the mix holds count distinct tracks. +func topUp(ctx context.Context, res model.MediaFiles, count int, sources ...func() (model.MediaFiles, error)) (model.MediaFiles, error) { + // The matcher can re-emit a track, so a full-looking res may hold fewer than count unique ones. + res = dedupByID(res) + var lastErr error + for _, more := range sources { + if len(res) >= count { + break + } + extra, err := more() + if err != nil { + log.Debug(ctx, "Could not top up a short mix", "have", len(res), "want", count, err) + lastErr = err + continue + } + res = dedupByID(append(res, extra...)) + } + if len(res) == 0 { + return nil, lastErr + } + return res[:min(len(res), count)], nil +} + +// 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 +// get the artist of the given entity, retrieve similar artists, get their top songs, and pick +// a weighted random selection of songs to return as similar songs. +func (e *provider) similarSongsFallback(ctx context.Context, id string, count int) (model.MediaFiles, error) { + artist, err := e.getArtist(ctx, id) + if err != nil { + return nil, err + } + + e.callGetSimilarArtists(ctx, e.ag, &artist, 15, false) + if utils.IsCtxDone(ctx) { + log.Warn(ctx, "SimilarSongs call canceled", ctx.Err()) + return nil, ctx.Err() + } + + weightedSongs := random.NewWeightedChooser[model.MediaFile]() + addArtist := func(a model.Artist, weightedSongs *random.WeightedChooser[model.MediaFile], count, artistWeight int) error { + if utils.IsCtxDone(ctx) { + log.Warn(ctx, "SimilarSongs call canceled", ctx.Err()) + return ctx.Err() + } + + topCount := max(count, 20) + topSongs, err := e.getMatchingTopSongs(ctx, e.ag, &auxArtist{Artist: a}, topCount) + if err != nil { + log.Warn(ctx, "Error getting artist's top songs", "artist", a.Name, err) + return nil + } + + weight := topCount * (4 + artistWeight) + for _, mf := range topSongs { + weightedSongs.Add(mf, weight) + weight -= 4 + } + return nil + } + + err = addArtist(artist.Artist, weightedSongs, count, 10) + if err != nil { + return nil, err + } + for _, a := range artist.SimilarArtists { + err := addArtist(a, weightedSongs, count, 0) + if err != nil { + return nil, err + } + } + + // Count distinct tracks, not picks: a collaboration sits in the chooser once per artist that + // lists it, and letting those repeats consume the budget strands unique candidates. + var similarSongs model.MediaFiles + picked := map[string]struct{}{} + for len(similarSongs) < count && weightedSongs.Size() > 0 { + s, err := weightedSongs.Pick() + if err != nil { + log.Warn(ctx, "Error getting weighted song", err) + continue + } + if _, dup := picked[s.ID]; dup { + continue + } + picked[s.ID] = struct{}{} + similarSongs = append(similarSongs, s) + } + + return similarSongs, nil +} diff --git a/core/external/provider_similarsongs_test.go b/core/external/provider_similarsongs_test.go index 5617269b3..ac54495f4 100644 --- a/core/external/provider_similarsongs_test.go +++ b/core/external/provider_similarsongs_test.go @@ -13,7 +13,6 @@ 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" @@ -65,6 +64,18 @@ var _ = Describe("Provider - SimilarSongs", func() { provider = NewProvider(ds, agentsCombined, matcher.New(ds)) }) + // Resolves track-1 through the GetEntityByID probe order and on to its artist. Left permissive: + // no spec here asserts how many times the entity is looked up. + stubTrackEntity := func() { + track := model.MediaFile{ID: "track-1", Title: "Track", Artist: "Artist", ArtistID: "artist-1"} + artist := model.Artist{ID: "artist-1", Name: "Artist"} + artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Maybe() + albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Maybe() + mediaFileRepo.On("Get", "track-1").Return(&track, nil).Maybe() + artistRepo.On("Get", "artist-1").Return(&artist, nil).Maybe() + artistRepo.On("GetAll", mock.Anything).Return(model.Artists{artist}, nil).Maybe() + } + Describe("dispatch by entity type", func() { Context("when ID is a MediaFile (track)", func() { It("calls GetSimilarSongsByTrack and returns matched songs", func() { @@ -83,7 +94,7 @@ var _ = Describe("Provider - SimilarSongs", func() { albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once() mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once() - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Just Can't Get Enough", "Depeche Mode", "track-mbid", 5). + agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Just Can't Get Enough", "Depeche Mode", "track-mbid", 1). Return([]agents.Song{ {Name: "Dreaming of Me", MBID: "", Artists: []agents.Artist{{Name: "Depeche Mode", MBID: "artist-mbid"}}}, }, nil).Once() @@ -126,7 +137,7 @@ var _ = Describe("Provider - SimilarSongs", func() { return false })).Return(model.MediaFiles{matchedSong}, nil).Maybe() - songs, err := provider.SimilarSongs(ctx, "track-1", 5) + songs, err := provider.SimilarSongs(ctx, "track-1", 1) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(1)) @@ -176,6 +187,76 @@ var _ = Describe("Provider - SimilarSongs", func() { Expect(songs).To(HaveLen(1)) Expect(songs[0].ID).To(Equal("song-1")) }) + + It("tops the mix up with the fallback when the agent's picks alone are too few", func() { + stubTrackEntity() + + // The agent knows one track of the three asked for. + agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Track", "Artist", "", 3). + Return([]agents.Song{{Name: "Agent Pick", MBID: "mbid-agent"}}, nil).Once() + mediaFileRepo.On("GetAll", mock.Anything). + Return(model.MediaFiles{{ID: "agent-1", Title: "Agent Pick", MbzRecordingID: "mbid-agent"}}, nil).Once() + + mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist", "", 15). + Return([]agents.Artist{}, 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.Anything). + Return(model.MediaFiles{{ID: "song-1", Title: "Song One", MbzRecordingID: "mbid-1"}}, nil).Once() + + songs, err := provider.SimilarSongs(ctx, "track-1", 3) + + Expect(err).ToNot(HaveOccurred()) + Expect(ids(songs)).To(ConsistOf("agent-1", "song-1")) + }) + + It("reaches a unique pick that sits past the count-th repeat", func() { + stubTrackEntity() + 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() + + // "Song B" sits past the second repeat, so a matcher capped at count never reaches it. + repeated := agents.Song{Name: "Song A", MBID: "mbid-a"} + agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Track", "Artist", "", 2). + Return([]agents.Song{repeated, repeated, {Name: "Song B", MBID: "mbid-b"}}, nil).Once() + mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{ + {ID: "t-a", Title: "Song A", MbzRecordingID: "mbid-a"}, + {ID: "t-b", Title: "Song B", MbzRecordingID: "mbid-b"}, + }, nil).Once() + + songs, err := provider.SimilarSongs(ctx, "track-1", 2) + + Expect(err).ToNot(HaveOccurred()) + Expect(ids(songs)).To(ConsistOf("t-a", "t-b")) + }) + + It("keeps topping up when the agent's picks repeat a track", func() { + stubTrackEntity() + + // The matcher re-emits a track when the same input song repeats, so these three + // picks resolve to only two distinct library tracks. + repeated := agents.Song{Name: "Song A", MBID: "mbid-a"} + agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Track", "Artist", "", 3). + Return([]agents.Song{repeated, repeated, {Name: "Song B", MBID: "mbid-b"}}, nil).Once() + mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{ + {ID: "t-a", Title: "Song A", MbzRecordingID: "mbid-a"}, + {ID: "t-b", Title: "Song B", MbzRecordingID: "mbid-b"}, + }, nil).Once() + + mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist", "", 15). + Return([]agents.Artist{}, nil).Once() + mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist", "", mock.Anything). + Return([]agents.Song{{Name: "Song C", MBID: "mbid-c"}}, nil).Once() + mediaFileRepo.On("GetAll", mock.Anything). + Return(model.MediaFiles{{ID: "t-c", Title: "Song C", MbzRecordingID: "mbid-c"}}, nil).Once() + + songs, err := provider.SimilarSongs(ctx, "track-1", 3) + + Expect(err).ToNot(HaveOccurred()) + Expect(ids(songs)).To(ConsistOf("t-a", "t-b", "t-c")) + }) }) Context("when ID is an Album", func() { @@ -187,7 +268,7 @@ var _ = Describe("Provider - SimilarSongs", func() { 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", "Speak & Spell", "Depeche Mode", "album-mbid", 5). + agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "album-1", "Speak & Spell", "Depeche Mode", "album-mbid", 1). Return([]agents.Song{ {Name: "New Life", MBID: "song-mbid", Artists: []agents.Artist{{Name: "Depeche Mode"}}}, }, nil).Once() @@ -208,7 +289,7 @@ var _ = Describe("Provider - SimilarSongs", func() { return hasEq })).Return(model.MediaFiles{matchedSong}, nil).Once() - songs, err := provider.SimilarSongs(ctx, "album-1", 5) + songs, err := provider.SimilarSongs(ctx, "album-1", 1) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(1)) @@ -286,7 +367,7 @@ var _ = Describe("Provider - SimilarSongs", func() { matchedSong := model.MediaFile{ID: "matched-1", Title: "Enjoy the Silence", Artist: "Depeche Mode", MbzRecordingID: "song-mbid"} artistRepo.On("Get", "artist-1").Return(&artist, nil).Once() - agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Depeche Mode", "artist-mbid", 5). + agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Depeche Mode", "artist-mbid", 1). Return([]agents.Song{ {Name: "Enjoy the Silence", MBID: "song-mbid", Artists: []agents.Artist{{Name: "Depeche Mode"}}}, }, nil).Once() @@ -307,7 +388,7 @@ var _ = Describe("Provider - SimilarSongs", func() { return hasEq })).Return(model.MediaFiles{matchedSong}, nil).Once() - songs, err := provider.SimilarSongs(ctx, "artist-1", 5) + songs, err := provider.SimilarSongs(ctx, "artist-1", 1) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(1)) @@ -348,6 +429,72 @@ var _ = Describe("Provider - SimilarSongs", func() { }) }) + Context("when ID is an Artist and the similar-artists fallback can't fill the mix", func() { + It("tops the mix up with the artist's own track-similars", func() { + artist := model.Artist{ID: "ar-1", Name: "Thin Artist"} + topSong := model.MediaFile{ID: "top-1", Title: "Top Song", ArtistID: "ar-1", MbzRecordingID: "mbid-top"} + + artistRepo.On("Get", "ar-1").Return(&artist, nil).Maybe() + artistRepo.On("GetAll", mock.Anything).Return(model.Artists{artist}, nil).Maybe() + + agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "ar-1", "Thin Artist", "", 5). + Return([]agents.Song{}, nil).Once() + + // No similar artist is in the library, so the fallback yields only the seed artist's + // own matching top song: one track for a mix of five. + mockAgent.On("GetSimilarArtists", mock.Anything, "ar-1", "Thin Artist", "", 15). + Return([]agents.Artist{}, nil).Once() + mockAgent.On("GetArtistTopSongs", mock.Anything, "ar-1", "Thin Artist", "", mock.Anything). + Return([]agents.Song{{Name: "Top Song", MBID: "mbid-top"}}, nil).Once() + mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{topSong}, nil).Once() + + mediaFileRepo.On("GetRandom", mock.Anything). + Return(model.MediaFiles{{ID: "s1", Title: "Seed", Artist: "Thin Artist"}}, nil).Once() + agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed", "Thin Artist", "", mock.Anything). + Return([]agents.Song{{Name: "Mix Song", MBID: "mbid-mix"}}, nil).Once() + mediaFileRepo.On("GetAll", mock.Anything). + Return(model.MediaFiles{{ID: "mix-1", Title: "Mix Song", MbzRecordingID: "mbid-mix"}}, nil).Once() + + songs, err := provider.SimilarSongs(ctx, "ar-1", 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(ids(songs)).To(ConsistOf("top-1", "mix-1")) + }) + }) + + Context("when ID is an Artist and the agent plus the similar-artists fallback already fill the mix", func() { + It("does not pay for seed-track sampling", func() { + artist := model.Artist{ID: "ar-1", Name: "The Artist"} + artistRepo.On("Get", "ar-1").Return(&artist, nil).Maybe() + artistRepo.On("GetAll", mock.Anything).Return(model.Artists{artist}, nil).Maybe() + + agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "ar-1", "The Artist", "", 3). + Return([]agents.Song{{Name: "A", MBID: "mbid-a"}, {Name: "B", MBID: "mbid-b"}}, nil).Once() + mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{ + {ID: "t-a", Title: "A", MbzRecordingID: "mbid-a"}, + {ID: "t-b", Title: "B", MbzRecordingID: "mbid-b"}, + }, nil).Once() + + // The similar-artists fallback supplies the third track, so the mix is full. + mockAgent.On("GetSimilarArtists", mock.Anything, "ar-1", "The Artist", "", 15). + Return([]agents.Artist{}, nil).Once() + mockAgent.On("GetArtistTopSongs", mock.Anything, "ar-1", "The Artist", "", mock.Anything). + Return([]agents.Song{{Name: "C", MBID: "mbid-c"}}, nil).Once() + mediaFileRepo.On("GetAll", mock.Anything). + Return(model.MediaFiles{{ID: "t-c", Title: "C", MbzRecordingID: "mbid-c"}}, nil).Once() + + mediaFileRepo.On("GetRandom", mock.Anything).Return(model.MediaFiles{{ID: "seed"}}, nil).Maybe() + 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, "ar-1", 3) + + Expect(err).ToNot(HaveOccurred()) + Expect(ids(songs)).To(ConsistOf("t-a", "t-b", "t-c")) + mediaFileRepo.AssertNotCalled(GinkgoT(), "GetRandom", mock.Anything) + }) + }) + 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"} @@ -553,7 +700,7 @@ var _ = Describe("Provider - SimilarSongs", func() { Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(3)) - ids := slice.Map(songs, func(mf model.MediaFile) string { return mf.ID }) + ids := ids(songs) Expect(ids).To(ContainElement(BeElementOf("b1", "b2")), "seed two must be represented in the mix") }) @@ -750,6 +897,60 @@ var _ = Describe("Provider - SimilarSongs", func() { } }) + It("keeps picking until the fallback holds count distinct tracks", func() { + // A collaboration in two artists' top songs lands in the chooser twice. Picking a fixed + // count of entries lets those duplicates eat the budget and strand unique candidates. + track := model.MediaFile{ID: "track-1", Title: "Track", Artist: "Artist One", ArtistID: "artist-1"} + artist1 := model.Artist{ID: "artist-1", Name: "Artist One"} + similarArtist := model.Artist{ID: "artist-3", Name: "Similar Artist"} + + artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Twice() + albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Twice() + mediaFileRepo.On("Get", "track-1").Return(&track, nil).Twice() + artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe() + artistRepo.On("Get", "artist-3").Return(&similarArtist, nil).Maybe() + artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { + return opt.Max == 1 && opt.Filters != nil + })).Return(model.Artists{artist1}, nil).Maybe() + + agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Track", "Artist One", "", 3). + Return([]agents.Song{}, nil).Once() + + mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15). + Return([]agents.Artist{{Name: "Similar Artist"}}, nil).Once() + artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { + _, ok := opt.Filters.(squirrel.Eq) + return opt.Max == 0 && ok + })).Return(model.Artists{}, nil).Once() + artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { + _, ok := opt.Filters.(squirrel.Or) + return opt.Max == 0 && ok + })).Return(model.Artists{similarArtist}, nil).Once() + + shared := model.MediaFile{ID: "t-a", Title: "Shared", MbzRecordingID: "mbid-a"} + mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything). + Return([]agents.Song{{Name: "Shared", MBID: "mbid-a"}}, nil).Once() + mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")). + Return(model.MediaFiles{shared}, nil).Once() + + mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-3", "Similar Artist", "", mock.Anything). + Return([]agents.Song{ + {Name: "Shared", MBID: "mbid-a"}, + {Name: "B", MBID: "mbid-b"}, + {Name: "C", MBID: "mbid-c"}, + }, nil).Once() + mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{ + shared, + {ID: "t-b", Title: "B", MbzRecordingID: "mbid-b"}, + {ID: "t-c", Title: "C", MbzRecordingID: "mbid-c"}, + }, nil).Once() + + songs, err := provider.SimilarSongs(ctx, "track-1", 3) + + Expect(err).ToNot(HaveOccurred()) + Expect(ids(songs)).To(ConsistOf("t-a", "t-b", "t-c")) + }) + It("returns ErrNotFound when artist is not found", func() { artistRepo.On("Get", "artist-unknown-artist").Return(nil, model.ErrNotFound) mediaFileRepo.On("Get", "artist-unknown-artist").Return(nil, model.ErrNotFound) @@ -792,7 +993,7 @@ var _ = Describe("Provider - SimilarSongs", func() { mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once() - songs, err := provider.SimilarSongs(ctx, "artist-1", 5) + songs, err := provider.SimilarSongs(ctx, "artist-1", 1) Expect(err).ToNot(HaveOccurred()) Expect(songs).To(HaveLen(1)) From 59a4ed8e794e73fd41f3af7000a34ac86e718858 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Thu, 13 Aug 2026 22:56:09 -0400 Subject: [PATCH 22/22] fix(plugins): stop reporting plugin call failures as not-found (#5953) * fix(plugins): stop reporting plugin call failures as not-found MetadataAgent joined agents.ErrNotFound onto every failed plugin call, so a transport fault was indistinguishable from a definitive miss. The artwork circuit breaker treats a not-found as a successful, definitive answer and resets its failure counter, so it never opened for a failing plugin and kept calling it on every request. Observed with the apple-music plugin against prod: ~900 iTunes 429s in 27 minutes with the breaker never tripping. Return the underlying error instead. The genuine empty-result branches still return agents.ErrNotFound, and agent fallback is unaffected because callAgentMethod/callAgentSliceMethod continue on any error, not only on ErrNotFound. * test(plugins): fold duplicate metadata agent error specs into one table The error-handling container drove all 11 MetadataAgent methods twice: once to assert the message, once to assert the failure is not an ErrNotFound. The argument lists were identical, so each method cost two WASM instantiations for one method's worth of coverage, and a new capability had to be registered in two places to stay guarded. Fold both assertions into a single DescribeTable, document the ErrNotFound contract at the sentinel where agent implementers will read it, and collapse breaker.record's hand-inlined predicate onto isTransientExternal, which it already duplicated by hand with a keep-in-sync comment. * fix(plugins): keep an unimplemented plugin method a definitive miss Returning the raw plugin error made errNotImplemented and errFunctionNotFound look like provider faults. Every MetadataAgent satisfies ArtistImageRetriever and AlbumImageRetriever regardless of what the plugin actually exports, so artwork resolution calls those stubs on a partially-implemented plugin: each call counted toward the artwork circuit breaker and kept the item in the retry queue instead of settling it absent. Map both sentinels back onto agents.ErrNotFound, joined so the underlying reason survives for diagnostics, and leave real call failures untouched. This matches what ScrobblerPlugin already does for the same two sentinels. The partial-implementation specs asserted only MatchError(errNotImplemented), which the previous errors.Join satisfied incidentally, so nothing caught the lost not-found semantics. They now assert both and are folded into one table. * test(plugins): cover the missing-export arm of agentErr The partial-metadata-agent fixture registers through the Go PDK, which exports every method and answers with the not-implemented code, so no fixture reaches the errFunctionNotFound branch. Building one would mean hand-writing Extism exports to deliberately omit a function, which tests the manager's function lookup rather than the mapping this PR added. Cover agentErr directly instead: both sentinels classify as a definitive miss, a call failure and a non-zero exit stay faults, and the underlying reason survives in every case. * fix(artwork): stop counting a cancelled run against the circuit breaker callPluginFunction returns ctx.Err() when a plugin call is cancelled, and that reached breaker.record as an ordinary error, so cancellations counted toward the five consecutive failures that open a gate. A cancellation says nothing about the provider, so it now neither counts nor clears the failure run. Deliberately scoped to breaker.record rather than isTransientExternal: the latter also drives whether the queue item is rescheduled, and a cancelled item must still be retried rather than settling absent. context.DeadlineExceeded is left counting as a fault, since a provider that blows the budget is one worth backing off from. Reachable today only at shutdown, where the in-memory breaker state is discarded anyway. It becomes live the moment Worker.gate is used on a request-scoped context, which is why it is worth closing now. --- core/agents/interfaces.go | 2 + core/artwork/gate.go | 8 +- core/artwork/worker_test.go | 37 +++++ plugins/metadata_agent.go | 27 ++-- plugins/metadata_agent_test.go | 262 ++++++++++++++++----------------- 5 files changed, 187 insertions(+), 149 deletions(-) diff --git a/core/agents/interfaces.go b/core/agents/interfaces.go index d5f4a6580..7fc5de361 100644 --- a/core/agents/interfaces.go +++ b/core/agents/interfaces.go @@ -53,6 +53,8 @@ func (s Song) Equals(other Song) bool { } var ( + // ErrNotFound means the provider answered and had nothing. Return the underlying error + // for a fault instead, or callers that back off on faults will treat it as definitive. ErrNotFound = errors.New("not found") ) diff --git a/core/artwork/gate.go b/core/artwork/gate.go index a64c11a20..ff77f733d 100644 --- a/core/artwork/gate.go +++ b/core/artwork/gate.go @@ -1,6 +1,7 @@ package artwork import ( + "context" "errors" "io" "sync" @@ -101,10 +102,13 @@ func (b *breaker) allow() bool { } func (b *breaker) record(name string, err error) { + // A cancelled run says nothing about the provider, so it neither counts nor clears. + if errors.Is(err, context.Canceled) { + return + } 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 !isTransientExternal(err) { if b.failures >= breakerThreshold { log.Info("Artwork: Circuit breaker closed for agent", "agent", name) } diff --git a/core/artwork/worker_test.go b/core/artwork/worker_test.go index 5b6e42885..9fad4b503 100644 --- a/core/artwork/worker_test.go +++ b/core/artwork/worker_test.go @@ -539,6 +539,43 @@ var _ = Describe("Worker", func() { Expect(calls).To(Equal(5), "the breaker should have re-closed after the success") }) + It("does not open the breaker when the run is cancelled", func() { + cancelled := func() (io.ReadCloser, string, error) { return nil, "", context.Canceled } + for range breakerThreshold + 3 { + _, _, err := w.gate("A", cancelled) + Expect(err).To(MatchError(context.Canceled), "a cancellation 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("ignores a cancellation mid-run, neither counting nor clearing the failures", func() { + failing := func() (io.ReadCloser, string, error) { return nil, "", errors.New("boom") } + cancelled := func() (io.ReadCloser, string, error) { return nil, "", context.Canceled } + for range breakerThreshold - 1 { + _, _, _ = w.gate("A", failing) + } + _, _, _ = w.gate("A", cancelled) + + 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 cancellation must not have counted as the final failure") + + _, _, err := w.gate("A", counting) + Expect(err).To(MatchError(errBreakerOpen), "the cancellation must not have cleared the earlier failures") + Expect(calls).To(Equal(1), "an open breaker must not call the external step") + }) + 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. diff --git a/plugins/metadata_agent.go b/plugins/metadata_agent.go index e5d6d43fc..607926438 100644 --- a/plugins/metadata_agent.go +++ b/plugins/metadata_agent.go @@ -50,6 +50,15 @@ func newMetadataAgent(p *plugin) *MetadataAgent { return &MetadataAgent{name: p.name, plugin: p} } +// agentErr keeps a plugin fault distinguishable from a definitive miss: a method the plugin +// simply does not implement has answered, so it must not count against a caller's back-off. +func agentErr(err error) error { + if errors.Is(err, errNotImplemented) || errors.Is(err, errFunctionNotFound) { + return errors.Join(agents.ErrNotFound, err) + } + return err +} + // MetadataAgent is an adapter that wraps an Extism plugin and implements // the agents interfaces for metadata retrieval. type MetadataAgent struct { @@ -69,7 +78,7 @@ func (a *MetadataAgent) GetArtistMBID(ctx context.Context, id string, name strin input := capabilities.ArtistMBIDRequest{ID: id, Name: name} result, err := callPluginFunction[capabilities.ArtistMBIDRequest, *capabilities.ArtistMBIDResponse](ctx, a.plugin, FuncGetArtistMBID, input) if err != nil { - return "", errors.Join(agents.ErrNotFound, err) + return "", agentErr(err) } if result == nil || result.MBID == "" { @@ -84,7 +93,7 @@ func (a *MetadataAgent) GetArtistURL(ctx context.Context, id, name, mbid string) input := capabilities.ArtistRequest{ID: id, Name: name, MBID: mbid} result, err := callPluginFunction[capabilities.ArtistRequest, *capabilities.ArtistURLResponse](ctx, a.plugin, FuncGetArtistURL, input) if err != nil { - return "", errors.Join(agents.ErrNotFound, err) + return "", agentErr(err) } if result == nil || result.URL == "" { return "", agents.ErrNotFound @@ -97,7 +106,7 @@ func (a *MetadataAgent) GetArtistBiography(ctx context.Context, id, name, mbid s input := capabilities.ArtistRequest{ID: id, Name: name, MBID: mbid} result, err := callPluginFunction[capabilities.ArtistRequest, *capabilities.ArtistBiographyResponse](ctx, a.plugin, FuncGetArtistBiography, input) if err != nil { - return "", errors.Join(agents.ErrNotFound, err) + return "", agentErr(err) } if result == nil || result.Biography == "" { @@ -112,7 +121,7 @@ func (a *MetadataAgent) GetSimilarArtists(ctx context.Context, id, name, mbid st input := capabilities.SimilarArtistsRequest{ID: id, Name: name, MBID: mbid, Limit: int32(limit)} result, err := callPluginFunction[capabilities.SimilarArtistsRequest, *capabilities.SimilarArtistsResponse](ctx, a.plugin, FuncGetSimilarArtists, input) if err != nil { - return nil, errors.Join(agents.ErrNotFound, err) + return nil, agentErr(err) } if result == nil || len(result.Artists) == 0 { @@ -132,7 +141,7 @@ func (a *MetadataAgent) GetArtistImages(ctx context.Context, id, name, mbid stri input := capabilities.ArtistRequest{ID: id, Name: name, MBID: mbid} result, err := callPluginFunction[capabilities.ArtistRequest, *capabilities.ArtistImagesResponse](ctx, a.plugin, FuncGetArtistImages, input) if err != nil { - return nil, errors.Join(agents.ErrNotFound, err) + return nil, agentErr(err) } if result == nil || len(result.Images) == 0 { @@ -152,7 +161,7 @@ func (a *MetadataAgent) GetArtistTopSongs(ctx context.Context, id, artistName, m input := capabilities.TopSongsRequest{ID: id, Name: artistName, MBID: mbid, Count: int32(count)} result, err := callPluginFunction[capabilities.TopSongsRequest, *capabilities.TopSongsResponse](ctx, a.plugin, FuncGetArtistTopSongs, input) if err != nil { - return nil, errors.Join(agents.ErrNotFound, err) + return nil, agentErr(err) } if result == nil || len(result.Songs) == 0 { @@ -167,7 +176,7 @@ func (a *MetadataAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid str input := capabilities.AlbumRequest{Name: name, Artist: artist, MBID: mbid} result, err := callPluginFunction[capabilities.AlbumRequest, *capabilities.AlbumInfoResponse](ctx, a.plugin, FuncGetAlbumInfo, input) if err != nil { - return nil, errors.Join(agents.ErrNotFound, err) + return nil, agentErr(err) } if result == nil { @@ -187,7 +196,7 @@ func (a *MetadataAgent) GetAlbumImages(ctx context.Context, name, artist, mbid s input := capabilities.AlbumRequest{Name: name, Artist: artist, MBID: mbid} result, err := callPluginFunction[capabilities.AlbumRequest, *capabilities.AlbumImagesResponse](ctx, a.plugin, FuncGetAlbumImages, input) if err != nil { - return nil, errors.Join(agents.ErrNotFound, err) + return nil, agentErr(err) } if result == nil || len(result.Images) == 0 { @@ -205,7 +214,7 @@ func (a *MetadataAgent) GetAlbumImages(ctx context.Context, name, artist, mbid s func callSimilarSongsPluginFunction[T any](ctx context.Context, plugin *plugin, funcName string, input T) ([]agents.Song, error) { result, err := callPluginFunction[T, *capabilities.SimilarSongsResponse](ctx, plugin, funcName, input) if err != nil { - return nil, err + return nil, agentErr(err) } if result == nil || len(result.Songs) == 0 { return nil, agents.ErrNotFound diff --git a/plugins/metadata_agent_test.go b/plugins/metadata_agent_test.go index a14db5d34..2dc67d41c 100644 --- a/plugins/metadata_agent_test.go +++ b/plugins/metadata_agent_test.go @@ -3,12 +3,36 @@ package plugins import ( + "errors" + "fmt" + "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/plugins/capabilities" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) +// The partial-metadata-agent fixture registers through the Go PDK, which exports every method +// and answers -2, so only errNotImplemented reaches agentErr through a real plugin. A plugin +// that omits the export entirely yields errFunctionNotFound, covered here directly. +var _ = Describe("agentErr", func() { + DescribeTable("classifies a plugin error as a miss or a fault", + func(err error, wantMiss bool) { + got := agentErr(err) + Expect(errors.Is(got, agents.ErrNotFound)).To(Equal(wantMiss)) + Expect(got).To(MatchError(err), "the underlying reason must survive for diagnostics") + }, + Entry("an unimplemented method is a miss", + fmt.Errorf("%w: %s", errNotImplemented, FuncGetArtistImages), true), + Entry("a missing export is a miss", + fmt.Errorf("%w: %s", errFunctionNotFound, FuncGetArtistImages), true), + Entry("a call failure is a fault", + fmt.Errorf("plugin call failed: %w", errors.New("returned status 429")), false), + Entry("a non-zero exit is a fault", + errors.New("plugin call exited with code 1"), false), + ) +}) + var _ = Describe("MetadataAgent", Ordered, func() { var agent agents.Interface @@ -166,82 +190,57 @@ var _ = Describe("MetadataAgent error handling", Ordered, func() { Expect(ok).To(BeTrue()) }) - It("returns error from GetArtistMBID", func() { - retriever := errorAgent.(agents.ArtistMBIDRetriever) - _, err := retriever.GetArtistMBID(GinkgoT().Context(), "artist-1", "Test") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("simulated plugin error")) - }) - - It("returns error from GetArtistURL", func() { - retriever := errorAgent.(agents.ArtistURLRetriever) - _, err := retriever.GetArtistURL(GinkgoT().Context(), "artist-1", "Test", "mbid") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("simulated plugin error")) - }) - - It("returns error from GetArtistBiography", func() { - retriever := errorAgent.(agents.ArtistBiographyRetriever) - _, err := retriever.GetArtistBiography(GinkgoT().Context(), "artist-1", "Test", "mbid") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("simulated plugin error")) - }) - - It("returns error from GetArtistImages", func() { - retriever := errorAgent.(agents.ArtistImageRetriever) - _, err := retriever.GetArtistImages(GinkgoT().Context(), "artist-1", "Test", "mbid") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("simulated plugin error")) - }) - - It("returns error from GetSimilarArtists", func() { - retriever := errorAgent.(agents.ArtistSimilarRetriever) - _, err := retriever.GetSimilarArtists(GinkgoT().Context(), "artist-1", "Test", "mbid", 5) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("simulated plugin error")) - }) - - It("returns error from GetArtistTopSongs", func() { - retriever := errorAgent.(agents.ArtistTopSongsRetriever) - _, err := retriever.GetArtistTopSongs(GinkgoT().Context(), "artist-1", "Test", "mbid", 5) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("simulated plugin error")) - }) - - It("returns error from GetAlbumInfo", func() { - retriever := errorAgent.(agents.AlbumInfoRetriever) - _, err := retriever.GetAlbumInfo(GinkgoT().Context(), "Album", "Artist", "mbid") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("simulated plugin error")) - }) - - It("returns error from GetAlbumImages", func() { - retriever := errorAgent.(agents.AlbumImageRetriever) - _, err := retriever.GetAlbumImages(GinkgoT().Context(), "Album", "Artist", "mbid") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("simulated plugin error")) - }) - - It("returns error from GetSimilarSongsByTrack", func() { - retriever := errorAgent.(agents.SimilarSongsByTrackRetriever) - _, err := retriever.GetSimilarSongsByTrack(GinkgoT().Context(), "track-1", "Test", "Artist", "mbid", 5) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("simulated plugin error")) - }) - - It("returns error from GetSimilarSongsByAlbum", func() { - retriever := errorAgent.(agents.SimilarSongsByAlbumRetriever) - _, err := retriever.GetSimilarSongsByAlbum(GinkgoT().Context(), "album-1", "Album", "Artist", "mbid", 5) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("simulated plugin error")) - }) - - It("returns error from GetSimilarSongsByArtist", func() { - retriever := errorAgent.(agents.SimilarSongsByArtistRetriever) - _, err := retriever.GetSimilarSongsByArtist(GinkgoT().Context(), "artist-1", "Artist", "mbid", 5) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("simulated plugin error")) - }) + DescribeTable("surfaces the plugin failure, not a definitive not-found", + func(call func() error) { + err := call() + Expect(err).To(MatchError(ContainSubstring("simulated plugin error"))) + Expect(err).ToNot(MatchError(agents.ErrNotFound)) + }, + Entry("GetArtistMBID", func() error { + _, err := errorAgent.(agents.ArtistMBIDRetriever).GetArtistMBID(GinkgoT().Context(), "artist-1", "Test") + return err + }), + Entry("GetArtistURL", func() error { + _, err := errorAgent.(agents.ArtistURLRetriever).GetArtistURL(GinkgoT().Context(), "artist-1", "Test", "mbid") + return err + }), + Entry("GetArtistBiography", func() error { + _, err := errorAgent.(agents.ArtistBiographyRetriever).GetArtistBiography(GinkgoT().Context(), "artist-1", "Test", "mbid") + return err + }), + Entry("GetSimilarArtists", func() error { + _, err := errorAgent.(agents.ArtistSimilarRetriever).GetSimilarArtists(GinkgoT().Context(), "artist-1", "Test", "mbid", 5) + return err + }), + Entry("GetArtistImages", func() error { + _, err := errorAgent.(agents.ArtistImageRetriever).GetArtistImages(GinkgoT().Context(), "artist-1", "Test", "mbid") + return err + }), + Entry("GetArtistTopSongs", func() error { + _, err := errorAgent.(agents.ArtistTopSongsRetriever).GetArtistTopSongs(GinkgoT().Context(), "artist-1", "Test", "mbid", 5) + return err + }), + Entry("GetAlbumInfo", func() error { + _, err := errorAgent.(agents.AlbumInfoRetriever).GetAlbumInfo(GinkgoT().Context(), "Album", "Artist", "mbid") + return err + }), + Entry("GetAlbumImages", func() error { + _, err := errorAgent.(agents.AlbumImageRetriever).GetAlbumImages(GinkgoT().Context(), "Album", "Artist", "mbid") + return err + }), + Entry("GetSimilarSongsByTrack", func() error { + _, err := errorAgent.(agents.SimilarSongsByTrackRetriever).GetSimilarSongsByTrack(GinkgoT().Context(), "track-1", "Test", "Artist", "mbid", 5) + return err + }), + Entry("GetSimilarSongsByAlbum", func() error { + _, err := errorAgent.(agents.SimilarSongsByAlbumRetriever).GetSimilarSongsByAlbum(GinkgoT().Context(), "album-1", "Album", "Artist", "mbid", 5) + return err + }), + Entry("GetSimilarSongsByArtist", func() error { + _, err := errorAgent.(agents.SimilarSongsByArtistRetriever).GetSimilarSongsByArtist(GinkgoT().Context(), "artist-1", "Artist", "mbid", 5) + return err + }), + ) }) var _ = Describe("MetadataAgent partial implementation", Ordered, func() { @@ -268,68 +267,55 @@ var _ = Describe("MetadataAgent partial implementation", Ordered, func() { Expect(bio).To(Equal("Partial agent biography for Test Artist")) }) - It("returns ErrNotFound for unimplemented method (GetArtistMBID)", func() { - retriever := partialAgent.(agents.ArtistMBIDRetriever) - _, err := retriever.GetArtistMBID(GinkgoT().Context(), "artist-1", "Test Artist") - Expect(err).To(MatchError(errNotImplemented)) - }) - - It("returns ErrNotFound for unimplemented method (GetArtistURL)", func() { - retriever := partialAgent.(agents.ArtistURLRetriever) - _, err := retriever.GetArtistURL(GinkgoT().Context(), "artist-1", "Test Artist", "mbid") - Expect(err).To(MatchError(errNotImplemented)) - }) - - It("returns ErrNotFound for unimplemented method (GetArtistImages)", func() { - retriever := partialAgent.(agents.ArtistImageRetriever) - _, err := retriever.GetArtistImages(GinkgoT().Context(), "artist-1", "Test Artist", "mbid") - Expect(err).To(MatchError(errNotImplemented)) - }) - - It("returns ErrNotFound for unimplemented method (GetSimilarArtists)", func() { - retriever := partialAgent.(agents.ArtistSimilarRetriever) - _, err := retriever.GetSimilarArtists(GinkgoT().Context(), "artist-1", "Test Artist", "mbid", 5) - Expect(err).To(MatchError(errNotImplemented)) - - }) - - It("returns ErrNotFound for unimplemented method (GetArtistTopSongs)", func() { - retriever := partialAgent.(agents.ArtistTopSongsRetriever) - _, err := retriever.GetArtistTopSongs(GinkgoT().Context(), "artist-1", "Test Artist", "mbid", 5) - Expect(err).To(MatchError(errNotImplemented)) - - }) - - It("returns ErrNotFound for unimplemented method (GetAlbumInfo)", func() { - retriever := partialAgent.(agents.AlbumInfoRetriever) - _, err := retriever.GetAlbumInfo(GinkgoT().Context(), "Album", "Artist", "mbid") - Expect(err).To(MatchError(errNotImplemented)) - - }) - - It("returns ErrNotFound for unimplemented method (GetAlbumImages)", func() { - retriever := partialAgent.(agents.AlbumImageRetriever) - _, err := retriever.GetAlbumImages(GinkgoT().Context(), "Album", "Artist", "mbid") - Expect(err).To(MatchError(errNotImplemented)) - }) - - It("returns ErrNotFound for unimplemented method (GetSimilarSongsByTrack)", func() { - retriever := partialAgent.(agents.SimilarSongsByTrackRetriever) - _, err := retriever.GetSimilarSongsByTrack(GinkgoT().Context(), "track-1", "Test", "Artist", "mbid", 5) - Expect(err).To(MatchError(errNotImplemented)) - }) - - It("returns ErrNotFound for unimplemented method (GetSimilarSongsByAlbum)", func() { - retriever := partialAgent.(agents.SimilarSongsByAlbumRetriever) - _, err := retriever.GetSimilarSongsByAlbum(GinkgoT().Context(), "album-1", "Album", "Artist", "mbid", 5) - Expect(err).To(MatchError(errNotImplemented)) - }) - - It("returns ErrNotFound for unimplemented method (GetSimilarSongsByArtist)", func() { - retriever := partialAgent.(agents.SimilarSongsByArtistRetriever) - _, err := retriever.GetSimilarSongsByArtist(GinkgoT().Context(), "artist-1", "Artist", "mbid", 5) - Expect(err).To(MatchError(errNotImplemented)) - }) + // An unimplemented optional method is a definitive miss. Reported as a fault it would + // count against the artwork circuit breaker and keep the item in the retry queue. + DescribeTable("reports an unimplemented method as a definitive not-found", + func(call func() error) { + err := call() + Expect(err).To(MatchError(errNotImplemented)) + Expect(err).To(MatchError(agents.ErrNotFound)) + }, + Entry("GetArtistMBID", func() error { + _, err := partialAgent.(agents.ArtistMBIDRetriever).GetArtistMBID(GinkgoT().Context(), "artist-1", "Test Artist") + return err + }), + Entry("GetArtistURL", func() error { + _, err := partialAgent.(agents.ArtistURLRetriever).GetArtistURL(GinkgoT().Context(), "artist-1", "Test Artist", "mbid") + return err + }), + Entry("GetArtistImages", func() error { + _, err := partialAgent.(agents.ArtistImageRetriever).GetArtistImages(GinkgoT().Context(), "artist-1", "Test Artist", "mbid") + return err + }), + Entry("GetSimilarArtists", func() error { + _, err := partialAgent.(agents.ArtistSimilarRetriever).GetSimilarArtists(GinkgoT().Context(), "artist-1", "Test Artist", "mbid", 5) + return err + }), + Entry("GetArtistTopSongs", func() error { + _, err := partialAgent.(agents.ArtistTopSongsRetriever).GetArtistTopSongs(GinkgoT().Context(), "artist-1", "Test Artist", "mbid", 5) + return err + }), + Entry("GetAlbumInfo", func() error { + _, err := partialAgent.(agents.AlbumInfoRetriever).GetAlbumInfo(GinkgoT().Context(), "Album", "Artist", "mbid") + return err + }), + Entry("GetAlbumImages", func() error { + _, err := partialAgent.(agents.AlbumImageRetriever).GetAlbumImages(GinkgoT().Context(), "Album", "Artist", "mbid") + return err + }), + Entry("GetSimilarSongsByTrack", func() error { + _, err := partialAgent.(agents.SimilarSongsByTrackRetriever).GetSimilarSongsByTrack(GinkgoT().Context(), "track-1", "Test", "Artist", "mbid", 5) + return err + }), + Entry("GetSimilarSongsByAlbum", func() error { + _, err := partialAgent.(agents.SimilarSongsByAlbumRetriever).GetSimilarSongsByAlbum(GinkgoT().Context(), "album-1", "Album", "Artist", "mbid", 5) + return err + }), + Entry("GetSimilarSongsByArtist", func() error { + _, err := partialAgent.(agents.SimilarSongsByArtistRetriever).GetSimilarSongsByArtist(GinkgoT().Context(), "artist-1", "Artist", "mbid", 5) + return err + }), + ) }) var _ = Describe("songRefToAgentSong multi-artist", func() {