From 7a11ca69bbfd3465ef4ce2af52966a97c51de77e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Wed, 19 Aug 2026 08:36:44 -0400 Subject: [PATCH 01/78] fix(jellyfin): honor the Filters, SortBy and MaxHeight params clients actually send (#5981) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(jellyfin): honor Filters=IsFavorite on /Artists and /Artists/AlbumArtists listArtistsByRole hand-built its itemsQuery and never set favOnly, so the favorites filter was silently dropped on both artist routes while /Items honored it. Finamp's home screen asks for favorite artists once per load and was served the entire artist list instead: 10,298 artists, 6.15 MB, 2.7s on a real library, and the wrong data on screen. Extract the favOnly parsing that parseItemsQuery already did into parseFavOnly and use it in both places. listArtists now adds the starred predicate to notMissing rather than replacing it, matching listAlbums and listSongs, so a favorite artist whose files are gone stays excluded. * fix(jellyfin): map SortBy=Runtime to duration for albums and songs sortColumnsByType had no runtime/runtimeticks key for any type, so Finamp's "Duration" sort silently misbehaved in two different ways. Albums: Finamp sends a bare SortBy=Runtime. Nothing matched, opts.Sort stayed empty, and applyOptions skips OrderBy entirely when Sort is empty — so the query ran with no ORDER BY at all and Ascending and Descending returned identical lists. Songs: Finamp sends SortBy=Runtime,AlbumArtist,Album,SortName. applySort takes the first *recognized* key, so Runtime was skipped and the list came back sorted by album artist while looking correct. Both repos already accept a duration sort (mediafile_repository maps it explicitly; album_repository falls through to the column name), so no migration is needed. Sorting 97k songs by duration costs a temp B-tree (~114ms on a prod-sized copy) — the same cost the Subsonic and UI duration sorts already pay, and correct where the previous behaviour was merely fast. * fix(jellyfin): apply the played/unplayed filters and MaxHeight image bound Filters was matched with a substring test for IsFavorite, so every other token Jellyfin defines was silently dropped and the response kept rows it should have excluded. Finamp sends Filters=IsUnplayed in normal use. Replace the bool with a parsed itemFilters carrying nullable favorite and played flags, so isFavorite=false and isPlayed=false are real filters rather than indistinguishable from an absent param. Standalone params are read first and the Filters list overrides them, the precedence real Jellyfin has. IsFavoriteOrLikes now maps to favorites deliberately instead of by substring accident; Likes, Dislikes, IsFolder, IsNotFolder and IsResumable have no Navidrome equivalent and are dropped rather than half-applied. The negative cases match NULL as well, since annotations are LEFT JOINed and an untouched item has no row. getItemImage read only maxwidth, so a client sending just MaxHeight got the full-size original: measured against a real cover, maxHeight=100 returned 82,570 bytes where maxWidth=100 returned 3,316. Use the tighter of the two bounds. * refactor(jellyfin): share the plain-param parser between /Items and /Artists listArtistsByRole hand-listed the itemsQuery fields it happened to need, which is exactly how the favorites filter went missing: the literal has been amended in four of the five commits that touched it. Extract listParams for the fields that come straight from query params so both paths read one parser, and the next supported param reaches every list path instead of only /Items. Also from the cleanup pass: collapse imageSize to a single clamped comparison and read its bounds through req.Params like the rest of the package, which drops the strconv import; build the artist and playlist filter lists with the flat append shape the album and song paths already use, instead of re-wrapping opts.Filters into a nested And per predicate; drop a nil guard in listPlaylists that no caller can reach, since both paths into queryItemsOfType build QueryOptions without Filters. applySort now logs when no SortBy key resolves at all — a miss inside a fallback list is normal, but none matching means a silently ignored sort, the failure mode that hid the Runtime bug. Its doc comment records why the remaining keys cannot simply be joined. Folds three duplicated test bodies into the tables that already parameterize them, and covers the artist-parent album branch, which reaches notMissing through filter.AlbumsByArtistID rather than the default branch. * docs(jellyfin): correct how applySort describes Jellyfin's SortBy semantics The comment claimed SortBy is a comma-separated fallback list. It is not: RequestHelpers.GetOrderBy (10.10) builds one (ItemSortBy, SortOrder) pair per key, so Jellyfin orders by every key in turn. Navidrome applies only the first recognized one, which is a real divergence — secondary keys never break ties — not the intended reading of the parameter. The assertion that the keys cannot be joined was also wrong. buildSortOrder does split its input on commas; what it maps is the whole string, so joining raw Jellyfin key names misses the mappings. Mapping each key first and joining the results would work, which makes multi-key sorting a real option rather than a blocked one. Documenting the current behaviour as a known divergence until then. * fix(jellyfin): order by every recognized SortBy key, not just the first Jellyfin orders by each SortBy key in turn, so "DatePlayed,SortName" means break ties by name. Navidrome applied only the first recognized key and dropped the rest, which is 28% of the sort traffic on a real server (23 of 82 requests in 12h carry 2-5 keys). Most were harmless because the primary key dominates, but PremiereDate,Album,ParentIndexNumber,IndexNumber,SortName came back unordered within a year. The keys cannot simply be joined: sortMapping keyed on the whole Sort string, so a joined value missed every mapping and fell through to raw column names. Make it resolve a comma list per part, but only when every part is a known key — the four existing callers that pass raw column lists (core/matcher, core/lyrics, core/maintenance, subsonic/browsing) all carry a part that is not a mapping key, several with their own direction, so they keep falling through exactly as before. Verified each one. applySort now collects every recognized key, skipping duplicates so ParentIndexNumber,IndexNumber does not repeat a column. random stays alone: the repo matches it by exact string equality, so joining it would both break that path and emit a bare 'random' column into the ORDER BY. Verified against a prod-sized copy: every multi-key combination seen in real traffic returns 200, and a secondary key now changes the order within a tied year for songs. Albums are unchanged there, because their max_year mapping already ended in ", name". * fix(persistence): resolve sort mappings exactly once Making sortMapping resolve a comma list per part broke an invariant it had been relying on: idempotence. sanitizeSort mapped the sort key up front and applyOptions then ran buildSortOrder over the result, so sortMapping was already being handed its own output. That was harmless only while a mapped value could never look like a key list. media_file's rated_at maps to "rating, rated_at", and both parts are keys, so the second pass expanded it to "rating, rating, rated_at". Found by round-tripping every mapping in all four repositories; it was the only collision, and the duplicate sort key was benign in SQL, but any future mapping of that shape would silently change meaning. sanitizeSort now validates without resolving, leaving buildSortOrder as the single mapping point. The generated SQL is unchanged — the whole suite passes apart from the two specs that asserted the old return value, which are updated and joined by a round-trip guard covering exactly the rated_at shape. Also use the paren-aware splitFunc that buildSortOrder already uses, so an expression carrying commas inside its parentheses cannot be split apart. * refactor(jellyfin,persistence): flatten the sort resolution paths Cleanup pass over the branch, no behavior change. sortMapping loses the len(parts)>1 guard, which existed only to pick between two identical toSnakeCase exits; the single-key case now falls through the same loop. lookupSortMapping hands back the snake_case form it had to derive so the fallback stops recomputing it — toSnakeCase is two regexps, and on a miss it was running twice per call. sanitizeSort now asks lookupSortMapping instead of probing the map itself, so "is this a known sort key" has one answer; the two had already drifted, since sanitizeSort tried one casing where the resolver tries three. applySort folds the nested random branch into the skip condition and the two trailing length tests into one switch. setSortMappings documents the invariant the comma-list rule depends on, where someone adding a mapping will read it. The README line describing SortBy still said only the first key applied, which the commit before last made false. Tests: the twelve near-identical sorting specs become one DescribeTable of (itemType, SortBy, want) triples, 124 lines to 36, and the applyOptions round-trip assertion collapses to the buildSortOrder call its sibling uses. * fix(jellyfin): keep annotation filters out of search, resolve sorts per part Two findings from the Codex review on #5981. The played/unplayed filters turned working requests into 500s when combined with SearchTerm. Search runs a two-phase FTS query whose first phase selects rowids with no annotation join, so a starred or play_count predicate there is "no such column", not a filter. Measured against master: MusicAlbum with SearchTerm and Filters=IsUnplayed went 200 -> 500, likewise IsPlayed and the Audio equivalents. listAlbums and listSongs now skip those predicates on the search path, matching what listArtists already did. That also clears the same 500 master already had for Filters=IsFavorite with SearchTerm. sortMapping resolved a comma list only while every part was a known key, so a list mixing a plain column with a mapped key kept neither: MusicAlbum SortBy=Runtime,SortName arrives as "duration, name", and duration is a plain album column, so name stayed raw instead of expanding to order_album_name. Albums whose name differs from its sort form — 1,366 of 6,987 on a real library — then ordered by the wrong secondary key, and PreferSortTags was ignored. Each part is now resolved on its own, which is what setSortMappings already documents for a single field. Verified every in-tree caller that passes a raw column list still produces its original ORDER BY. Codex also asked for the artist search path to apply the same filters. It would 500 for the reason above, and wrapping the library scope in a compound filter makes requestedLibraryIDs stop recognizing it, silently widening the search past the requested ParentId. * fix(jellyfin): honor the first SortOrder value for a multi-key sort applySort compared the whole SortOrder string with "Descending", so a per-key list like SortOrder=Descending,Ascending failed the match and every key, including the primary, sorted ascending — the exact opposite of the request. Take the first comma-separated value, which Jellyfin also uses for any key past the end of the SortOrder list. True per-key directions can't be expressed through the single opts.Sort string and are left out; no observed client sends a SortOrder list. --- persistence/sql_base_repository.go | 36 ++++- persistence/sql_base_repository_test.go | 59 +++++++- persistence/sql_restful.go | 12 +- server/jellyfin/README.md | 6 +- server/jellyfin/browsing.go | 19 +-- server/jellyfin/browsing_test.go | 23 +++ server/jellyfin/images.go | 15 +- server/jellyfin/images_test.go | 33 +++- server/jellyfin/items.go | 149 +++++++++++++------ server/jellyfin/items_test.go | 190 +++++++++++++++--------- 10 files changed, 394 insertions(+), 148 deletions(-) diff --git a/persistence/sql_base_repository.go b/persistence/sql_base_repository.go index 428ba7a7b..c61bca1a6 100644 --- a/persistence/sql_base_repository.go +++ b/persistence/sql_base_repository.go @@ -97,6 +97,8 @@ func (r *sqlRepository) registerModel(instance any, filters map[string]filterFun } // setSortMappings sets the mappings for the sort fields. If the sort field is not in the map, it will be used as is. +// This applies per comma-separated part, so a key added here also defines that bare name wherever a +// caller uses it inside a sort list. // // If PreferSortTags is enabled, it will map the order fields to the corresponding sort expression, // which gives precedence to sort tags. @@ -147,17 +149,37 @@ func (r sqlRepository) applyOptions(sq SelectBuilder, options ...model.QueryOpti // TODO Change all sortMappings to have a consistent case func (r sqlRepository) sortMapping(sort string) string { - if mapping, ok := r.sortMappings[sort]; ok { + if mapping, _, ok := r.lookupSortMapping(sort); ok { return mapping } - if mapping, ok := r.sortMappings[toCamelCase(sort)]; ok { - return mapping + // Each part of a comma list is resolved on its own, so a mix of mapped keys and plain columns + // keeps the mappings the recognized parts have. + parts := strings.FieldsFunc(sort, splitFunc(',')) + mapped := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if partMapping, _, ok := r.lookupSortMapping(part); ok { + part = partMapping + } else { + part = toSnakeCase(part) + } + mapped = append(mapped, part) } - sort = toSnakeCase(sort) - if mapping, ok := r.sortMappings[sort]; ok { - return mapping + return strings.Join(mapped, ", ") +} + +// lookupSortMapping also returns the snake_case form when it had to derive one, so a caller's +// fallback doesn't recompute it: toSnakeCase runs two regexps. +func (r sqlRepository) lookupSortMapping(sort string) (mapping, snakeCased string, ok bool) { + if mapping, ok = r.sortMappings[sort]; ok { + return mapping, sort, true } - return sort + if mapping, ok = r.sortMappings[toCamelCase(sort)]; ok { + return mapping, "", true + } + snakeCased = toSnakeCase(sort) + mapping, ok = r.sortMappings[snakeCased] + return mapping, snakeCased, ok } func (r sqlRepository) buildSortOrder(sort, order string) string { diff --git a/persistence/sql_base_repository_test.go b/persistence/sql_base_repository_test.go index 9c6c6007f..0f76eb6ab 100644 --- a/persistence/sql_base_repository_test.go +++ b/persistence/sql_base_repository_test.go @@ -92,14 +92,28 @@ var _ = Describe("sqlRepository", func() { Expect(sort).To(BeEmpty()) }) - It("returns the mapped value when sort key exists", func() { + // Validation only: buildSortOrder resolves the mapping, so mapping here too would hand + // sortMapping its own output and re-map values whose parts are themselves keys. + It("accepts a known sort key without resolving it", func() { sort, _ := r.sanitizeSort("sort1", "") - Expect(sort).To(Equal("mappedSort1")) + Expect(sort).To(Equal("sort1")) }) It("is case insensitive", func() { sort, _ := r.sanitizeSort("Sort1", "") - Expect(sort).To(Equal("mappedSort1")) + Expect(sort).To(Equal("sort1")) + }) + + It("still resolves the mapping by the time the SQL is built", func() { + Expect(r.buildSortOrder("sort1", "asc")).To(Equal("mappedSort1 asc")) + }) + + // A mapping whose parts are themselves keys (media_file rated_at = "rating, rated_at") + // must survive the round trip through sanitizeSort and buildSortOrder unduplicated. + It("does not re-map a value whose parts are also keys", func() { + r.sortMappings = map[string]string{"rating": "rating", "rated_at": "rating, rated_at"} + sort, _ := r.sanitizeSort("rated_at", "") + Expect(r.buildSortOrder(sort, "asc")).To(Equal("rating asc, rated_at asc")) }) It("returns the field if it is a valid field", func() { @@ -135,6 +149,45 @@ var _ = Describe("sqlRepository", func() { }) }) + Describe("sortMapping", func() { + BeforeEach(func() { + r.sortMappings = map[string]string{ + "name": "order_album_name, order_album_artist_name", + "recently_added": "album.created_at, album.id", + } + }) + It("maps a single key", func() { + Expect(r.sortMapping("recently_added")).To(Equal("album.created_at, album.id")) + }) + It("maps every part of a comma list when all of them are known keys", func() { + Expect(r.sortMapping("recently_added, name")). + To(Equal("album.created_at, album.id, order_album_name, order_album_artist_name")) + }) + It("resolves the known parts of a mixed list and leaves the rest as columns", func() { + Expect(r.sortMapping("recently_added, play_count")). + To(Equal("album.created_at, album.id, play_count")) + }) + // Jellyfin's MusicAlbum SortBy=Runtime,SortName arrives as "duration, name"; duration is a + // plain album column while name is mapped, and the mapping must survive the mix. + It("keeps a mapping when an earlier part is a plain column", func() { + Expect(r.sortMapping("duration, name")). + To(Equal("duration, order_album_name, order_album_artist_name")) + }) + It("leaves a raw column list with directions untouched", func() { + Expect(r.sortMapping("starred desc, rating desc")).To(Equal("starred desc, rating desc")) + }) + It("does not split an expression on a comma inside its parentheses", func() { + Expect(r.sortMapping("coalesce(name, ''), title")).To(Equal("coalesce(name, ''), title")) + Expect(r.sortMapping("coalesce(nullif(a,''), b) desc, c")).To(Equal("coalesce(nullif(a,''), b) desc, c")) + }) + It("keeps a mapping whose value nests commas inside parentheses", func() { + r.sortMappings["max_year"] = "coalesce(nullif(original_date,''), cast(max_year as text)), release_date" + Expect(r.sortMapping("max_year, name")).To(Equal( + "coalesce(nullif(original_date,''), cast(max_year as text)), release_date, " + + "order_album_name, order_album_artist_name")) + }) + }) + Describe("buildSortOrder", func() { BeforeEach(func() { r.sortMappings = map[string]string{} diff --git a/persistence/sql_restful.go b/persistence/sql_restful.go index 1dcabcec6..b1cfd2379 100644 --- a/persistence/sql_restful.go +++ b/persistence/sql_restful.go @@ -69,13 +69,11 @@ func (r *sqlRepository) parseRestOptions(ctx context.Context, options ...rest.Qu func (r sqlRepository) sanitizeSort(sort, order string) (string, string) { if sort != "" { sort = toSnakeCase(sort) - if mapped, ok := r.sortMappings[sort]; ok { - sort = mapped - } else { - if !r.isFieldWhiteListed(sort) { - log.Warn(r.ctx, "Ignoring sort not whitelisted", "sort", sort, "table", r.tableName) - sort = "" - } + // Validate only: buildSortOrder resolves the mapping later, and mapping here as well would + // feed sortMapping its own output. + if _, _, known := r.lookupSortMapping(sort); !known && !r.isFieldWhiteListed(sort) { + log.Warn(r.ctx, "Ignoring sort not whitelisted", "sort", sort, "table", r.tableName) + sort = "" } } if order != "" { diff --git a/server/jellyfin/README.md b/server/jellyfin/README.md index 5759e6cc0..dc3219dfa 100644 --- a/server/jellyfin/README.md +++ b/server/jellyfin/README.md @@ -104,7 +104,11 @@ album's tracks — Feishin fetches them this way instead of `ParentId`); `GenreI genre's albums or tracks — Finamp's genre screen sends it the same way; `/Artists/AlbumArtists` and `MusicArtist` queries accept it too, matching artists credited on an album of that genre); `SearchTerm`; -favorites-only (`Filters=IsFavorite` or the standalone `isFavorite=true`); `SortBy`/`SortOrder`; +`Filters` (`IsFavorite`, `IsFavoriteOrLikes`, `IsPlayed`, `IsUnplayed`) and the standalone +`isFavorite`/`isPlayed` booleans it can also be expressed as — `Filters` wins when both are sent, as +in Jellyfin; `Likes`, `Dislikes`, `IsFolder`, `IsNotFolder` and `IsResumable` have no Navidrome +equivalent and are ignored; `SortBy`/`SortOrder` (every recognized key is applied in order, so secondary keys break ties; +unrecognized keys are skipped, and `Random` always sorts alone); `StartIndex`/`Limit`; and `Ids` (batch fetch by id). `Recursive=false` with a library `ParentId` returns direct children only (no tracks — no track is a library's direct child). diff --git a/server/jellyfin/browsing.go b/server/jellyfin/browsing.go index 9acd8d5a8..25c44e748 100644 --- a/server/jellyfin/browsing.go +++ b/server/jellyfin/browsing.go @@ -24,10 +24,6 @@ func (api *Router) getAlbumArtists(w http.ResponseWriter, r *http.Request) { // when accessible (like queryItems) or all accessible libraries otherwise. func (api *Router) listArtistsByRole(w http.ResponseWriter, r *http.Request, role model.Role) { ctx := r.Context() - p := req.Params(r) - opts := model.QueryOptions{Offset: p.IntOr("startindex", 0), Max: p.IntOr("limit", 0)} - applySort(&opts, "MusicArtist", p.StringOr("sortby", ""), p.StringOr("sortorder", "")) - scopeIDs, _, ok := parentIDScope(ctx, r) if !ok { http.Error(w, "Not Found", http.StatusNotFound) @@ -38,14 +34,13 @@ func (api *Router) listArtistsByRole(w http.ResponseWriter, r *http.Request, rol 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: genreIds, - search: searchTerm(p), - fields: dto.ParseFields(p.Strings("fields")...), - } + // This route resolves its own scope, so it shares only the plain query params with /Items. + q := listParams(req.Params(r)) + q.scopeIDs = scopeIDs + q.genreIds = genreIds + + opts := model.QueryOptions{Offset: q.offset, Max: q.limit} + applySort(&opts, "MusicArtist", q.sortBy, q.sortOrder) if q.search != "" { opts.Max = clampLimit(opts.Max, defaultSearchLimit, maxSearchLimit) } diff --git a/server/jellyfin/browsing_test.go b/server/jellyfin/browsing_test.go index 2825c03e8..657c7a42c 100644 --- a/server/jellyfin/browsing_test.go +++ b/server/jellyfin/browsing_test.go @@ -156,6 +156,29 @@ var _ = Describe("Browsing", func() { Expect(sql).NotTo(ContainSubstring("library_artist.library_id")) }) + DescribeTable("restricts to favorites", + func(url string, handler func(*Router) http.HandlerFunc) { + artistRepo := ds.Artist(context.Background()).(*tests.MockArtistRepo) + artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", url, nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) + invoke(handler(api), w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + sql, args, err := artistRepo.Options.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).To(ContainSubstring("starred")) + // listArtists always ANDs notMissing, favorites filter or not. + Expect(sql).To(ContainSubstring("missing")) + Expect(args).To(ContainElement(true)) + }, + Entry("Filters=IsFavorite", "/Artists?Filters=IsFavorite", + func(a *Router) http.HandlerFunc { return a.getArtists }), + Entry("isFavorite=true", "/Artists?isFavorite=true", + func(a *Router) http.HandlerFunc { return a.getArtists }), + Entry("on /Artists/AlbumArtists", "/Artists/AlbumArtists?Filters=IsFavorite", + func(a *Router) http.HandlerFunc { return a.getAlbumArtists }), + ) + 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"}}) diff --git a/server/jellyfin/images.go b/server/jellyfin/images.go index 65f02d7a0..fcf69c039 100644 --- a/server/jellyfin/images.go +++ b/server/jellyfin/images.go @@ -11,7 +11,6 @@ import ( _ "image/png" "io" "net/http" - "strconv" "github.com/dustin/go-humanize" "github.com/navidrome/navidrome/conf" @@ -20,9 +19,20 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/imghttp" + "github.com/navidrome/navidrome/utils/req" _ "golang.org/x/image/webp" ) +// imageSize picks the tighter of Jellyfin's two bounds, because Navidrome resizes on a single +// dimension: reading only MaxWidth serves the full-size original to a client that sent MaxHeight. +func imageSize(maxWidth, maxHeight int) int { + w, h := max(maxWidth, 0), max(maxHeight, 0) + if w == 0 || h == 0 { + return max(w, h) + } + return min(w, h) +} + 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. @@ -31,7 +41,8 @@ func (api *Router) getItemImage(w http.ResponseWriter, r *http.Request) { if !ok { return } - size, _ := strconv.Atoi(r.URL.Query().Get("maxwidth")) + p := req.Params(r) + size := imageSize(p.IntOr("maxwidth", 0), p.IntOr("maxheight", 0)) artID := api.resolveArtworkID(ctx, itemId) img, err := api.artwork.GetOrPlaceholder(ctx, artID, size, false) diff --git a/server/jellyfin/images_test.go b/server/jellyfin/images_test.go index 0e0d6220d..2f32e6ec9 100644 --- a/server/jellyfin/images_test.go +++ b/server/jellyfin/images_test.go @@ -30,14 +30,16 @@ import ( type fakeArtwork struct { artwork.Artwork - recvId string - recvCtx context.Context - data []byte - hash string + recvId string + recvSize int + recvCtx context.Context + data []byte + hash string } func (f *fakeArtwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (*artwork.Image, error) { f.recvId = id + f.recvSize = size f.recvCtx = ctx data := f.data if data == nil { @@ -61,6 +63,29 @@ func newImageRequest(itemId string) (*httptest.ResponseRecorder, *http.Request) } var _ = Describe("Images", func() { + // Real Jellyfin fits the image inside either bound, so a client that sends only MaxHeight must + // still get a resized image rather than the full-size original. + DescribeTable("derives the requested size from MaxWidth or MaxHeight", + func(query string, wantSize int) { + ds := &tests.MockDataStore{} + 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(testID("a1"))) + r.URL.RawQuery = query + api.getItemImage(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(fa.recvSize).To(Equal(wantSize)) + }, + Entry("MaxWidth only", "maxwidth=300", 300), + Entry("MaxHeight only", "maxheight=300", 300), + Entry("both, smaller bound wins", "maxwidth=200&maxheight=300", 200), + Entry("both, smaller bound wins regardless of order", "maxwidth=300&maxheight=200", 200), + Entry("neither", "", 0), + ) + It("streams album artwork", func() { ds := &tests.MockDataStore{} ds.Album(context.Background()).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go index de0da88eb..eb5cd2a1a 100644 --- a/server/jellyfin/items.go +++ b/server/jellyfin/items.go @@ -31,6 +31,51 @@ func searchTerm(p *req.Values) string { return strings.TrimSpace(p.StringOr("searchterm", "")) } +// itemFilters is the parsed Filters=... list together with the standalone isFavorite/isPlayed params +// clients may send instead. A nil field means the client asked for no filtering on that dimension. +type itemFilters struct { + favorite *bool + played *bool +} + +// parseItemFilters reads the standalone params first and lets the Filters list win, matching real +// Jellyfin. Tokens with no Navidrome equivalent (Likes, IsFolder, IsResumable) are dropped. +func parseItemFilters(p *req.Values) itemFilters { + f := itemFilters{favorite: p.BoolPtr("isfavorite"), played: p.BoolPtr("isplayed")} + for token := range strings.SplitSeq(p.StringOr("filters", ""), ",") { + switch strings.TrimSpace(token) { + case "IsFavorite", "IsFavoriteOrLikes": + f.favorite = new(true) + case "IsPlayed": + f.played = new(true) + case "IsUnplayed": + f.played = new(false) + } + } + return f +} + +// predicates renders the filters as annotation-column conditions. The negative cases have to match +// NULL as well: annotations are LEFT JOINed, so an item nobody has touched has no row at all. +func (f itemFilters) predicates() []squirrel.Sqlizer { + var out []squirrel.Sqlizer + if f.favorite != nil { + if *f.favorite { + out = append(out, squirrel.Eq{"starred": true}) + } else { + out = append(out, squirrel.Or{squirrel.Eq{"starred": nil}, squirrel.Eq{"starred": false}}) + } + } + if f.played != nil { + if *f.played { + out = append(out, squirrel.Gt{"play_count": 0}) + } else { + out = append(out, squirrel.Or{squirrel.Eq{"play_count": nil}, squirrel.Eq{"play_count": 0}}) + } + } + return out +} + func (api *Router) getItems(w http.ResponseWriter, r *http.Request) { res, err := api.queryItems(r.Context(), r) if err != nil { @@ -214,7 +259,7 @@ type itemsQuery struct { sortOrder string offset int limit int - favOnly bool + filters itemFilters // parentId scopes the query. entityParent is the same id only when it names an entity (an artist // for MusicAlbum, an album for Audio) rather than a library. parentId string @@ -231,6 +276,19 @@ type itemsQuery struct { studioIds []string } +// listParams reads the itemsQuery fields that come straight from query params. +func listParams(p *req.Values) itemsQuery { + return itemsQuery{ + fields: dto.ParseFields(p.Strings("fields")...), + search: searchTerm(p), + sortBy: p.StringOr("sortby", ""), + sortOrder: p.StringOr("sortorder", ""), + offset: p.IntOr("startindex", 0), + limit: p.IntOr("limit", 0), + filters: parseItemFilters(p), + } +} + // 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). A non-empty id param that @@ -261,24 +319,14 @@ func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) (itemsQ if !ok { return itemsQuery{}, model.ErrNotFound } - q := itemsQuery{ - fields: dto.ParseFields(p.Strings("fields")...), - ids: ids, - rawTypes: p.StringOr("includeitemtypes", ""), - search: searchTerm(p), - sortBy: p.StringOr("sortby", ""), - sortOrder: p.StringOr("sortorder", ""), - offset: p.IntOr("startindex", 0), - limit: p.IntOr("limit", 0), - // Clients express "favorites only" two ways: Filters=IsFavorite and the standalone - // isFavorite=true param (Finamp's "Favourite tracks" widget uses the latter). - favOnly: strings.Contains(p.StringOr("filters", ""), "IsFavorite") || p.BoolOr("isfavorite", false), - parentId: parentId, - genreIds: genreIds, - albumIds: albumIds, - years: parseYears(r), - studioIds: studioIds, - } + q := listParams(p) + q.ids = ids + q.rawTypes = p.StringOr("includeitemtypes", "") + q.parentId = parentId + q.genreIds = genreIds + q.albumIds = albumIds + q.years = parseYears(r) + q.studioIds = studioIds // An artist's page filters by artist, not ParentId: Finamp sends ParentId= for scoping // plus AlbumArtistIds/ArtistIds/contributingArtistIds for the artist. albumArtistScope := firstNonEmpty(p.StringOr("albumartistids", ""), p.StringOr("artistids", "")) @@ -621,8 +669,10 @@ func (api *Router) listAlbums(ctx context.Context, opts model.QueryOptions, q it if len(q.studioIds) > 0 { filters = append(filters, filter.ByStudioID(q.studioIds)) } - if q.favOnly { - filters = append(filters, filter.ByStarred().Filters) + // Not on the search path: its first FTS phase selects rowids with no annotation join, so a + // starred/play_count predicate there is "no such column" rather than a filter. + if q.search == "" { + filters = append(filters, q.filters.predicates()...) } opts.Filters = filters opts = filter.ApplyLibraryFilter(opts, q.scopeIDs) @@ -668,8 +718,10 @@ func (api *Router) listSongs(ctx context.Context, opts model.QueryOptions, q ite if len(q.studioIds) > 0 { filters = append(filters, filter.ByStudioID(q.studioIds)) } - if q.favOnly { - filters = append(filters, filter.ByStarred().Filters) + // Not on the search path: its first FTS phase selects rowids with no annotation join, so a + // starred/play_count predicate there is "no such column" rather than a filter. + if q.search == "" { + filters = append(filters, q.filters.predicates()...) } opts.Filters = filters opts = filter.ApplyLibraryFilter(opts, q.scopeIDs) @@ -720,14 +772,12 @@ func (api *Router) listArtists(ctx context.Context, opts model.QueryOptions, q i return materialized(result(slice.Map(artists, toItem), total, opts.Offset)), nil } - if q.favOnly { - opts.Filters = filter.ArtistsByStarred().Filters - } else { - opts.Filters = notMissing - } + filters := squirrel.And{notMissing} + filters = append(filters, q.filters.predicates()...) if len(q.genreIds) > 0 { - opts.Filters = squirrel.And{opts.Filters, filter.ArtistsByGenreID(q.genreIds)} + filters = append(filters, filter.ArtistsByGenreID(q.genreIds)) } + opts.Filters = filters opts = filter.ArtistsByRole(opts, role) opts = filter.ApplyArtistLibraryFilter(opts, q.scopeIDs) total, _ := repo.CountAll(model.QueryOptions{Filters: opts.Filters}) @@ -752,13 +802,8 @@ func (api *Router) listGenres(ctx context.Context, opts model.QueryOptions) (ite // listPlaylists lists playlists visible to the current user. Visibility (public or owned) is // enforced by playlistRepository, not scopeIDs. func (api *Router) listPlaylists(ctx context.Context, opts model.QueryOptions, q itemsQuery) (itemsResult, error) { - if q.favOnly { - starred := squirrel.Eq{"starred": true} - if opts.Filters == nil { - opts.Filters = starred - } else { - opts.Filters = squirrel.And{opts.Filters, starred} - } + if preds := q.filters.predicates(); len(preds) > 0 { + opts.Filters = squirrel.And(preds) } repo := api.ds.Playlist(ctx) total, err := repo.CountAll(model.QueryOptions{Filters: opts.Filters}) @@ -908,18 +953,32 @@ func result(items []dto.BaseItemDto, total, start int) dto.QueryResult { return dto.QueryResult{Items: items, TotalRecordCount: total, StartIndex: start} } -// applySort translates Jellyfin's SortBy/SortOrder into a valid model.QueryOptions sort key for the -// item type. Clients send SortBy as a comma-separated fallback list (e.g. "DateCreated,SortName"); -// this uses the first recognized key. An unrecognized SortBy is left untouched (the repo's default), -// not passed through raw where it could produce an invalid ORDER BY. +// applySort keeps every recognized SortBy key, so secondary keys break ties as Jellyfin intends. +// Unrecognized keys are skipped, not passed through raw where they could make an invalid ORDER BY. func applySort(opts *model.QueryOptions, itemType, sortBy, order string) { + var cols []string for key := range strings.SplitSeq(sortBy, ",") { - if col, ok := sortColumn(itemType, strings.TrimSpace(key)); ok { - opts.Sort = col + col, ok := sortColumn(itemType, strings.TrimSpace(key)) + // The repo matches random by exact string equality, so it can only ever sort alone. + if !ok || slices.Contains(cols, col) || (col == "random" && len(cols) > 0) { + continue + } + cols = append(cols, col) + if col == "random" { break } } - if strings.EqualFold(order, "Descending") { + switch { + case len(cols) > 0: + opts.Sort = strings.Join(cols, ", ") + case sortBy != "": + log.Debug("Jellyfin API: no usable SortBy key, falling back to the default order", + "itemType", itemType, "sortBy", sortBy) + } + // Jellyfin allows a per-key SortOrder list, which one Order can't express; honor the first value + // for every key, as Jellyfin does for keys past the end of the list. + first, _, _ := strings.Cut(order, ",") + if strings.EqualFold(first, "Descending") { opts.Order = "desc" } } @@ -941,6 +1000,8 @@ var sortColumnsByType = map[string]map[string]string{ "dateplayed": "play_date", "communityrating": "rating", "random": "random", + "runtime": "duration", + "runtimeticks": "duration", // Finamp's "Latest Releases" sorts by PremiereDate; "year" matches songs' ProductionYear. "premieredate": "year", "productionyear": "year", @@ -964,6 +1025,8 @@ var sortColumnsByType = map[string]map[string]string{ "playcount": "play_count", "dateplayed": "play_date", "communityrating": "rating", + "runtime": "duration", + "runtimeticks": "duration", "premieredate": "max_year", "productionyear": "max_year", }, "MusicGenre": { diff --git a/server/jellyfin/items_test.go b/server/jellyfin/items_test.go index b227ab719..17180c36a 100644 --- a/server/jellyfin/items_test.go +++ b/server/jellyfin/items_test.go @@ -327,17 +327,75 @@ var _ = Describe("Items", func() { Expect(albumRepo.Options.Max).To(Equal(3)) }) - It("applies a starred filter when Filters=IsFavorite", func() { - albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&Filters=IsFavorite", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - sql, _, err := albumRepo.Options.Filters.ToSql() - Expect(err).NotTo(HaveOccurred()) - Expect(sql).To(ContainSubstring("starred")) - }) + DescribeTable("translates the Filters list and its standalone equivalents", + func(query string, wantSQL, notWantSQL []string) { + 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&"+query, 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()) + for _, want := range wantSQL { + Expect(sql).To(ContainSubstring(want)) + } + for _, not := range notWantSQL { + Expect(sql).NotTo(ContainSubstring(not)) + } + }, + Entry("IsFavorite", "Filters=IsFavorite", []string{"starred"}, nil), + Entry("IsFavorite,IsUnplayed combined", "Filters=IsFavorite,IsUnplayed", + []string{"starred", "play_count"}, nil), + Entry("IsUnplayed", "Filters=IsUnplayed", []string{"play_count"}, []string{"starred"}), + Entry("IsPlayed", "Filters=IsPlayed", []string{"play_count"}, []string{"starred"}), + Entry("IsFavoriteOrLikes is treated as favorites", "Filters=IsFavoriteOrLikes", []string{"starred"}, nil), + Entry("isPlayed=false", "isPlayed=false", []string{"play_count"}, nil), + Entry("isFavorite=false still filters", "isFavorite=false", []string{"starred"}, nil), + // Jellyfin builds the query from the standalone params, then applies Filters over the top. + Entry("Filters wins over the standalone param", "isFavorite=false&Filters=IsFavorite", + []string{"starred = "}, nil), + // No Navidrome equivalent: these must be dropped, not half-applied. + Entry("Likes is ignored", "Filters=Likes", nil, []string{"starred", "play_count"}), + Entry("IsResumable is ignored", "Filters=IsResumable", nil, []string{"starred", "play_count"}), + // The artist-parent branch gets notMissing from filter.AlbumsByArtistID, not the default + // branch, so favorites must not be the only predicate left on it. + Entry("keeps missing excluded under an artist parent", + "Filters=IsFavorite&ArtistIds="+dto.EncodeID(testID("ar1")), + []string{"starred", "missing"}, nil), + ) + + // Search runs a two-phase FTS query whose first phase has no annotation join, so an + // annotation predicate there is "no such column: starred" -> 500. + DescribeTable("does not push annotation filters into a search", + func(itemType, filters string) { + 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"}}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", + "/Items?IncludeItemTypes="+itemType+"&SearchTerm=one&Filters="+filters, nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + var opts model.QueryOptions + if itemType == "MusicAlbum" { + opts = ds.Album(context.Background()).(*tests.MockAlbumRepo).Options + } else { + opts = ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo).Options + } + if opts.Filters == nil { + return + } + sql, _, err := opts.Filters.ToSql() + Expect(err).NotTo(HaveOccurred()) + Expect(sql).NotTo(ContainSubstring("starred")) + Expect(sql).NotTo(ContainSubstring("play_count")) + }, + Entry("albums, IsFavorite", "MusicAlbum", "IsFavorite"), + Entry("albums, IsUnplayed", "MusicAlbum", "IsUnplayed"), + Entry("albums, IsPlayed", "MusicAlbum", "IsPlayed"), + Entry("songs, IsFavorite", "Audio", "IsFavorite"), + Entry("songs, IsUnplayed", "Audio", "IsUnplayed"), + ) It("forwards SearchTerm to the repo's Search method", func() { albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) @@ -601,65 +659,59 @@ 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: testID("a1"), Name: "One"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SortBy=PlayCount", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(albumRepo.Options.Sort).To(Equal("play_count")) - }) + DescribeTable("translates SortBy into the repo's sort keys", + func(itemType, sortBy, want string) { + albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) + albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) + 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="+itemType+"&SortBy="+sortBy, nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + got := mfRepo.Options.Sort + if itemType == "MusicAlbum" { + got = albumRepo.Options.Sort + } + Expect(got).To(Equal(want)) + }, + Entry("PlayCount", "MusicAlbum", "PlayCount", "play_count"), + Entry("DatePlayed", "Audio", "DatePlayed", "play_date"), + Entry("Runtime on albums", "MusicAlbum", "Runtime", "duration"), + Entry("RunTimeTicks alias", "MusicAlbum", "RunTimeTicks", "duration"), + // Finamp leads its track sort with Runtime: unless that resolves, the first recognized + // key is AlbumArtist and the list looks sorted while being sorted by the wrong thing. + Entry("Finamp's Runtime-led track sort", "Audio", "Runtime,AlbumArtist,Album,SortName", + "duration, album_artist, album, title"), + Entry("every recognized key, in order", "MusicAlbum", "DateCreated,SortName", "recently_added, name"), + Entry("a key repeating a column is dropped", "Audio", + "PremiereDate,Album,ParentIndexNumber,IndexNumber,SortName", "year, album, title"), + // random is matched by exact string equality in the repo, so it can never share a sort. + Entry("Random stays alone", "MusicAlbum", "Random,SortName", "random"), + Entry("unrecognized keys are skipped", "Audio", "Runtime,Nonsense,SortName", "duration, title"), + Entry("only the last key recognized", "Audio", "Unknown1,Unknown2,SortName", "title"), + Entry("Finamp's album view is disc+track", "Audio", "ParentIndexNumber,IndexNumber,SortName", "album, title"), + Entry("nothing recognized leaves the repo default", "MusicAlbum", "SeriesSortName", ""), + ) - It("maps SortBy=DatePlayed to the play_date column", 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&SortBy=DatePlayed", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(mfRepo.Options.Sort).To(Equal("play_date")) - }) - - It("uses the first recognized key in a comma-separated SortBy list", func() { - albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: 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) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(albumRepo.Options.Sort).To(Equal("recently_added")) - }) - - It("skips unrecognized keys in a comma-separated SortBy list to find one that is", func() { - mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: 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) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(mfRepo.Options.Sort).To(Equal("title")) - }) - - It("maps Finamp's album view SortBy (ParentIndexNumber,IndexNumber) to disc+track order", func() { - mfRepo := ds.MediaFile(context.Background()).(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: 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) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(mfRepo.Options.Sort).To(Equal("album")) - }) - - It("leaves Sort at the repo default when no SortBy key is recognized", func() { - albumRepo := ds.Album(context.Background()).(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SortBy=SeriesSortName", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(albumRepo.Options.Sort).To(Equal("")) - }) + // Jellyfin allows a per-key SortOrder list; we cannot express that through one Order, so + // we honor the first value for all keys, matching Jellyfin's fallback for extra keys. + DescribeTable("reads the first SortOrder value for the whole sort", + func(sortOrder, want string) { + 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&SortBy=Runtime,SortName&SortOrder="+sortOrder, nil).WithContext(ctxUser()) + invoke(api.getItems, w, r) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(albumRepo.Options.Order).To(Equal(want)) + }, + Entry("ascending", "Ascending", ""), + Entry("descending", "Descending", "desc"), + Entry("descending leading a list", "Descending,Ascending", "desc"), + Entry("ascending leading a list", "Ascending,Descending", ""), + ) }) Describe("library scoping", func() { From 1f3034f0229777c2023e861a4d9a92aaca1a1207 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Wed, 19 Aug 2026 08:47:53 -0400 Subject: [PATCH 02/78] fix(playlist): block track edits on synced playlists across all APIs (#5984) * fix(playlist): block track edits on synced playlists across all APIs A synced playlist's tracks come from its source file, so any track edit made through the UI or an API was silently reverted on the next scan. Track mutations funnel through two service guards, checkTracksEditable (incremental edits) and Create (wholesale replace, used by Subsonic createPlaylist and Jellyfin's replace path), which each duplicated the smart-playlist check. Both now consult a shared model.Playlist.TracksEditable() predicate, so the native, Subsonic, and Jellyfin paths are all locked: track edits return ErrNotAuthorized (403, or Subsonic error 50) instead of being accepted and lost. Metadata-only edits (name, comment, public, the sync flag itself) still go through checkWritable and are unaffected. In the UI, a synced playlist's track list becomes read-only, mirroring how smart playlists already behave. * fix(playlist): return 409 Conflict for non-editable playlist track edits The previous commit rejected track edits on smart and synced playlists with ErrNotAuthorized (403). That conflates two different things: a 403 says the caller lacks permission, but a synced or smart playlist's tracks are immutable for everyone, including the owner and admins. It is a property of the resource, not the caller. Introduce ErrPlaylistNotEditable and return it from both track-edit guards. The Native and Jellyfin APIs now map it to 409 Conflict; Subsonic maps it to error 50, the closest code it has (it has no read-only concept). The Native track handlers previously mapped this rejection inconsistently (400 on add, 500 on remove, 403 on reorder) through a new shared writePlaylistError helper. Genuine authorization failures (non-owner, non-admin) still return ErrNotAuthorized. * fix(playlist): surface synced read-only state in picker, Jellyfin, and OpenSubsonic Follow-up to the track-edit lock: the read-only state was enforced but not advertised consistently, so clients still offered edits that the server rejects. - UI: the Add to Playlist picker filtered targets by isWritable only, offering synced playlists that then 409 on add. It now filters with canChangeTracks. - Jellyfin: addToPlaylist/removeFromPlaylist hard-coded every error to 404, so a locked playlist reported "not found" instead of 409. They now return 409 for ErrPlaylistNotEditable while keeping the deliberate anti-probing 404 for every other error (a non-owner never reaches ErrPlaylistNotEditable, so 409 leaks nothing). - OpenSubsonic: buildOSPlaylist marked only smart playlists readonly; owned synced playlists advertised readonly=false. Readonly now also covers !TracksEditable(), matching the existing smart-playlist treatment. * fix(jellyfin): report CanEdit from playlist editability in permission probes getPlaylistUsers and getPlaylistUser returned CanEdit: true unconditionally, so Finamp (which probes this before showing edit controls) offered track editing on synced/smart playlists whose add/remove requests now return 409. Both handlers now fetch the playlist and set CanEdit from TracksEditable(), keeping the deliberate non-owner looseness (CanEdit stays true for a normal playlist a non-owner views) and mapping any lookup error to 404 like the sibling probes. * fix(playlist): check ownership before editability when replacing tracks Create checked TracksEditable() before ownership, so a non-owner replacing another user's public smart/synced playlist (Jellyfin updatePlaylist with a non-empty Ids list) received a 409 read-only conflict instead of a 403 authorization failure. The incremental guards check ownership first via checkWritable; Create now matches that order. Subsonic is unaffected (both errors map to code 50). Owners of their own smart/synced playlists still get the read-only conflict. * fix(jellyfin): return 403 for locked playlists, matching Jellyfin Jellyfin itself refuses edits on its file-backed playlists with Forbid() (403): PlaylistsController gates every mutation on OwnerUserId == caller or a share with CanEdit, and playlists imported from .m3u files satisfy neither. Its CanEdit is an ACL field, not a read-only marker, and Jellyfin core has no server-managed playlist type at all. Our Jellyfin routes exist to imitate that API, so ErrPlaylistNotEditable now maps to 403 there instead of 409. The native API keeps 409 (a resource-state conflict is the accurate REST answer where we define the contract) and Subsonic keeps error 50, its closest code. * chore(playlist): trim comments added by this branch Several comments ran to three or four lines and carried rationale that belongs in the commit history rather than the code: what Jellyfin does with its own file-backed playlists, and restatements of the expressions directly below them. Each block is now one or two lines covering only the non-obvious why. --- core/playlists/playlists.go | 13 +++-- core/playlists/playlists_test.go | 46 +++++++++++++-- model/errors.go | 3 + model/playlist.go | 5 ++ model/playlist_test.go | 15 +++++ server/jellyfin/playlists.go | 49 ++++++++++++---- server/jellyfin/playlists_test.go | 63 ++++++++++++++++++++- server/nativeapi/playlists.go | 30 ++++++---- server/nativeapi/playlists_test.go | 14 +++++ server/subsonic/api.go | 3 +- server/subsonic/playlists.go | 2 +- server/subsonic/playlists_test.go | 9 +++ ui/src/common/playlistUtils.js | 2 +- ui/src/common/playlistUtils.test.js | 6 ++ ui/src/dialogs/SelectPlaylistInput.jsx | 5 +- ui/src/dialogs/SelectPlaylistInput.test.jsx | 9 +++ 16 files changed, 232 insertions(+), 42 deletions(-) diff --git a/core/playlists/playlists.go b/core/playlists/playlists.go index 656bde05e..9ae8f09cb 100644 --- a/core/playlists/playlists.go +++ b/core/playlists/playlists.go @@ -130,12 +130,13 @@ func (s *playlists) Create(ctx context.Context, playlistId string, name string, if err != nil { return err } - if pls.IsSmartPlaylist() { - return model.ErrNotAuthorized - } + // Ownership first: a non-owner must get ErrNotAuthorized, not a read-only conflict. if !usr.IsAdmin && pls.OwnerID != usr.ID { return model.ErrNotAuthorized } + if !pls.TracksEditable() { + return model.ErrPlaylistNotEditable + } } else { pls = &model.Playlist{Name: name} pls.OwnerID = usr.ID @@ -230,14 +231,14 @@ func (s *playlists) checkWritable(ctx context.Context, id string) (*model.Playli return pls, nil } -// checkTracksEditable verifies the user can modify tracks (ownership + not smart playlist). +// checkTracksEditable verifies the user owns the playlist and its tracks are editable. func (s *playlists) checkTracksEditable(ctx context.Context, playlistID string) (*model.Playlist, error) { pls, err := s.checkWritable(ctx, playlistID) if err != nil { return nil, err } - if pls.IsSmartPlaylist() { - return nil, model.ErrNotAuthorized + if !pls.TracksEditable() { + return nil, model.ErrPlaylistNotEditable } return pls, nil } diff --git a/core/playlists/playlists_test.go b/core/playlists/playlists_test.go index e8134b2ef..dd182b3f1 100644 --- a/core/playlists/playlists_test.go +++ b/core/playlists/playlists_test.go @@ -102,6 +102,8 @@ var _ = Describe("Playlists", func() { "pls-2": {ID: "pls-2", Name: "Other's", OwnerID: "other-user"}, "pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1", Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, + "pls-synced": {ID: "pls-synced", Name: "Synced", OwnerID: "user-1", Sync: true}, + "pls-synced-other": {ID: "pls-synced-other", Name: "Other's Synced", OwnerID: "other-user", Sync: true, Public: true}, } ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) }) @@ -145,6 +147,18 @@ var _ = Describe("Playlists", func() { It("denies replacing tracks on a smart playlist", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) _, err := ps.Create(ctx, "pls-smart", "", []string{"song-1"}) + Expect(err).To(MatchError(model.ErrPlaylistNotEditable)) + }) + + It("denies replacing tracks on a synced playlist", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + _, err := ps.Create(ctx, "pls-synced", "", []string{"song-1"}) + Expect(err).To(MatchError(model.ErrPlaylistNotEditable)) + }) + + It("denies a non-owner with authorization, not a conflict, on a public synced playlist", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + _, err := ps.Create(ctx, "pls-synced-other", "", []string{"song-1"}) Expect(err).To(MatchError(model.ErrNotAuthorized)) }) }) @@ -159,6 +173,7 @@ var _ = Describe("Playlists", func() { "pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"}, "pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1", Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, + "pls-synced": {ID: "pls-synced", Name: "Synced", OwnerID: "user-1", Sync: true}, } mockPlsRepo.TracksRepo = mockTracks ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) @@ -191,13 +206,13 @@ var _ = Describe("Playlists", func() { It("denies adding tracks to a smart playlist", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) err := ps.Update(ctx, "pls-smart", nil, nil, nil, []string{"song-1"}, nil) - Expect(err).To(MatchError(model.ErrNotAuthorized)) + Expect(err).To(MatchError(model.ErrPlaylistNotEditable)) }) It("denies removing tracks from a smart playlist", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) err := ps.Update(ctx, "pls-smart", nil, nil, nil, nil, []int{0}) - Expect(err).To(MatchError(model.ErrNotAuthorized)) + Expect(err).To(MatchError(model.ErrPlaylistNotEditable)) }) It("allows metadata updates on a smart playlist", func() { @@ -205,6 +220,18 @@ var _ = Describe("Playlists", func() { err := ps.Update(ctx, "pls-smart", new("Updated Smart"), nil, nil, nil, nil) Expect(err).ToNot(HaveOccurred()) }) + + It("denies adding tracks to a synced playlist", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + err := ps.Update(ctx, "pls-synced", nil, nil, nil, []string{"song-1"}, nil) + Expect(err).To(MatchError(model.ErrPlaylistNotEditable)) + }) + + It("allows metadata updates on a synced playlist", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + err := ps.Update(ctx, "pls-synced", new("Renamed Synced"), nil, nil, nil, nil) + Expect(err).ToNot(HaveOccurred()) + }) }) Describe("AddTracks", func() { @@ -216,7 +243,8 @@ var _ = Describe("Playlists", func() { "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, "pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1", Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, - "pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"}, + "pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"}, + "pls-synced": {ID: "pls-synced", Name: "Synced", OwnerID: "user-1", Sync: true}, } mockPlsRepo.TracksRepo = mockTracks ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) @@ -246,7 +274,13 @@ var _ = Describe("Playlists", func() { It("denies editing smart playlists", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) _, err := ps.AddTracks(ctx, "pls-smart", []string{"song-1"}) - Expect(err).To(MatchError(model.ErrNotAuthorized)) + Expect(err).To(MatchError(model.ErrPlaylistNotEditable)) + }) + + It("denies editing synced playlists", func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + _, err := ps.AddTracks(ctx, "pls-synced", []string{"song-1"}) + Expect(err).To(MatchError(model.ErrPlaylistNotEditable)) }) It("returns error when playlist not found", func() { @@ -280,7 +314,7 @@ var _ = Describe("Playlists", func() { It("denies on smart playlist", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) err := ps.RemoveTracks(ctx, "pls-smart", []string{"track-1"}) - Expect(err).To(MatchError(model.ErrNotAuthorized)) + Expect(err).To(MatchError(model.ErrPlaylistNotEditable)) }) It("denies non-owner", func() { @@ -314,7 +348,7 @@ var _ = Describe("Playlists", func() { It("denies on smart playlist", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) err := ps.ReorderTrack(ctx, "pls-smart", 1, 3) - Expect(err).To(MatchError(model.ErrNotAuthorized)) + Expect(err).To(MatchError(model.ErrPlaylistNotEditable)) }) }) diff --git a/model/errors.go b/model/errors.go index 41029d316..0e2543378 100644 --- a/model/errors.go +++ b/model/errors.go @@ -9,4 +9,7 @@ var ( ErrExpired = errors.New("access expired") ErrNotAvailable = errors.New("functionality not available") ErrValidation = errors.New("validation error") + + // ErrPlaylistNotEditable: tracks are server-managed, so nobody can edit them (not an ACL failure). + ErrPlaylistNotEditable = errors.New("playlist tracks are not editable") ) diff --git a/model/playlist.go b/model/playlist.go index 55b94a640..306401271 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -42,6 +42,11 @@ func (pls Playlist) IsSmartPlaylist() bool { return pls.Rules != nil && pls.Rules.Expression != nil } +// TracksEditable reports whether the track list is user-owned rather than server-managed. +func (pls Playlist) TracksEditable() bool { + return !pls.IsSmartPlaylist() && !pls.Sync +} + // RefreshDelay returns the playlist's own refresh window when set, falling // back to the global SmartPlaylistRefreshDelay. func (pls Playlist) RefreshDelay() time.Duration { diff --git a/model/playlist_test.go b/model/playlist_test.go index d936129ce..d98c85716 100644 --- a/model/playlist_test.go +++ b/model/playlist_test.go @@ -73,4 +73,19 @@ var _ = Describe("Playlist", func() { Expect(pls.RefreshDelay()).To(Equal(5 * time.Second)) }) }) + + Describe("TracksEditable", func() { + It("is true for a plain playlist", func() { + Expect(model.Playlist{}.TracksEditable()).To(BeTrue()) + }) + + It("is false for a smart playlist", func() { + pls := model.Playlist{Rules: &criteria.Criteria{Expression: criteria.Is{"loved": true}}} + Expect(pls.TracksEditable()).To(BeFalse()) + }) + + It("is false for a synced playlist", func() { + Expect(model.Playlist{Sync: true}.TracksEditable()).To(BeFalse()) + }) + }) }) diff --git a/server/jellyfin/playlists.go b/server/jellyfin/playlists.go index 805b369c8..ac790a821 100644 --- a/server/jellyfin/playlists.go +++ b/server/jellyfin/playlists.go @@ -29,11 +29,11 @@ func playlistsFolder() dto.BaseItemDto { } } -// playlistError maps core/playlists write errors to HTTP status: ownership -> 403, missing/invisible -// -> 404 (never revealing another user's private playlist), else -> 500. +// playlistError maps core/playlists write errors to HTTP status: ownership or locked -> 403, +// missing/invisible -> 404 (never revealing another user's private playlist), else -> 500. func (api *Router) playlistError(w http.ResponseWriter, r *http.Request, err error) { switch { - case errors.Is(err, model.ErrNotAuthorized): + case errors.Is(err, model.ErrNotAuthorized), errors.Is(err, model.ErrPlaylistNotEditable): http.Error(w, "Forbidden", http.StatusForbidden) case errors.Is(err, model.ErrNotFound): http.Error(w, "Not Found", http.StatusNotFound) @@ -262,7 +262,7 @@ func (api *Router) songIDs(ctx context.Context, opts model.QueryOptions) []strin } // addToPlaylist appends items by id, expanding containers into tracks (see expandContainerIDs). -// AddTracks enforces ownership; any error maps to 404. +// AddTracks enforces ownership; a locked playlist maps to 403, any other error to 404. func (api *Router) addToPlaylist(w http.ResponseWriter, r *http.Request) { ctx := r.Context() id, ok := itemIDParam(w, r, "playlistId") @@ -276,6 +276,10 @@ func (api *Router) addToPlaylist(w http.ResponseWriter, r *http.Request) { } ids := api.expandContainerIDs(ctx, decoded) if _, err := api.playlists.AddTracks(ctx, id, ids); err != nil { + if errors.Is(err, model.ErrPlaylistNotEditable) { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } http.Error(w, "Not Found", http.StatusNotFound) return } @@ -284,7 +288,7 @@ func (api *Router) addToPlaylist(w http.ResponseWriter, r *http.Request) { // removeFromPlaylist removes entries by entryIds — playlist-entry ids (PlaylistItemId), not media // file ids, since RemoveTracks deletes playlist_tracks rows by that id. RemoveTracks enforces -// ownership; any error maps to 404. +// ownership; a locked playlist maps to 403, any other error to 404. func (api *Router) removeFromPlaylist(w http.ResponseWriter, r *http.Request) { ctx := r.Context() id, ok := itemIDParam(w, r, "playlistId") @@ -302,21 +306,44 @@ func (api *Router) removeFromPlaylist(w http.ResponseWriter, r *http.Request) { ids = append(ids, entry) } if err := api.playlists.RemoveTracks(ctx, id, ids); err != nil { + if errors.Is(err, model.ErrPlaylistNotEditable) { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } http.Error(w, "Not Found", http.StatusNotFound) return } w.WriteHeader(http.StatusNoContent) } -// getPlaylistUsers and getPlaylistUser answer client probes (e.g. Finamp) made before allowing -// edits. Navidrome has no per-playlist ACL, so every user is reported CanEdit; ownership is still -// enforced by AddTracks/RemoveTracks. +// Clients probe these before offering edits. Navidrome has no per-playlist ACL, so CanEdit carries +// only editability; ownership is enforced on write, and a lookup error 404s to prevent probing. func (api *Router) getPlaylistUsers(w http.ResponseWriter, r *http.Request) { - u, _ := request.UserFrom(r.Context()) - api.ok(w, r, []dto.PlaylistUserPermissions{{UserId: dto.EncodeID(u.ID), CanEdit: true}}) + ctx := r.Context() + id, ok := itemIDParam(w, r, "playlistId") + if !ok { + return + } + pls, err := api.playlists.Get(ctx, id) + if err != nil { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + u, _ := request.UserFrom(ctx) + api.ok(w, r, []dto.PlaylistUserPermissions{{UserId: dto.EncodeID(u.ID), CanEdit: pls.TracksEditable()}}) } func (api *Router) getPlaylistUser(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + id, ok := itemIDParam(w, r, "playlistId") + if !ok { + return + } + pls, err := api.playlists.Get(ctx, id) + if err != nil { + http.Error(w, "Not Found", http.StatusNotFound) + return + } userId := chi.URLParam(r, "userId") - api.ok(w, r, dto.PlaylistUserPermissions{UserId: userId, CanEdit: true}) + api.ok(w, r, dto.PlaylistUserPermissions{UserId: userId, CanEdit: pls.TracksEditable()}) } diff --git a/server/jellyfin/playlists_test.go b/server/jellyfin/playlists_test.go index 3599cc784..270f5fe08 100644 --- a/server/jellyfin/playlists_test.go +++ b/server/jellyfin/playlists_test.go @@ -381,6 +381,15 @@ var _ = Describe("Playlists", func() { Expect(w.Code).To(Equal(http.StatusNotFound)) }) + It("returns 403 when the playlist is not editable (synced/smart), like Jellyfin", func() { + fp.addErr = model.ErrPlaylistNotEditable + w := httptest.NewRecorder() + 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.StatusForbidden)) + }) + It("passes no ids (not a spurious empty string) when the ids param is absent", func() { w := httptest.NewRecorder() r := httptest.NewRequest("POST", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items", nil).WithContext(context.Background()) @@ -430,6 +439,15 @@ var _ = Describe("Playlists", func() { Expect(w.Code).To(Equal(http.StatusNotFound)) }) + It("returns 403 when the playlist is not editable (synced/smart), like Jellyfin", func() { + fp.removeErr = model.ErrPlaylistNotEditable + w := httptest.NewRecorder() + 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.StatusForbidden)) + }) + It("passes no ids (not a spurious empty string) when the entryIds param is absent", func() { w := httptest.NewRecorder() r := httptest.NewRequest("DELETE", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items", nil).WithContext(context.Background()) @@ -442,7 +460,8 @@ var _ = Describe("Playlists", func() { }) Describe("getPlaylistUsers", func() { - It("returns the current user with CanEdit true", func() { + It("returns the current user with CanEdit true for an editable playlist", func() { + fp.getByIDPls = &model.Playlist{ID: testID("pl1")} w := httptest.NewRecorder() ctx := request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice"}) r := httptest.NewRequest("GET", "/Playlists/"+testID("pl1")+"/Users", nil).WithContext(ctx) @@ -453,21 +472,59 @@ var _ = Describe("Playlists", func() { Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) Expect(res).To(Equal([]dto.PlaylistUserPermissions{{UserId: dto.EncodeID(testID("u1")), CanEdit: true}})) }) + + It("reports CanEdit false for a synced playlist", func() { + fp.getByIDPls = &model.Playlist{ID: testID("pl1"), Sync: true} + w := httptest.NewRecorder() + 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[0].CanEdit).To(BeFalse()) + }) + + It("returns 404 when the playlist is not visible", func() { + fp.getByIDErr = model.ErrNotFound + w := httptest.NewRecorder() + 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.StatusNotFound)) + }) }) Describe("getPlaylistUser", func() { - It("returns CanEdit true for the requested user", func() { + requestUser := func() *httptest.ResponseRecorder { w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/Playlists/"+testID("pl1")+"/Users/"+testID("u1"), nil).WithContext(context.Background()) rctx := chi.NewRouteContext() - rctx.URLParams.Add("playlistId", testID("pl1")) + rctx.URLParams.Add("playlistId", dto.EncodeID(testID("pl1"))) rctx.URLParams.Add("userId", testID("u1")) r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx)) api.getPlaylistUser(w, r) + return w + } + + It("returns CanEdit true for an editable playlist", func() { + fp.getByIDPls = &model.Playlist{ID: testID("pl1")} + w := requestUser() 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: testID("u1"), CanEdit: true})) }) + + It("reports CanEdit false for a synced playlist", func() { + fp.getByIDPls = &model.Playlist{ID: testID("pl1"), Sync: true} + w := requestUser() + Expect(w.Code).To(Equal(http.StatusOK)) + var res dto.PlaylistUserPermissions + Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) + Expect(res.CanEdit).To(BeFalse()) + }) }) }) diff --git a/server/nativeapi/playlists.go b/server/nativeapi/playlists.go index 90b2f9e94..d215eb9dd 100644 --- a/server/nativeapi/playlists.go +++ b/server/nativeapi/playlists.go @@ -20,6 +20,20 @@ import ( type restHandler = func(rest.RepositoryConstructor, ...rest.Logger) http.HandlerFunc +// writePlaylistError maps a playlist service error to an HTTP status, or defaultStatus if unknown. +func writePlaylistError(w http.ResponseWriter, err error, defaultStatus int) { + switch { + case errors.Is(err, model.ErrNotFound): + http.Error(w, err.Error(), http.StatusNotFound) + case errors.Is(err, model.ErrNotAuthorized): + http.Error(w, err.Error(), http.StatusForbidden) + case errors.Is(err, model.ErrPlaylistNotEditable): + http.Error(w, err.Error(), http.StatusConflict) + default: + http.Error(w, err.Error(), defaultStatus) + } +} + func playlistTracksHandler(pls playlists.Playlists, handler restHandler, refreshSmartPlaylist func(*http.Request) bool) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { plsId := chi.URLParam(r, "playlistId") @@ -111,7 +125,7 @@ func deleteFromPlaylist(pls playlists.Playlists) http.HandlerFunc { } if err != nil { log.Error(r.Context(), "Error deleting tracks from playlist", "playlistId", playlistId, "ids", ids, err) - http.Error(w, err.Error(), http.StatusInternalServerError) + writePlaylistError(w, err, http.StatusInternalServerError) return } writeDeleteManyResponse(w, r, ids) @@ -138,22 +152,22 @@ func addToPlaylist(pls playlists.Playlists) http.HandlerFunc { } count, c := 0, 0 if c, err = pls.AddTracks(ctx, playlistId, payload.Ids); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + writePlaylistError(w, err, http.StatusBadRequest) return } count += c if c, err = pls.AddAlbums(ctx, playlistId, payload.AlbumIds); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + writePlaylistError(w, err, http.StatusBadRequest) return } count += c if c, err = pls.AddArtists(ctx, playlistId, payload.ArtistIds); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + writePlaylistError(w, err, http.StatusBadRequest) return } count += c if c, err = pls.AddDiscs(ctx, playlistId, payload.Discs); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + writePlaylistError(w, err, http.StatusBadRequest) return } count += c @@ -192,12 +206,8 @@ func reorderItem(pls playlists.Playlists) http.HandlerFunc { return } err = pls.ReorderTrack(ctx, playlistId, id, newPos) - if errors.Is(err, model.ErrNotAuthorized) { - http.Error(w, err.Error(), http.StatusForbidden) - return - } if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + writePlaylistError(w, err, http.StatusBadRequest) return } diff --git a/server/nativeapi/playlists_test.go b/server/nativeapi/playlists_test.go index 9bf502687..74ef58cab 100644 --- a/server/nativeapi/playlists_test.go +++ b/server/nativeapi/playlists_test.go @@ -183,6 +183,20 @@ var _ = Describe("Playlist Tracks Endpoint", func() { }) }) +var _ = Describe("writePlaylistError", func() { + DescribeTable("maps a service error to an HTTP status", + func(err error, expected int) { + w := httptest.NewRecorder() + writePlaylistError(w, err, http.StatusBadRequest) + Expect(w.Code).To(Equal(expected)) + }, + Entry("not found -> 404", model.ErrNotFound, http.StatusNotFound), + Entry("not authorized -> 403", model.ErrNotAuthorized, http.StatusForbidden), + Entry("not editable -> 409", model.ErrPlaylistNotEditable, http.StatusConflict), + Entry("unrecognized -> default", model.ErrValidation, http.StatusBadRequest), + ) +}) + type mockPlaylistTrackRepo struct { model.PlaylistTrackRepository tracks model.PlaylistTracks diff --git a/server/subsonic/api.go b/server/subsonic/api.go index 82e404228..029046c39 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -304,7 +304,8 @@ func mapToSubsonicError(err error) subError { err = newError(responses.ErrorGeneric, err.Error()) case errors.Is(err, model.ErrNotFound), errors.Is(err, rest.ErrNotFound): err = newError(responses.ErrorDataNotFound, "data not found") - case errors.Is(err, model.ErrNotAuthorized), errors.Is(err, rest.ErrPermissionDenied): + case errors.Is(err, model.ErrNotAuthorized), errors.Is(err, rest.ErrPermissionDenied), + errors.Is(err, model.ErrPlaylistNotEditable): // Subsonic has no code for "read-only resource" err = newError(responses.ErrorAuthorizationFail) case errors.Is(err, stream.ErrTooManyTranscodes): err = newError(responses.ErrorGeneric, "too many concurrent transcodes, please retry shortly") diff --git a/server/subsonic/playlists.go b/server/subsonic/playlists.go index bd53528d9..774a9c430 100644 --- a/server/subsonic/playlists.go +++ b/server/subsonic/playlists.go @@ -172,7 +172,7 @@ func buildOSPlaylist(ctx context.Context, p model.Playlist) *responses.OpenSubso } } else { user, ok := request.UserFrom(ctx) - pls.Readonly = !ok || p.OwnerID != user.ID + pls.Readonly = !ok || p.OwnerID != user.ID || !p.TracksEditable() } return &pls diff --git a/server/subsonic/playlists_test.go b/server/subsonic/playlists_test.go index a7c9e2ec7..f18f33b47 100644 --- a/server/subsonic/playlists_test.go +++ b/server/subsonic/playlists_test.go @@ -111,6 +111,15 @@ var _ = Describe("buildPlaylist", func() { Expect(result.Public).To(BeTrue()) Expect(result.Readonly).To(BeFalse()) }) + + It("is read-only for a synced playlist even as owner", func() { + ctx = request.WithUser(ctx, model.User{ID: "1234", UserName: "admin"}) + playlist.Sync = true + + result := router.buildPlaylist(ctx, playlist) + + Expect(result.Readonly).To(BeTrue()) + }) }) Context("when minimal clients list is empty", func() { diff --git a/ui/src/common/playlistUtils.js b/ui/src/common/playlistUtils.js index 74a01d47a..2d0c745ff 100644 --- a/ui/src/common/playlistUtils.js +++ b/ui/src/common/playlistUtils.js @@ -12,4 +12,4 @@ export const isReadOnly = (ownerId) => { export const isSmartPlaylist = (pls) => !!pls.rules export const canChangeTracks = (pls) => - isWritable(pls.ownerId) && !isSmartPlaylist(pls) + isWritable(pls.ownerId) && !isSmartPlaylist(pls) && !pls.sync diff --git a/ui/src/common/playlistUtils.test.js b/ui/src/common/playlistUtils.test.js index 2c671ecf5..345b6045d 100644 --- a/ui/src/common/playlistUtils.test.js +++ b/ui/src/common/playlistUtils.test.js @@ -74,5 +74,11 @@ describe('playlistUtils', () => { const playlist = { ownerId: 'user1', rules: [] } expect(canChangeTracks(playlist)).toBe(false) }) + + it('returns false if playlist is synced', () => { + localStorage.setItem('userId', 'user1') + const playlist = { ownerId: 'user1', sync: true } + expect(canChangeTracks(playlist)).toBe(false) + }) }) }) diff --git a/ui/src/dialogs/SelectPlaylistInput.jsx b/ui/src/dialogs/SelectPlaylistInput.jsx index 847107523..2f040a7f7 100644 --- a/ui/src/dialogs/SelectPlaylistInput.jsx +++ b/ui/src/dialogs/SelectPlaylistInput.jsx @@ -16,7 +16,7 @@ import { import AddIcon from '@material-ui/icons/Add' import { useGetList, useTranslate } from 'react-admin' import PropTypes from 'prop-types' -import { isWritable } from '../common' +import { canChangeTracks } from '../common' import { makeStyles } from '@material-ui/core' const useStyles = makeStyles((theme) => ({ @@ -268,8 +268,7 @@ export const SelectPlaylistInput = ({ onChange }) => { ) const options = - ids && - ids.map((id) => data[id]).filter((option) => isWritable(option.ownerId)) + ids && ids.map((id) => data[id]).filter((option) => canChangeTracks(option)) // Filter playlists based on search text const filteredOptions = diff --git a/ui/src/dialogs/SelectPlaylistInput.test.jsx b/ui/src/dialogs/SelectPlaylistInput.test.jsx index 4ffcdf0b6..753d12c03 100644 --- a/ui/src/dialogs/SelectPlaylistInput.test.jsx +++ b/ui/src/dialogs/SelectPlaylistInput.test.jsx @@ -16,6 +16,7 @@ const mockPlaylists = [ { id: 'playlist-2', name: 'Jazz Collection', ownerId: 'admin' }, { id: 'playlist-3', name: 'Electronic Beats', ownerId: 'admin' }, { id: 'playlist-4', name: 'Chill Vibes', ownerId: 'user2' }, // Not writable by admin + { id: 'playlist-5', name: 'Synced List', ownerId: 'admin', sync: true }, ] const mockIndexedData = { @@ -27,6 +28,12 @@ const mockIndexedData = { ownerId: 'admin', }, 'playlist-4': { id: 'playlist-4', name: 'Chill Vibes', ownerId: 'user2' }, + 'playlist-5': { + id: 'playlist-5', + name: 'Synced List', + ownerId: 'admin', + sync: true, + }, } const createTestComponent = ( @@ -89,6 +96,8 @@ describe('SelectPlaylistInput', () => { // Should not show playlists not owned by admin (not writable) expect(screen.queryByText('Chill Vibes')).not.toBeInTheDocument() + // Should not show synced playlists (their tracks are not editable) + expect(screen.queryByText('Synced List')).not.toBeInTheDocument() }) it('should filter playlists based on search input', async () => { From 2dab4b40483a6feb3be2007afa4a1ac83c2d0504 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 19 Aug 2026 08:49:15 -0400 Subject: [PATCH 03/78] chore: remove redundant comment Signed-off-by: Deluan --- model/errors.go | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/model/errors.go b/model/errors.go index 0e2543378..9e56f9501 100644 --- a/model/errors.go +++ b/model/errors.go @@ -3,13 +3,11 @@ package model import "errors" var ( - ErrNotFound = errors.New("data not found") - ErrInvalidAuth = errors.New("invalid authentication") - ErrNotAuthorized = errors.New("not authorized") - ErrExpired = errors.New("access expired") - ErrNotAvailable = errors.New("functionality not available") - ErrValidation = errors.New("validation error") - - // ErrPlaylistNotEditable: tracks are server-managed, so nobody can edit them (not an ACL failure). + ErrNotFound = errors.New("data not found") + ErrInvalidAuth = errors.New("invalid authentication") + ErrNotAuthorized = errors.New("not authorized") + ErrExpired = errors.New("access expired") + ErrNotAvailable = errors.New("functionality not available") + ErrValidation = errors.New("validation error") ErrPlaylistNotEditable = errors.New("playlist tracks are not editable") ) From 6d8a3e48eeacf4dda8edd6841f714f3ee064a371 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 19 Aug 2026 09:19:47 -0400 Subject: [PATCH 04/78] refactor: replace `md5` with `xxh3` for faster and more efficient hashing Signed-off-by: Deluan --- .golangci.yml | 3 +++ core/artwork/housekeeping.go | 6 ++---- core/artwork/housekeeping_test.go | 2 +- model/mediafile.go | 4 ++-- model/mediafile_test.go | 12 ++++-------- model/participants.go | 4 ++-- model/tag.go | 4 ++-- persistence/sql_base_repository.go | 6 +++--- 8 files changed, 19 insertions(+), 22 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 0804138dc..7df4f8ec3 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -27,6 +27,9 @@ linters: disable: - staticcheck settings: + errcheck: + exclude-functions: + - (*github.com/zeebo/xxh3.Hasher).Write gocritic: disable-all: true enabled-checks: diff --git a/core/artwork/housekeeping.go b/core/artwork/housekeeping.go index ae1fc0a0d..f2996d044 100644 --- a/core/artwork/housekeeping.go +++ b/core/artwork/housekeeping.go @@ -2,8 +2,6 @@ package artwork import ( "context" - "crypto/md5" - "encoding/hex" "fmt" "slices" "strconv" @@ -16,6 +14,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/slice" + "github.com/zeebo/xxh3" ) // StaleAbsentAge is how long an absent state is trusted before a recheck retries it. @@ -66,8 +65,7 @@ func FingerprintInputs() []FingerprintInput { func ConfigFingerprint() string { values := slice.Map(FingerprintInputs(), func(i FingerprintInput) string { return i.Value }) raw := fmt.Sprintf("%s|%d", strings.Join(values, "|"), artworkEpoch) - sum := md5.Sum([]byte(raw)) //nolint:gosec // fingerprint, not security-sensitive - return hex.EncodeToString(sum[:]) + return fmt.Sprintf("%016x", xxh3.Hash([]byte(raw))) } // backfill enqueues artwork resolution for every entity when the config fingerprint changed. diff --git a/core/artwork/housekeeping_test.go b/core/artwork/housekeeping_test.go index 32a7688b4..4ea15ab04 100644 --- a/core/artwork/housekeeping_test.go +++ b/core/artwork/housekeeping_test.go @@ -135,7 +135,7 @@ var _ = Describe("Housekeeping", func() { conf.Server.EnableExternalServices = true conf.Server.EnableM3UExternalAlbumArt = false - Expect(ConfigFingerprint()).To(Equal("7e537a22febc07d3d5ca40546e88da54")) + Expect(ConfigFingerprint()).To(Equal("7b538a83a870c16d")) }) It("reports the config inputs it hashes, so a change can be traced to a setting", func() { diff --git a/model/mediafile.go b/model/mediafile.go index f4e767272..99aee591e 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -2,7 +2,6 @@ package model import ( "cmp" - "crypto/md5" "encoding/json" "fmt" "iter" @@ -20,6 +19,7 @@ import ( "github.com/navidrome/navidrome/utils/gg" "github.com/navidrome/navidrome/utils/number" "github.com/navidrome/navidrome/utils/slice" + "github.com/zeebo/xxh3" ) type MediaFile struct { @@ -232,7 +232,7 @@ func (mf MediaFile) Hash() string { ZeroNil: true, } hash, _ := hashstructure.Hash(mf, opts) - sum := md5.New() + sum := xxh3.New() sum.Write(fmt.Appendf(nil, "%d", hash)) sum.Write(mf.Tags.Hash()) sum.Write(mf.Participants.Hash()) diff --git a/model/mediafile_test.go b/model/mediafile_test.go index 3f306f1a7..097e3ca54 100644 --- a/model/mediafile_test.go +++ b/model/mediafile_test.go @@ -715,14 +715,10 @@ var _ = Describe("MediaFile.Movements", func() { }) var _ = Describe("MediaFile.Hash", func() { - // Guards the upgrade guarantee: converting BPM/BitDepth from int to *int must not change hashes, - // or every file would be spuriously re-imported on the next scan. - // Golden hashes were captured at 46221d516 when those fields were plain ints. - It("keeps hashes identical to the pre-pointer-conversion values", func() { - // Golden hashes computed at 46221d516, when BPM/BitDepth were plain ints — pinning - // them guarantees the pointer conversion cannot trigger a full-library re-import. - Expect(MediaFile{Title: "Song"}.Hash()).To(Equal("1d856ced42cb96db39e354a4bac9a622")) - Expect(MediaFile{Title: "Song", BPM: new(120), BitDepth: new(16)}.Hash()).To(Equal("b2b0b1d1dd7fd767093588e4af3a0689")) + // Pins the hash formula: an accidental change spuriously re-imports every file on the next scan. + It("hashes to a stable value", func() { + Expect(MediaFile{Title: "Song"}.Hash()).To(Equal("05fdf70bb0cbe090")) + Expect(MediaFile{Title: "Song", BPM: new(120), BitDepth: new(16)}.Hash()).To(Equal("b5daf6ac1009a538")) }) It("changes the hash when a pointer field has a value", func() { base := MediaFile{Title: "Song"} diff --git a/model/participants.go b/model/participants.go index afbda10de..b5390d22c 100644 --- a/model/participants.go +++ b/model/participants.go @@ -2,12 +2,12 @@ package model import ( "cmp" - "crypto/md5" "fmt" "slices" "strings" "github.com/navidrome/navidrome/utils/slice" + "github.com/zeebo/xxh3" ) var ( @@ -193,7 +193,7 @@ func (p Participants) Hash() []byte { flattened = append(flattened, role.String()+":"+strings.Join(ids, "/")) } slices.Sort(flattened) - sum := md5.New() + sum := xxh3.New() sum.Write([]byte(strings.Join(flattened, "|"))) return sum.Sum(nil) } diff --git a/model/tag.go b/model/tag.go index 1bc011495..bb4fce181 100644 --- a/model/tag.go +++ b/model/tag.go @@ -2,13 +2,13 @@ package model import ( "cmp" - "crypto/md5" "fmt" "slices" "strings" "github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/utils/slice" + "github.com/zeebo/xxh3" ) type Tag struct { @@ -117,7 +117,7 @@ func (t Tags) Hash() []byte { } ids := t.IDs() slices.Sort(ids) - sum := md5.New() + sum := xxh3.New() sum.Write([]byte(strings.Join(ids, "|"))) return sum.Sum(nil) } diff --git a/persistence/sql_base_repository.go b/persistence/sql_base_repository.go index c61bca1a6..d4cf9b456 100644 --- a/persistence/sql_base_repository.go +++ b/persistence/sql_base_repository.go @@ -2,7 +2,6 @@ package persistence import ( "context" - "crypto/md5" "database/sql" "errors" "fmt" @@ -23,6 +22,7 @@ import ( "github.com/navidrome/navidrome/utils/hasher" "github.com/navidrome/navidrome/utils/slice" "github.com/pocketbase/dbx" + "github.com/zeebo/xxh3" ) // sqlRepository is the base repository for all SQL repositories. It provides common functions to interact with the DB. @@ -298,8 +298,8 @@ func (r sqlRepository) visibleLibraryIDs() ([]int, error) { func (r sqlRepository) seedKey() string { // Seed keys must be all lowercase, or else SQLite3 will encode it, making it not match the seed // used in the query. Hashing the user ID and converting it to a hex string will do the trick - userIDHash := md5.Sum([]byte(loggedUser(r.ctx).ID)) - return fmt.Sprintf("%s|%x", r.tableName, userIDHash) + userIDHash := xxh3.Hash([]byte(loggedUser(r.ctx).ID)) + return fmt.Sprintf("%s|%016x", r.tableName, userIDHash) } func (r sqlRepository) resetSeededRandom(options []model.QueryOptions) { From bd6b7a66863ce4771cd8e3450e355cf512b31363 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 19 Aug 2026 10:35:15 -0400 Subject: [PATCH 05/78] test: increase timeout for cache availability checks to 10 seconds --- core/artwork/e2e/acquire_serve_test.go | 2 +- core/artwork/e2e/resolution_harness_test.go | 2 +- core/stream/media_streamer_test.go | 9 +++++---- server/subsonic/e2e/subsonic_artwork_test.go | 2 +- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/core/artwork/e2e/acquire_serve_test.go b/core/artwork/e2e/acquire_serve_test.go index 0a9363b5d..34dfb2cac 100644 --- a/core/artwork/e2e/acquire_serve_test.go +++ b/core/artwork/e2e/acquire_serve_test.go @@ -106,7 +106,7 @@ var _ = Describe("Acquisition → serve loop", func() { 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()) + Eventually(func() bool { return imgCache.Available(ctx) }, 10*time.Second).Should(BeTrue()) svc = artwork.NewArtwork(ds, imgCache, store, ffm) worker = artwork.NewWorker(ds, store, agents.GetAgents(ds, nil), ffm, events.NoopBroker(), imgCache) diff --git a/core/artwork/e2e/resolution_harness_test.go b/core/artwork/e2e/resolution_harness_test.go index 1168393df..fff62ce69 100644 --- a/core/artwork/e2e/resolution_harness_test.go +++ b/core/artwork/e2e/resolution_harness_test.go @@ -117,7 +117,7 @@ func setupResolutionHarness() { 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()) + Eventually(func() bool { return imgCache.Available(rctx) }, 10*time.Second).Should(BeTrue()) rsvc = artwork.NewArtwork(rds, imgCache, rstore, ffm) rworker = artwork.NewWorker(rds, rstore, agents.GetAgents(rds, nil), ffm, events.NoopBroker(), imgCache) diff --git a/core/stream/media_streamer_test.go b/core/stream/media_streamer_test.go index f5ca16d3f..fb1c59a60 100644 --- a/core/stream/media_streamer_test.go +++ b/core/stream/media_streamer_test.go @@ -5,6 +5,7 @@ import ( "errors" "io" "os" + "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" @@ -33,7 +34,7 @@ var _ = Describe("MediaStreamer", func() { {ID: "123", Path: "tests/fixtures/test.mp3", Suffix: "mp3", BitRate: 128, Duration: 257.0}, }) testCache := stream.NewTranscodingCache() - Eventually(func() bool { return testCache.Available(context.TODO()) }).Should(BeTrue()) + Eventually(func() bool { return testCache.Available(context.TODO()) }, 10*time.Second).Should(BeTrue()) streamer = stream.NewMediaStreamer(ds, ffmpeg, testCache) }) AfterEach(func() { @@ -75,7 +76,7 @@ var _ = Describe("MediaStreamer", func() { conf.Server.Transcoding.MaxConcurrent = 1 conf.Server.Transcoding.MaxConcurrentPerUser = 0 tightCache := stream.NewTranscodingCache() - Eventually(func() bool { return tightCache.Available(context.TODO()) }).Should(BeTrue()) + Eventually(func() bool { return tightCache.Available(context.TODO()) }, 10*time.Second).Should(BeTrue()) tightStreamer := stream.NewMediaStreamer(ds, blockingFFmpeg, tightCache) userCtx := request.WithUsername(ctx, "alice") @@ -92,7 +93,7 @@ var _ = Describe("MediaStreamer", func() { conf.Server.Transcoding.MaxConcurrent = 1 conf.Server.Transcoding.MaxConcurrentPerUser = 0 tightCache := stream.NewTranscodingCache() - Eventually(func() bool { return tightCache.Available(context.TODO()) }).Should(BeTrue()) + Eventually(func() bool { return tightCache.Available(context.TODO()) }, 10*time.Second).Should(BeTrue()) tightStreamer := stream.NewMediaStreamer(ds, ffmpeg, tightCache) userCtx := request.WithUsername(ctx, "alice") @@ -112,7 +113,7 @@ var _ = Describe("MediaStreamer", func() { conf.Server.Transcoding.MaxConcurrent = 1 conf.Server.Transcoding.MaxConcurrentPerUser = 0 tightCache := stream.NewTranscodingCache() - Eventually(func() bool { return tightCache.Available(context.TODO()) }).Should(BeTrue()) + Eventually(func() bool { return tightCache.Available(context.TODO()) }, 10*time.Second).Should(BeTrue()) tightStreamer := stream.NewMediaStreamer(ds, ffmpeg, tightCache) userCtx := request.WithUsername(ctx, "alice") diff --git a/server/subsonic/e2e/subsonic_artwork_test.go b/server/subsonic/e2e/subsonic_artwork_test.go index 619619810..9324ea9e3 100644 --- a/server/subsonic/e2e/subsonic_artwork_test.go +++ b/server/subsonic/e2e/subsonic_artwork_test.go @@ -313,7 +313,7 @@ func newDummyImageCache(ctx context.Context) cache.FileCache { 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()) + Eventually(func() bool { return c.Available(ctx) }, 10*time.Second).Should(BeTrue()) return c } From c362519f76d1922a6c3e1345e5ec91dada6275ed Mon Sep 17 00:00:00 2001 From: Junker der Provinz <133605895+junkerderprovinz@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:50:32 +0200 Subject: [PATCH 06/78] test: unskip path-separator tests on Windows (#5381) (#5916) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: unskip AbsolutePath and i18n path-separator tests on Windows (#5381) Signed-off-by: junkerderprovinz * test: unskip metadata folder-PID test on Windows via path.Dir (#5381) Signed-off-by: junkerderprovinz * test(storage): make relative-folder assertion cross-platform and unskip on Windows (#5381) Signed-off-by: junkerderprovinz * fix(persistence): normalize folder-update-info paths with forward slashes on Windows (#5381) Signed-off-by: junkerderprovinz * review: drop folder-PID change, trim storage_test comment (#5381) Revert model/metadata/persistent_ids.go to master: switching the `folder` PID attribute from filepath.Dir to path.Dir would change the persistent IDs of existing Windows libraries and needs a migration path, so it is out of scope for this PR. The matching test unskip is reverted with it, leaving #TBD-path-sep-metadata open in #5381. Trim the core/storage/storage_test.go comment to two lines. Signed-off-by: junkerderprovinz --------- Signed-off-by: junkerderprovinz Co-authored-by: Deluan Quintão --- core/common_test.go | 4 ++-- core/storage/storage_test.go | 10 ++++++---- persistence/folder_repository_test.go | 5 ----- server/nativeapi/translations_test.go | 5 ++--- 4 files changed, 10 insertions(+), 14 deletions(-) diff --git a/core/common_test.go b/core/common_test.go index 0d6e3a299..f57ea8d38 100644 --- a/core/common_test.go +++ b/core/common_test.go @@ -2,6 +2,7 @@ package core import ( "context" + "path/filepath" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -41,10 +42,9 @@ var _ = Describe("common.go", func() { }) It("returns the absolute path when library exists", func() { - tests.SkipOnWindows("path separator bug (#TBD-path-sep-core)") ctx := context.Background() abs := AbsolutePath(ctx, ds, libId, path) - Expect(abs).To(Equal("/library/root/music/file.mp3")) + Expect(abs).To(Equal(filepath.FromSlash("/library/root/music/file.mp3"))) }) It("returns the original path if library not found", func() { diff --git a/core/storage/storage_test.go b/core/storage/storage_test.go index 336b5a7a9..03904d086 100644 --- a/core/storage/storage_test.go +++ b/core/storage/storage_test.go @@ -7,7 +7,6 @@ import ( "runtime" "testing" - "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -56,13 +55,16 @@ var _ = Describe("Storage", func() { Expect(s.(*fakeLocalStorage).u.Path).To(Equal("/tmp")) }) It("should return a file implementation for a relative folder", func() { - tests.SkipOnWindows("path separator bug (#TBD-path-sep-storage)") s, err := For("tmp") Expect(err).ToNot(HaveOccurred()) cwd, _ := os.Getwd() Expect(s).To(BeAssignableToTypeOf(&fakeLocalStorage{})) - Expect(s.(*fakeLocalStorage).u.Scheme).To(Equal("file")) - Expect(s.(*fakeLocalStorage).u.Path).To(Equal(filepath.Join(cwd, "tmp"))) + u := s.(*fakeLocalStorage).u + Expect(u.Scheme).To(Equal("file")) + // On Windows the drive letter lands in u.Host, so re-join it with + // u.Path (as newLocalStorage does) to keep the assertion OS-independent. + got := filepath.Join(u.Host, filepath.FromSlash(u.Path)) + Expect(got).To(Equal(filepath.Join(cwd, "tmp"))) }) It("should return error if schema is unregistered", func() { _, err := For("webdav:///tmp") diff --git a/persistence/folder_repository_test.go b/persistence/folder_repository_test.go index b429cecb9..545d8ad49 100644 --- a/persistence/folder_repository_test.go +++ b/persistence/folder_repository_test.go @@ -8,7 +8,6 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/tests" "github.com/navidrome/navidrome/utils/slice" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -137,7 +136,6 @@ var _ = Describe("FolderRepository", func() { }) It("includes all child folders when querying parent", func() { - tests.SkipOnWindows("path storage (#TBD-path-sep-persistence)") // Create a parent folder with multiple children parent := model.NewFolder(testLib, "TestParent/Music") child1 := model.NewFolder(testLib, "TestParent/Music/Rock/Queen") @@ -159,7 +157,6 @@ var _ = Describe("FolderRepository", func() { }) It("excludes children from other libraries", func() { - tests.SkipOnWindows("path storage (#TBD-path-sep-persistence)") // Create parent in testLib parent := model.NewFolder(testLib, "TestIsolation/Parent") child := model.NewFolder(testLib, "TestIsolation/Parent/Child") @@ -185,7 +182,6 @@ var _ = Describe("FolderRepository", func() { }) It("excludes missing children when querying parent", func() { - tests.SkipOnWindows("path storage (#TBD-path-sep-persistence)") // Create parent and children, mark one as missing parent := model.NewFolder(testLib, "TestMissingChild/Parent") child1 := model.NewFolder(testLib, "TestMissingChild/Parent/Child1") @@ -206,7 +202,6 @@ var _ = Describe("FolderRepository", func() { }) It("handles mix of existing and non-existing target paths", func() { - tests.SkipOnWindows("path storage (#TBD-path-sep-persistence)") // Create folders for one path but not the other existingParent := model.NewFolder(testLib, "TestMixed/Exists") existingChild := model.NewFolder(testLib, "TestMixed/Exists/Child") diff --git a/server/nativeapi/translations_test.go b/server/nativeapi/translations_test.go index 77c088f6f..1c641da77 100644 --- a/server/nativeapi/translations_test.go +++ b/server/nativeapi/translations_test.go @@ -5,12 +5,12 @@ import ( "io" "io/fs" "os" + "path" "path/filepath" "testing/fstest" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/resources" - "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -18,13 +18,12 @@ import ( var _ = Describe("Translations", func() { Describe("I18n files", func() { It("contains only valid json language files", func() { - tests.SkipOnWindows("path separator bug (#TBD-path-sep-nativeapi)") fsys := resources.FS() dir, _ := fsys.Open(consts.I18nFolder) files, _ := dir.(fs.ReadDirFile).ReadDir(-1) for _, f := range files { name := filepath.Base(f.Name()) - filePath := filepath.Join(consts.I18nFolder, name) + filePath := path.Join(consts.I18nFolder, name) file, _ := fsys.Open(filePath) data, _ := io.ReadAll(file) var out map[string]any From dff9e47c2ea53e040ddac2fcc36f4563e53053be Mon Sep 17 00:00:00 2001 From: Junker der Provinz <133605895+junkerderprovinz@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:11:00 +0200 Subject: [PATCH 07/78] fix(scanner): detect in-place playlist edits via the folder content hash (#5914) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(scanner): detect in-place playlist edits via the folder content hash Signed-off-by: junkerderprovinz * docs(scanner): clarify the playlist entries in the folder hash Shorten the comment on the playlist loop, and record why the playlist count stays in the hash header: it is redundant with the loop for change detection, but removing it changes the hashed byte stream for every folder, including folders without playlists, which would mark every folder outdated on the first scan after upgrade. Signed-off-by: junkerderprovinz * test(scanner): pin filename and size into the playlist hash assertions The playlist size test called time.Now() twice, so the modtime differed too and carried the assertion — dropping info.Size() from the hash left the suite green. It now shares one baseTime. A new rename test swaps the map key with count, size and modtime held constant, so dropping the filename from the hash fails. Both mutations were verified to fail before this change and pass after. --------- Signed-off-by: junkerderprovinz Co-authored-by: Deluan --- scanner/folder_entry.go | 22 +++++++--- scanner/folder_entry_test.go | 80 ++++++++++++++++++++++++++++++++---- scanner/phase_1_folders.go | 4 +- scanner/walk_dir_tree.go | 4 +- 4 files changed, 93 insertions(+), 17 deletions(-) diff --git a/scanner/folder_entry.go b/scanner/folder_entry.go index 0e893d6e6..e7eef223c 100644 --- a/scanner/folder_entry.go +++ b/scanner/folder_entry.go @@ -23,6 +23,7 @@ func newFolderEntry(job *scanJob, id, path string, info model.FolderUpdateInfo) path: path, audioFiles: make(map[string]fs.DirEntry), imageFiles: make(map[string]fs.DirEntry), + playlistFiles: make(map[string]fs.DirEntry), albumIDMap: make(map[string]string), updTime: info.UpdatedAt, prevHash: info.Hash, @@ -41,7 +42,7 @@ type folderEntry struct { updTime time.Time // from DB audioFiles map[string]fs.DirEntry imageFiles map[string]fs.DirEntry - numPlaylists int + playlistFiles map[string]fs.DirEntry numSubFolders int imagesUpdatedAt time.Time prevHash string // Previous hash from DB @@ -57,7 +58,7 @@ type folderEntry struct { } func (f *folderEntry) hasNoFiles() bool { - return len(f.audioFiles) == 0 && len(f.imageFiles) == 0 && f.numPlaylists == 0 + return len(f.audioFiles) == 0 && len(f.imageFiles) == 0 && len(f.playlistFiles) == 0 } func (f *folderEntry) isEmpty() bool { @@ -94,7 +95,7 @@ func (f *folderEntry) toFolder() *model.Folder { folder := model.NewFolder(f.job.lib, f.path) folder.NumAudioFiles = len(f.audioFiles) if playlists.InPath(*folder) { - folder.NumPlaylists = f.numPlaylists + folder.NumPlaylists = len(f.playlistFiles) } folder.ImageFiles = slices.Collect(maps.Keys(f.imageFiles)) folder.ImagesUpdatedAt = f.imagesUpdatedAt @@ -108,16 +109,18 @@ func (f *folderEntry) hash() string { h, "%s:%d:%d:%s", f.modTime.UTC(), - f.numPlaylists, + len(f.playlistFiles), // redundant with the loop below, but dropping it re-hashes every folder f.numSubFolders, f.imagesUpdatedAt.UTC(), ) - // Sort the keys of audio and image files to ensure consistent hashing + // Sort the keys of audio, image and playlist files to ensure consistent hashing audioKeys := slices.Collect(maps.Keys(f.audioFiles)) slices.Sort(audioKeys) imageKeys := slices.Collect(maps.Keys(f.imageFiles)) slices.Sort(imageKeys) + playlistKeys := slices.Collect(maps.Keys(f.playlistFiles)) + slices.Sort(playlistKeys) // Include audio files with their size and modtime for _, key := range audioKeys { @@ -135,5 +138,14 @@ func (f *folderEntry) hash() string { } } + // Include playlist files, so a content edit is detected even when the folder's + // mtime is preserved (rsync -a) or the playlist is not the newest file. + for _, key := range playlistKeys { + _, _ = io.WriteString(h, key) + if info, err := f.playlistFiles[key].Info(); err == nil { + _, _ = fmt.Fprintf(h, ":%d:%s", info.Size(), info.ModTime().UTC().String()) + } + } + return hex.EncodeToString(h.Sum(nil)) } diff --git a/scanner/folder_entry_test.go b/scanner/folder_entry_test.go index e8e354b38..4493f9309 100644 --- a/scanner/folder_entry_test.go +++ b/scanner/folder_entry_test.go @@ -48,6 +48,7 @@ var _ = Describe("folder_entry", func() { Expect(entry.path).To(Equal(path)) Expect(entry.audioFiles).To(BeEmpty()) Expect(entry.imageFiles).To(BeEmpty()) + Expect(entry.playlistFiles).To(BeEmpty()) Expect(entry.albumIDMap).To(BeEmpty()) Expect(entry.updTime).To(Equal(updateInfo.UpdatedAt)) Expect(entry.prevHash).To(Equal(updateInfo.Hash)) @@ -95,7 +96,7 @@ var _ = Describe("folder_entry", func() { }) It("returns false when folder has playlists", func() { - entry.numPlaylists = 1 + entry.playlistFiles["list.m3u"] = &fakeDirEntry{name: "list.m3u"} Expect(entry.hasNoFiles()).To(BeFalse()) }) @@ -107,7 +108,7 @@ var _ = Describe("folder_entry", func() { It("returns false when folder has multiple types of content", func() { entry.audioFiles["test.mp3"] = &fakeDirEntry{name: "test.mp3"} entry.imageFiles["cover.jpg"] = &fakeDirEntry{name: "cover.jpg"} - entry.numPlaylists = 2 + entry.playlistFiles["list.m3u"] = &fakeDirEntry{name: "list.m3u"} entry.numSubFolders = 3 Expect(entry.hasNoFiles()).To(BeFalse()) }) @@ -149,7 +150,11 @@ var _ = Describe("folder_entry", func() { "cover.jpg": &fakeDirEntry{name: "cover.jpg"}, "folder.png": &fakeDirEntry{name: "folder.png"}, } - entry.numPlaylists = 3 + entry.playlistFiles = map[string]fs.DirEntry{ + "list1.m3u": &fakeDirEntry{name: "list1.m3u"}, + "list2.m3u": &fakeDirEntry{name: "list2.m3u"}, + "list3.m3u": &fakeDirEntry{name: "list3.m3u"}, + } entry.imagesUpdatedAt = time.Now() }) @@ -200,7 +205,10 @@ var _ = Describe("folder_entry", func() { "z.jpg": &fakeDirEntry{name: "z.jpg"}, "x.png": &fakeDirEntry{name: "x.png"}, } - entry.numPlaylists = 2 + entry.playlistFiles = map[string]fs.DirEntry{ + "q.m3u": &fakeDirEntry{name: "q.m3u"}, + "p.m3u": &fakeDirEntry{name: "p.m3u"}, + } entry.numSubFolders = 3 hash1 := entry.hash() @@ -214,6 +222,10 @@ var _ = Describe("folder_entry", func() { "x.png": &fakeDirEntry{name: "x.png"}, "z.jpg": &fakeDirEntry{name: "z.jpg"}, } + entry.playlistFiles = map[string]fs.DirEntry{ + "p.m3u": &fakeDirEntry{name: "p.m3u"}, + "q.m3u": &fakeDirEntry{name: "q.m3u"}, + } hash2 := entry.hash() Expect(hash1).To(Equal(hash2)) @@ -252,10 +264,10 @@ var _ = Describe("folder_entry", func() { Expect(hash1).ToNot(Equal(hash2)) }) - It("produces different hash when playlist count changes", func() { + It("produces different hash when playlist files change", func() { hash1 := entry.hash() - entry.numPlaylists = 5 + entry.playlistFiles["new.m3u"] = &fakeDirEntry{name: "new.m3u"} hash2 := entry.hash() Expect(hash1).ToNot(Equal(hash2)) @@ -377,6 +389,58 @@ var _ = Describe("folder_entry", func() { Expect(hash1).ToNot(Equal(hash2)) }) + It("produces different hash when playlist file size changes", func() { + baseTime := time.Now() + entry.playlistFiles["list.m3u"] = &fakeDirEntry{ + name: "list.m3u", + fileInfo: &fakeFileInfo{name: "list.m3u", size: 1000, modTime: baseTime}, + } + hash1 := entry.hash() + + entry.playlistFiles["list.m3u"] = &fakeDirEntry{ + name: "list.m3u", + fileInfo: &fakeFileInfo{name: "list.m3u", size: 2000, modTime: baseTime}, + } + hash2 := entry.hash() + + Expect(hash1).ToNot(Equal(hash2)) + }) + + It("produces different hash when playlist file modification time changes", func() { + baseTime := time.Now() + entry.playlistFiles["list.m3u"] = &fakeDirEntry{ + name: "list.m3u", + fileInfo: &fakeFileInfo{name: "list.m3u", size: 1000, modTime: baseTime}, + } + hash1 := entry.hash() + + entry.playlistFiles["list.m3u"] = &fakeDirEntry{ + name: "list.m3u", + fileInfo: &fakeFileInfo{name: "list.m3u", size: 1000, modTime: baseTime.Add(1 * time.Hour)}, + } + hash2 := entry.hash() + + Expect(hash1).ToNot(Equal(hash2)) + }) + + It("produces different hash when a playlist is renamed", func() { + baseTime := time.Now() + entry.playlistFiles["old.m3u"] = &fakeDirEntry{ + name: "old.m3u", + fileInfo: &fakeFileInfo{name: "old.m3u", size: 1000, modTime: baseTime}, + } + hash1 := entry.hash() + + delete(entry.playlistFiles, "old.m3u") + entry.playlistFiles["new.m3u"] = &fakeDirEntry{ + name: "new.m3u", + fileInfo: &fakeFileInfo{name: "new.m3u", size: 1000, modTime: baseTime}, + } + hash2 := entry.hash() + + Expect(hash1).ToNot(Equal(hash2)) + }) + It("produces valid hex-encoded hash", func() { hash := entry.hash() Expect(hash).To(HaveLen(32)) // MD5 hash should be 32 hex characters @@ -421,7 +485,7 @@ var _ = Describe("folder_entry", func() { }) It("returns true when hash has changed", func() { - entry.numPlaylists = 10 // Change something to change the hash + entry.playlistFiles["list.m3u"] = &fakeDirEntry{name: "list.m3u"} // Change something to change the hash Expect(entry.isOutdated()).To(BeTrue()) }) @@ -445,7 +509,7 @@ var _ = Describe("folder_entry", func() { It("returns true when full scan condition is not met but hash changed", func() { entry.updTime = entry.job.lib.LastScanStartedAt.Add(1 * time.Hour) - entry.numPlaylists = 10 // Change hash + entry.playlistFiles["list.m3u"] = &fakeDirEntry{name: "list.m3u"} // Change hash Expect(entry.isOutdated()).To(BeTrue()) }) }) diff --git a/scanner/phase_1_folders.go b/scanner/phase_1_folders.go index 82e91d5ad..6107b3316 100644 --- a/scanner/phase_1_folders.go +++ b/scanner/phase_1_folders.go @@ -165,7 +165,7 @@ func (p *phaseFolders) producer() ppl.Producer[*folderEntry] { log.Trace(p.ctx, "Scanner: Checking folder state", " folder", folder.path, "_updTime", folder.updTime, "_modTime", folder.modTime, "_lastScanStartedAt", folder.job.lib.LastScanStartedAt, "numAudioFiles", len(folder.audioFiles), "numImageFiles", len(folder.imageFiles), - "numPlaylists", folder.numPlaylists, "numSubfolders", folder.numSubFolders) + "numPlaylists", len(folder.playlistFiles), "numSubfolders", folder.numSubFolders) // Check if folder is outdated if folder.isOutdated() { @@ -492,7 +492,7 @@ func (p *phaseFolders) logFolder(entry *folderEntry) (*folderEntry, error) { logCall = log.Trace } logCall(p.ctx, "Scanner: Completed processing folder", - "audioCount", len(entry.audioFiles), "imageCount", len(entry.imageFiles), "plsCount", entry.numPlaylists, + "audioCount", len(entry.audioFiles), "imageCount", len(entry.imageFiles), "plsCount", len(entry.playlistFiles), "elapsed", entry.elapsed.Elapsed(), "tracksMissing", len(entry.missingTracks), "tracksImported", len(entry.tracks), "library", entry.job.lib.Name, consts.Zwsp+"folder", entry.path) return entry, nil diff --git a/scanner/walk_dir_tree.go b/scanner/walk_dir_tree.go index 887344b1b..20f6d4213 100644 --- a/scanner/walk_dir_tree.go +++ b/scanner/walk_dir_tree.go @@ -82,7 +82,7 @@ func walkFolder(ctx context.Context, job *scanJob, currentFolder string, checker dir := path.Clean(currentFolder) log.Trace(ctx, "Scanner: Found directory", " path", dir, "audioFiles", maps.Keys(folder.audioFiles), - "images", maps.Keys(folder.imageFiles), "playlists", folder.numPlaylists, "imagesUpdatedAt", folder.imagesUpdatedAt, + "images", maps.Keys(folder.imageFiles), "playlists", len(folder.playlistFiles), "imagesUpdatedAt", folder.imagesUpdatedAt, "updTime", folder.updTime, "modTime", folder.modTime, "numChildren", len(children)) folder.path = dir folder.elapsed.Start() @@ -157,7 +157,7 @@ func loadDir(ctx context.Context, job *scanJob, dirPath string, checker *IgnoreC case model.IsAudioFile(name): folder.audioFiles[entry.Name()] = entry case model.IsValidPlaylist(name): - folder.numPlaylists++ + folder.playlistFiles[entry.Name()] = entry case model.IsImageFile(name): folder.imageFiles[entry.Name()] = entry folder.imagesUpdatedAt = utils.TimeNewest(folder.imagesUpdatedAt, fileInfo.ModTime(), folder.modTime) From fd4b3256e4c3e2c6eeeb90c2e3034149b536f873 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Wed, 19 Aug 2026 16:20:38 -0400 Subject: [PATCH 08/78] perf(artwork): compute the blurhash DCT separably (#5989) The cosine basis factors into cosX[i][x] * cosY[j][y], so the pixel loop does not need to visit every (i,j) pair. Each row now collapses to xComp dot products, folded over yComp once per row: w*h*xComp + h*xComp*yComp multiply-accumulates instead of w*h*xComp*yComp. Encoding is ~60% faster at every input size, and ~80% faster at the 128px size the artwork pipeline actually feeds it (263us -> 53us). Hashes are byte-identical, so the existing golden-value specs cover the rewrite. --- core/artwork/blurhash/blurhash.go | 35 +++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/core/artwork/blurhash/blurhash.go b/core/artwork/blurhash/blurhash.go index ca5bf7026..29e1ccfda 100644 --- a/core/artwork/blurhash/blurhash.go +++ b/core/artwork/blurhash/blurhash.go @@ -52,6 +52,12 @@ func Encode(img image.Image) (string, error) { lin := srgbToLinearTable() factors := make([][3]float64, xComp*yComp) + linR := make([]float64, w) + linG := make([]float64, w) + linB := make([]float64, w) + rowR := make([]float64, xComp) + rowG := make([]float64, xComp) + rowB := make([]float64, xComp) for y := range h { row := src.pix[y*src.stride:] for x := range w { @@ -60,15 +66,26 @@ func Encode(img image.Image) (string, error) { 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 - } + linR[x], linG[x], linB[x] = lin[r], lin[g], lin[b] + } + // The basis is separable, so a row costs xComp dot products plus one fold over yComp, + // rather than xComp*yComp multiply-accumulates per pixel. + for i := range xComp { + var sr, sg, sb float64 + for x, c := range cosX[i] { + sr += c * linR[x] + sg += c * linG[x] + sb += c * linB[x] + } + rowR[i], rowG[i], rowB[i] = sr, sg, sb + } + for j := range yComp { + cy := cosY[j][y] + for i := range xComp { + f := &factors[j*xComp+i] + f[0] += cy * rowR[i] + f[1] += cy * rowG[i] + f[2] += cy * rowB[i] } } } From 881073c1833e66c47839927dc3c1577e39471c25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Wed, 19 Aug 2026 16:25:12 -0400 Subject: [PATCH 09/78] feat(cli): let artwork explain/refresh accept an id without its kind (#5988) * feat(cmd): make artwork explain/refresh accept an id without its kind The kind can now come from the id itself: a full artwork id (al-) carries it in the prefix, and a bare id is resolved across tables via GetEntityByID. The explicit leader still works. * fix(cmd): keep refreshing resolvable ids when others fail to resolve resolveArtworkTargets now collects a self-describing id it cannot resolve as a failure instead of aborting, so refresh reports and skips the bad ones and still queues the rest, matching refreshItems per-item behavior. explain stays strict and rejects any unresolved input. --- cmd/artwork.go | 110 +++++++++++++++++++++++++++++++-------- cmd/artwork_test.go | 83 ++++++++++++++++++++++++++--- model/get_entity.go | 37 +++++++++---- model/get_entity_test.go | 22 ++++++++ 4 files changed, 212 insertions(+), 40 deletions(-) diff --git a/cmd/artwork.go b/cmd/artwork.go index e9c910f11..aeaec0e43 100644 --- a/cmd/artwork.go +++ b/cmd/artwork.go @@ -59,31 +59,27 @@ var artworkCmd = &cobra.Command{ } var artworkExplainCmd = &cobra.Command{ - Use: "explain ", + Use: "explain [] ", Short: "Explain why an item's artwork resolved the way it did", Long: "Explain why an item's artwork resolved the way it did.\n\n" + + "The item can be given as a bare id, a full artwork id (e.g. al-), or a pair.\n" + " is one of: " + kindPrefixes(explainKinds) + ".\n" + "A disc artwork id is the album id and the disc number, joined by a colon: :2", - Args: cobra.ExactArgs(2), + Args: cobra.RangeArgs(1, 2), Run: func(cmd *cobra.Command, args []string) { - kind, err := parseArtworkKind(args[0], explainKinds) - if err != nil { - log.Fatal(cmd.Context(), err) - } - runExplain(cmd.Context(), kind, args[1]) + runExplain(cmd.Context(), args) }, } var artworkRefreshCmd = &cobra.Command{ - Use: "refresh ...", + Use: "refresh [] ...", Short: "Clear an item's artwork state and re-resolve it", - Args: cobra.MinimumNArgs(2), + Long: "Clear an item's artwork state and re-resolve it.\n\n" + + "Each item can be given as a bare id, a full artwork id (e.g. al-), or a shared\n" + + " ... leader. is one of: " + kindPrefixes(artwork.RefreshableKinds) + ".", + Args: cobra.MinimumNArgs(1), Run: func(cmd *cobra.Command, args []string) { - kind, err := parseArtworkKind(args[0], artwork.RefreshableKinds) - if err != nil { - log.Fatal(cmd.Context(), err) - } - runRefresh(cmd.Context(), kind, args[1:]) + runRefresh(cmd.Context(), args) }, } @@ -499,19 +495,28 @@ func printReprocessPreview(out io.Writer, kinds []model.Kind, matched []int64, t } } -func runRefresh(ctx context.Context, kind model.Kind, ids []string) { +func runRefresh(ctx context.Context, args []string) { defer db.Init(ctx)() ds, ctx := getAdminContext(ctx) - if failed := refreshItems(ctx, ds, kind, ids, os.Stdout); failed > 0 { - log.Fatal(ctx, "Failed to refresh artwork", "kind", kind, "failed", failed, "total", len(ids)) + targets, failures, err := resolveArtworkTargets(ctx, ds, args, artwork.RefreshableKinds) + if err != nil { + log.Fatal(ctx, err) + } + for _, f := range failures { + log.Error(ctx, "Skipping unresolved item", f) + } + failed := refreshItems(ctx, ds, targets, os.Stdout) + len(failures) + if failed > 0 { + log.Fatal(ctx, "Failed to refresh artwork", "failed", failed, "total", len(targets)+len(failures)) } } -// refreshItems keeps going after a failure — the ids are independent — and returns how many failed. -func refreshItems(ctx context.Context, ds model.DataStore, kind model.Kind, ids []string, out io.Writer) int { +// refreshItems keeps going after a failure — the items are independent — and returns how many failed. +func refreshItems(ctx context.Context, ds model.DataStore, targets []model.ArtworkID, out io.Writer) int { var failed int - for _, id := range ids { + for _, t := range targets { + kind, id := t.Kind, t.ID // artwork.Refresh would happily queue an id that does not exist, orphaning a queue row. if _, err := artworkItemName(ctx, ds, kind, id); err != nil { log.Error(ctx, "Item not found", "kind", kind, "id", id, err) @@ -544,7 +549,56 @@ func parseArtworkKind(s string, valid []model.Kind) (model.Kind, error) { if ok && slices.Contains(valid, kind) { return kind, nil } - return kind, fmt.Errorf("invalid kind %q, expected one of: %s", s, kindPrefixes(valid)) + return kind, invalidKindErr(s, valid) +} + +func invalidKindErr(s string, valid []model.Kind) error { + return fmt.Errorf("invalid kind %q, expected one of: %s", s, kindPrefixes(valid)) +} + +// resolveArtworkTargets resolves explain/refresh positional args into artwork ids, accepting a +// shared " ..." leader or self-describing args (a bare id, or a full artwork id). A +// self-describing arg that cannot be resolved is returned as a failure rather than aborting the +// batch, so refresh can process the resolvable ids; a malformed leader is a usage error. +func resolveArtworkTargets(ctx context.Context, ds model.DataStore, args []string, valid []model.Kind) ([]model.ArtworkID, []error, error) { + if kind, ok := model.ParseKind(args[0]); ok && len(args) > 1 { + if !slices.Contains(valid, kind) { + return nil, nil, invalidKindErr(args[0], valid) + } + return slice.Map(args[1:], func(id string) model.ArtworkID { + return model.ArtworkID{Kind: kind, ID: id} + }), nil, nil + } + var targets []model.ArtworkID + var failures []error + for _, arg := range args { + target, err := artworkKindAndID(ctx, ds, arg) + if err == nil && !slices.Contains(valid, target.Kind) { + err = invalidKindErr(target.Kind.Prefix(), valid) + } + if err != nil { + failures = append(failures, err) + continue + } + targets = append(targets, target) + } + return targets, failures, nil +} + +// artworkKindAndID resolves one self-describing argument: a full artwork id (al-) takes its kind +// from the prefix, a bare id is looked up. Entity ids never start with "-", so no collision. +func artworkKindAndID(ctx context.Context, ds model.DataStore, arg string) (model.ArtworkID, error) { + if artID, err := model.ParseArtworkID(arg); err == nil && artID.ID != "" { + return model.ArtworkID{Kind: artID.Kind, ID: artID.ID}, nil + } + kind, err := model.GetEntityKindByID(ctx, ds, arg) + if errors.Is(err, model.ErrNotFound) { + return model.ArtworkID{}, fmt.Errorf("could not determine kind for %q; pass an explicit ", arg) + } + if err != nil { + return model.ArtworkID{}, err + } + return model.ArtworkID{Kind: kind, ID: arg}, nil } // explainAgents accounts for every configured agent: one the CLI cannot construct (a plugin, or a @@ -721,10 +775,22 @@ func formatTime(t time.Time) string { return t.Format(time.RFC3339) } -func runExplain(ctx context.Context, kind model.Kind, id string) { +func runExplain(ctx context.Context, args []string) { defer db.Init(ctx)() ds, ctx := getAdminContext(ctx) + targets, failures, err := resolveArtworkTargets(ctx, ds, args, explainKinds) + if err != nil { + log.Fatal(ctx, err) + } + if len(failures) > 0 { + log.Fatal(ctx, failures[0]) + } + if len(targets) != 1 { + log.Fatal(ctx, "explain takes a single item; pass one id or a pair") + } + kind, id := targets[0].Kind, targets[0].ID + name, err := artworkItemName(ctx, ds, kind, id) if err != nil { log.Fatal(ctx, "Item not found", "kind", kind, "id", id, err) diff --git a/cmd/artwork_test.go b/cmd/artwork_test.go index b28d8dac5..8b50ba775 100644 --- a/cmd/artwork_test.go +++ b/cmd/artwork_test.go @@ -54,6 +54,74 @@ var _ = Describe("parseArtworkKind", func() { }) }) +var _ = Describe("resolveArtworkTargets", func() { + var ds *tests.MockDataStore + ctx := context.Background() + + BeforeEach(func() { + artists := tests.CreateMockArtistRepo() + artists.SetData(model.Artists{{ID: "artist1"}}) + ds = &tests.MockDataStore{MockedArtist: artists} + }) + + It("accepts the explicit leader shared by every id", func() { + targets, failures, err := resolveArtworkTargets(ctx, ds, []string{"al", "x", "y"}, explainKinds) + Expect(err).ToNot(HaveOccurred()) + Expect(failures).To(BeEmpty()) + Expect(targets).To(Equal([]model.ArtworkID{ + {Kind: model.KindAlbumArtwork, ID: "x"}, {Kind: model.KindAlbumArtwork, ID: "y"}})) + }) + + It("rejects an explicit kind the command does not accept as a usage error", func() { + _, _, err := resolveArtworkTargets(ctx, ds, []string{"dc", "x"}, artwork.RefreshableKinds) + Expect(err).To(MatchError(ContainSubstring("invalid kind"))) + }) + + It("resolves a bare id by looking it up across tables", func() { + targets, failures, err := resolveArtworkTargets(ctx, ds, []string{"artist1"}, explainKinds) + Expect(err).ToNot(HaveOccurred()) + Expect(failures).To(BeEmpty()) + Expect(targets).To(Equal([]model.ArtworkID{{Kind: model.KindArtistArtwork, ID: "artist1"}})) + }) + + It("reads the kind from a full artwork id prefix without a database lookup", func() { + targets, _, err := resolveArtworkTargets(ctx, ds, []string{"al-realalbum"}, explainKinds) + Expect(err).ToNot(HaveOccurred()) + Expect(targets).To(Equal([]model.ArtworkID{{Kind: model.KindAlbumArtwork, ID: "realalbum"}})) + }) + + It("strips the hash suffix from a full artwork id", func() { + targets, _, err := resolveArtworkTargets(ctx, ds, []string{"al-realalbum_0123456789abcdef"}, explainKinds) + Expect(err).ToNot(HaveOccurred()) + Expect(targets).To(Equal([]model.ArtworkID{{Kind: model.KindAlbumArtwork, ID: "realalbum"}})) + }) + + It("collects a self-describing arg whose kind the command does not accept", func() { + targets, failures, err := resolveArtworkTargets(ctx, ds, []string{"dc-realalbum:2"}, artwork.RefreshableKinds) + Expect(err).ToNot(HaveOccurred()) + Expect(targets).To(BeEmpty()) + Expect(failures).To(HaveLen(1)) + Expect(failures[0]).To(MatchError(ContainSubstring("invalid kind"))) + }) + + It("collects an id that matches nothing and has no kind prefix", func() { + targets, failures, err := resolveArtworkTargets(ctx, ds, []string{"nope"}, explainKinds) + Expect(err).ToNot(HaveOccurred()) + Expect(targets).To(BeEmpty()) + Expect(failures).To(HaveLen(1)) + Expect(failures[0]).To(MatchError(ContainSubstring("could not determine kind"))) + }) + + It("resolves the valid ids and collects the unresolvable ones", func() { + targets, failures, err := resolveArtworkTargets(ctx, ds, []string{"artist1", "nope", "al-realalbum"}, explainKinds) + Expect(err).ToNot(HaveOccurred()) + Expect(targets).To(Equal([]model.ArtworkID{ + {Kind: model.KindArtistArtwork, ID: "artist1"}, {Kind: model.KindAlbumArtwork, ID: "realalbum"}})) + Expect(failures).To(HaveLen(1)) + Expect(failures[0]).To(MatchError(ContainSubstring("could not determine kind"))) + }) +}) + var _ = Describe("explainResult", func() { It("reports the winning source", func() { steps := []artwork.TraceStep{{Candidate: "folder", Outcome: "hit", Detail: "/music/a.jpg"}} @@ -337,10 +405,10 @@ var _ = Describe("discArtworkName", func() { }) var _ = Describe("artwork refresh command", func() { - It("requires at least a kind and one id", func() { - Expect(artworkRefreshCmd.Args(artworkRefreshCmd, []string{"ar"})).To(HaveOccurred()) + It("requires at least one argument", func() { + Expect(artworkRefreshCmd.Args(artworkRefreshCmd, []string{})).To(HaveOccurred()) + Expect(artworkRefreshCmd.Args(artworkRefreshCmd, []string{"id1"})).ToNot(HaveOccurred()) Expect(artworkRefreshCmd.Args(artworkRefreshCmd, []string{"ar", "id1"})).ToNot(HaveOccurred()) - Expect(artworkRefreshCmd.Args(artworkRefreshCmd, []string{"ar", "id1", "id2"})).ToNot(HaveOccurred()) }) }) @@ -873,7 +941,8 @@ var _ = Describe("refreshItems", func() { Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: model.KindAlbumArtwork.Prefix(), ItemID: "al-1", ImageType: model.ImageTypePrimary, Hash: "abc123"})).To(Succeed()) - Expect(refreshItems(ctx, ds, model.KindAlbumArtwork, []string{"al-1", "al-3"}, &out)).To(BeZero()) + Expect(refreshItems(ctx, ds, []model.ArtworkID{ + {Kind: model.KindAlbumArtwork, ID: "al-1"}, {Kind: model.KindAlbumArtwork, ID: "al-3"}}, &out)).To(BeZero()) _, err := art.GetItemArtwork(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary) Expect(err).To(MatchError(model.ErrNotFound)) @@ -884,7 +953,7 @@ var _ = Describe("refreshItems", func() { }) It("skips an id that does not exist instead of queuing it", func() { - Expect(refreshItems(ctx, ds, model.KindAlbumArtwork, []string{"al-2"}, &out)).To(Equal(1)) + Expect(refreshItems(ctx, ds, []model.ArtworkID{{Kind: model.KindAlbumArtwork, ID: "al-2"}}, &out)).To(Equal(1)) _, err := queue.Get(model.KindAlbumArtwork, "al-2", model.ImageTypePrimary) Expect(err).To(MatchError(model.ErrNotFound), "a typo must not leave an orphan queue row") @@ -892,8 +961,8 @@ var _ = Describe("refreshItems", func() { }) It("continues past a failing id and counts the failures", func() { - Expect(refreshItems(ctx, ds, model.KindAlbumArtwork, - []string{"al-1", "al-2", "al-3"}, &out)).To(Equal(1)) + Expect(refreshItems(ctx, ds, []model.ArtworkID{{Kind: model.KindAlbumArtwork, ID: "al-1"}, + {Kind: model.KindAlbumArtwork, ID: "al-2"}, {Kind: model.KindAlbumArtwork, ID: "al-3"}}, &out)).To(Equal(1)) Expect(out.String()).To(Equal("al/al-1: queued\nal/al-3: queued\n"), "the ids after a failure are still refreshed") diff --git a/model/get_entity.go b/model/get_entity.go index 3e1a78d1d..e5d41f1be 100644 --- a/model/get_entity.go +++ b/model/get_entity.go @@ -7,21 +7,36 @@ import ( // TODO: Should the type be encoded in the ID? func GetEntityByID(ctx context.Context, ds DataStore, id string) (any, error) { - getters := []func() (any, error){ - func() (any, error) { return ds.Artist(ctx).Get(id) }, - func() (any, error) { return ds.Album(ctx).Get(id) }, - func() (any, error) { return ds.Playlist(ctx).Get(id) }, - func() (any, error) { return ds.MediaFile(ctx).Get(id) }, - func() (any, error) { return ds.Radio(ctx).Get(id) }, + entity, _, err := getEntity(ctx, ds, id) + return entity, err +} + +// GetEntityKindByID resolves a bare entity id to its artwork Kind, searching the same tables as +// GetEntityByID. It reports ErrNotFound when no entity owns the id. +func GetEntityKindByID(ctx context.Context, ds DataStore, id string) (Kind, error) { + _, kind, err := getEntity(ctx, ds, id) + return kind, err +} + +func getEntity(ctx context.Context, ds DataStore, id string) (any, Kind, error) { + getters := []struct { + kind Kind + get func() (any, error) + }{ + {KindArtistArtwork, func() (any, error) { return ds.Artist(ctx).Get(id) }}, + {KindAlbumArtwork, func() (any, error) { return ds.Album(ctx).Get(id) }}, + {KindPlaylistArtwork, func() (any, error) { return ds.Playlist(ctx).Get(id) }}, + {KindMediaFileArtwork, func() (any, error) { return ds.MediaFile(ctx).Get(id) }}, + {KindRadioArtwork, func() (any, error) { return ds.Radio(ctx).Get(id) }}, } - for _, get := range getters { - entity, err := get() + for _, g := range getters { + entity, err := g.get() if err == nil { - return entity, nil + return entity, g.kind, nil } if !errors.Is(err, ErrNotFound) { - return nil, err + return nil, Kind{}, err } } - return nil, ErrNotFound + return nil, Kind{}, ErrNotFound } diff --git a/model/get_entity_test.go b/model/get_entity_test.go index f8a4c9e8e..4e589406a 100644 --- a/model/get_entity_test.go +++ b/model/get_entity_test.go @@ -38,3 +38,25 @@ var _ = Describe("GetEntityByID", func() { Expect(err).ToNot(MatchError(model.ErrNotFound)) }) }) + +var _ = Describe("GetEntityKindByID", func() { + var ds *tests.MockDataStore + var ctx context.Context + + BeforeEach(func() { + ds = &tests.MockDataStore{} + ctx = GinkgoT().Context() + }) + + It("returns the artwork kind for the matching id", func() { + ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1"}}) + kind, err := model.GetEntityKindByID(ctx, ds, "a1") + Expect(err).ToNot(HaveOccurred()) + Expect(kind).To(Equal(model.KindAlbumArtwork)) + }) + + It("returns ErrNotFound when no entity matches", func() { + _, err := model.GetEntityKindByID(ctx, ds, "missing") + Expect(err).To(MatchError(model.ErrNotFound)) + }) +}) From 3d3c3ed60186deb95461cd80b052e564fb9d2055 Mon Sep 17 00:00:00 2001 From: Daniel Barrientos Anariba <69573860+danielbanariba@users.noreply.github.com> Date: Thu, 20 Aug 2026 06:53:11 -0600 Subject: [PATCH 10/78] ci: lint with the golangci-lint version the Makefile declares (#5994) * ci: lint with the golangci-lint version the Makefile declares The workflow asked for `version: latest` while the Makefile pins `GOLANGCI_LINT_VERSION ?= v2.12.0`, so `make lint` and CI ran different linters. golangci-lint v2.13.0 started reporting G404 on three existing `rand.Shuffle` calls, which turned every PR red without a line of Go changing. Read the version from the Makefile instead of resolving `latest`, so the two stay in step and a new release cannot break unchanged code. * chore: re-run CI --- .github/workflows/pipeline.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 8e6e8126a..b91c19505 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -68,10 +68,16 @@ jobs: with: go-version-file: go.mod + # Keep CI on the same version `make lint` installs, so a clean local run + # cannot turn red in CI just because a new golangci-lint was released. + - name: Resolve golangci-lint version + id: golangci-version + run: echo "version=$(grep '^GOLANGCI_LINT_VERSION' Makefile | cut -d ' ' -f 3)" >> "$GITHUB_OUTPUT" + - name: golangci-lint uses: golangci/golangci-lint-action@v9 with: - version: latest + version: ${{ steps.golangci-version.outputs.version }} problem-matchers: true args: --timeout 2m From fc1c1366dcfb18c6ac885fe271d04ec6fede8c2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Thu, 20 Aug 2026 09:04:09 -0400 Subject: [PATCH 11/78] fix(cli): write `pls -p` playlist output to stdout (#5996) The export path used the `println` builtin, which writes to stderr, so `navidrome pls -p X > playlist.m3u8` produced an empty file while the M3U body was interleaved with the startup logs on stderr. `println` also appended a newline that `ToM3U8` already provides, so the piped output had a stray trailing blank line that `-o file` did not. Both destinations are now byte-identical. The stdout/file choice moved into a `writePlaylist` helper shared by `pls -p` and `pls export -p`, which both had the same bug. It takes the destination as an `io.Writer`, matching the existing convention in cmd/artwork.go. --- cmd/pls.go | 17 ++++++++++------- cmd/pls_test.go | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 7 deletions(-) create mode 100644 cmd/pls_test.go diff --git a/cmd/pls.go b/cmd/pls.go index 184ca6fe7..93b411483 100644 --- a/cmd/pls.go +++ b/cmd/pls.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "os" "path/filepath" "strconv" @@ -141,14 +142,16 @@ func findPlaylist(ctx context.Context, ds model.DataStore, nameOrID string) *mod func runExporter(ctx context.Context) { ds, ctx := getAdminContext(ctx) playlist := findPlaylist(ctx, ds, playlistID) - pls := playlist.ToM3U8() - if outputFile == "-" || outputFile == "" { - println(pls) + writePlaylist(playlist.ToM3U8(), os.Stdout, outputFile) +} + +func writePlaylist(m3u string, out io.Writer, file string) { + if file == "" || file == "-" { + fmt.Fprint(out, m3u) return } - err := os.WriteFile(outputFile, []byte(pls), 0600) - if err != nil { - log.Fatal("Error writing to the output file", "file", outputFile, err) + if err := os.WriteFile(file, []byte(m3u), 0600); err != nil { + log.Fatal("Error writing to the output file", "file", file, err) } } @@ -157,7 +160,7 @@ func runExport(ctx context.Context) { if playlistID != "" && outputFile == "" { playlist := findPlaylist(ctx, ds, playlistID) - println(playlist.ToM3U8()) + writePlaylist(playlist.ToM3U8(), os.Stdout, outputFile) return } diff --git a/cmd/pls_test.go b/cmd/pls_test.go new file mode 100644 index 000000000..f3e8c7edd --- /dev/null +++ b/cmd/pls_test.go @@ -0,0 +1,35 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("writePlaylist", func() { + const m3u = "#EXTM3U\n#PLAYLIST:DJ Wave\n#EXTINF:364,Bel Canto - Dreaming Girl\n" + plsFile := filepath.Join(os.TempDir(), fmt.Sprintf("navidrome-pls-%d.m3u8", os.Getpid())) + + BeforeEach(func() { + DeferCleanup(func() { _ = os.Remove(plsFile) }) + }) + + DescribeTable("writes the playlist to exactly one destination", + func(file, wantStream, wantFile string) { + var out strings.Builder + + writePlaylist(m3u, &out, file) + + written, _ := os.ReadFile(plsFile) + Expect(out.String()).To(Equal(wantStream)) + Expect(string(written)).To(Equal(wantFile)) + }, + Entry("no file name writes to the stream", "", m3u, ""), + Entry("a dash writes to the stream", "-", m3u, ""), + Entry("a path writes to the file", plsFile, "", m3u), + ) +}) From 17db7d40770bb30edffc675323e4329a9303288a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Thu, 20 Aug 2026 10:02:04 -0400 Subject: [PATCH 12/78] ci: pull base images through mirror.gcr.io instead of ECR Public (#5997) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI builds were failing at random with: buildx failed with: toomanyrequests: Rate exceeded The 429 comes from public.ecr.aws, not Docker Hub. AWS caps unauthenticated ECR Public pulls at 1 per second per source IP (authenticated: 10/s). The build matrix starts 11 jobs at once and each resolves 4 base images, so roughly 44 anonymous pulls land in a couple of seconds — from GitHub runner IPs that are shared with every other GitHub customer. Measured against public.ecr.aws with an anonymous token, 60 requests at concurrency 30 returned 31x 429 in 0.48s. The same probe against mirror.gcr.io (Google's Docker Hub pull-through cache) returned zero errors: 750 manifest requests up to ~141 req/s, plus 120 layer blob requests at concurrency 60. Google publishes no rate limit for it, so this is measured headroom, not a contract — but it is roughly 10x the pipeline's peak rate, and cached pulls do not count against Docker Hub's limits either. Authenticating to ECR Public was the alternative. It was rejected because 10 pulls/s is still under the ~44-pull burst, it needs an AWS account plus a secret, and secrets never reach fork pull requests — so forks would keep failing. The mirror fixes forks too. Verified buildkit honours the mirror block by routing a build through a local logging registry: all 6 requests (manifests and blobs) hit the mirror, none went to Docker Hub directly. Confirmed mirror.gcr.io answers 200 for buildkit's "?ns=docker.io" query form on all four images, for both GET and HEAD. Confirmed buildkit falls back to Docker Hub when the mirror is unreachable — the build still succeeds, but the resolve takes ~30s instead of ~0.3s, so a mirror outage means slow builds, not broken ones. The existing Docker Hub login covers that fallback on main-repo runs. msitools.dockerfile is only used by the local `make docker-msi` target, but is switched over too so no ECR Public reference is left behind. --- .github/actions/prepare-docker/action.yml | 5 +++++ Dockerfile | 10 +++++----- release/wix/msitools.dockerfile | 2 +- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/.github/actions/prepare-docker/action.yml b/.github/actions/prepare-docker/action.yml index b8cde4aaf..6cb54dbdb 100644 --- a/.github/actions/prepare-docker/action.yml +++ b/.github/actions/prepare-docker/action.yml @@ -68,6 +68,11 @@ runs: - name: Set up Docker Buildx id: buildx uses: docker/setup-buildx-action@v4 + with: + # Runner IPs are shared, so anonymous base image pulls get rate-limited. + buildkitd-config-inline: | + [registry."docker.io"] + mirrors = ["mirror.gcr.io"] - name: Extract metadata for Docker image id: meta diff --git a/Dockerfile b/Dockerfile index df5df52ab..847c19bf7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM --platform=$BUILDPLATFORM ghcr.io/crazy-max/osxcross:14.5-debian AS osxcros ######################################################################################################################## ### Build xx (original image: tonistiigi/xx) -FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.20 AS xx-build +FROM --platform=$BUILDPLATFORM alpine:3.20 AS xx-build # v1.9.0 ENV XX_VERSION=a5592eab7a57895e8d385394ff12241bc65ecd50 @@ -26,7 +26,7 @@ COPY --from=xx-build /out/ /usr/bin/ ######################################################################################################################## ### Build Navidrome UI -FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/node:lts-alpine AS ui +FROM --platform=$BUILDPLATFORM node:lts-alpine AS ui WORKDIR /app # Install node dependencies @@ -43,7 +43,7 @@ COPY --from=ui /build /build ######################################################################################################################## ### Build Navidrome binary for Docker image (dynamic musl, enables native libwebp via dlopen) -FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.26-alpine AS build-alpine +FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS build-alpine COPY --from=xx / / ARG TARGETPLATFORM @@ -85,7 +85,7 @@ EOT ######################################################################################################################## ### Build Navidrome binary for standalone distribution (static glibc, cross-compiled) -FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.26-trixie AS base +FROM --platform=$BUILDPLATFORM golang:1.26-trixie AS base RUN apt-get update && apt-get install -y clang lld COPY --from=xx / / WORKDIR /workspace @@ -154,7 +154,7 @@ COPY --from=build /out / ######################################################################################################################## ### Build Final Image -FROM public.ecr.aws/docker/library/alpine:3.20 AS final +FROM alpine:3.20 AS final LABEL maintainer="deluan@navidrome.org" LABEL org.opencontainers.image.source="https://github.com/navidrome/navidrome" diff --git a/release/wix/msitools.dockerfile b/release/wix/msitools.dockerfile index 38364eb47..90249c1ce 100644 --- a/release/wix/msitools.dockerfile +++ b/release/wix/msitools.dockerfile @@ -1,3 +1,3 @@ -FROM public.ecr.aws/docker/library/alpine +FROM alpine RUN apk update && apk add jq msitools WORKDIR /workspace \ No newline at end of file From 0a55cc8cafdde9ab92aa0a207507dc2165ff1caf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Thu, 20 Aug 2026 22:48:26 -0400 Subject: [PATCH 13/78] docs(plugins): document how a metadata agent signals "not found" (#6001) A MetadataAgent plugin reports "I have no data for this item" by returning an empty response with a nil error. Any error it returns instead is treated as a plugin fault and retried with backoff. That rule was not documented anywhere, so an author naturally returns an error for a missing item, and Navidrome then retries every item the plugin's source does not cover. This is not hypothetical: the artist-nfo-metadata plugin returned an error for every artist without an artist.nfo, which kept those artists in the artwork retry queue for hours and tripped the artwork circuit breaker for the plugin as a whole. Document the rule on the capability interface, which ndpgen copies into the Go PDK, and in the MetadataAgent section of the plugin README. --- plugins/README.md | 8 ++++++++ plugins/capabilities/metadata_agent.go | 3 +++ plugins/pdk/go/metadata/metadata.go | 3 +++ plugins/pdk/go/metadata/metadata_stub.go | 3 +++ 4 files changed, 17 insertions(+) diff --git a/plugins/README.md b/plugins/README.md index b04e12bd9..7042b8c45 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -174,6 +174,14 @@ Capabilities define what your plugin can do. They're automatically detected base Provides artist and album metadata. All methods are **optional** — implement only the ones your data source supports. +> **Returning "not found".** When you have no data for an item, return an empty response and no +> error. In the Go PDK that is `return nil, nil`. Navidrome reads it as a definitive "not found" +> and stops asking. +> +> Return an error only when the plugin itself failed, such as an unreachable API or a broken host +> call. Navidrome retries failed calls with backoff. A plugin that errors on "no data" makes +> Navidrome retry every item it has no data for. + | Function | Input | Output | Description | |-----------------------------------|----------------------------|----------------------------------|--------------------------| | `nd_get_artist_mbid` | `{id, name}` | `{mbid}` | Get MusicBrainz ID | diff --git a/plugins/capabilities/metadata_agent.go b/plugins/capabilities/metadata_agent.go index f856562c6..72cb1622f 100644 --- a/plugins/capabilities/metadata_agent.go +++ b/plugins/capabilities/metadata_agent.go @@ -9,6 +9,9 @@ import "github.com/navidrome/navidrome/plugins/types" // Plugins implementing this capability can choose which methods to implement. // Each method is optional - plugins only need to provide the functionality they support. // +// To say "no data for this item", return a nil response and a nil error. Return an error only when +// the plugin itself failed, because Navidrome retries failed calls with backoff. +// //nd:capability name=metadata type MetadataAgent interface { // GetArtistMBID retrieves the MusicBrainz ID for an artist. diff --git a/plugins/pdk/go/metadata/metadata.go b/plugins/pdk/go/metadata/metadata.go index c561c2893..bb0ae9620 100644 --- a/plugins/pdk/go/metadata/metadata.go +++ b/plugins/pdk/go/metadata/metadata.go @@ -186,6 +186,9 @@ type TopSongsResponse struct { // // Plugins implementing this capability can choose which methods to implement. // Each method is optional - plugins only need to provide the functionality they support. +// +// To say "no data for this item", return a nil response and a nil error. Return an error only when +// the plugin itself failed, because Navidrome retries failed calls with backoff. type Metadata interface{} // ArtistMBIDProvider provides the GetArtistMBID function. diff --git a/plugins/pdk/go/metadata/metadata_stub.go b/plugins/pdk/go/metadata/metadata_stub.go index e72cca103..572eba4da 100644 --- a/plugins/pdk/go/metadata/metadata_stub.go +++ b/plugins/pdk/go/metadata/metadata_stub.go @@ -184,6 +184,9 @@ type TopSongsResponse struct { // // Plugins implementing this capability can choose which methods to implement. // Each method is optional - plugins only need to provide the functionality they support. +// +// To say "no data for this item", return a nil response and a nil error. Return an error only when +// the plugin itself failed, because Navidrome retries failed calls with backoff. type Metadata interface{} // ArtistMBIDProvider provides the GetArtistMBID function. From c26f6f9e981dcdbf14a86120bcda5e3d0a3e74a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Fri, 21 Aug 2026 10:24:01 -0400 Subject: [PATCH 14/78] feat(artwork): store the resolution trace so `artwork explain` works offline (#5980) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(artwork): record the resolution trace so explain works without --live The worker never attached a ChainTrace, so `artwork explain` had to re-walk the priority chain at CLI time. That reconstruction could disagree with what actually happened, and without --live it could not report the external tier at all. The worker now traces every acquisition and stores it. `explain` reads the stored trace by default and reports when it was recorded; --live re-walks and calls the agents. Disc artwork keeps no row, so it always walks live. A chain trace alone would have explained almost nothing about failures: six of the seven ways an item can fail happen after the chain has already picked a winner. The trace now covers those stages too, and has somewhere to live when they fail: the retrying queue row carries the last failure, and the state row keeps it in last_failure once the retry budget is spent and the queue row is deleted. Measured on a copy of a 682MB / 43.6k-item library: +9.7MB (+1.4%). No row crosses the WITHOUT ROWID overflow threshold, so list hydration is unchanged; only full scans of item_artwork, which no request performs, read more pages. * test(artwork): pin the give-up ordering that keeps a failure for unresolved items recordGiveUp updates an existing row, and for a kind with a recheck path that row is only created moments earlier by the absent settle. Recording before the settle would lose the failure for every item that never resolved, with nothing to catch it. * refactor(artwork): tighten the trace code after review Four fixes worth taking: The doc comments on ChainTrace and chainState.trace still said the worker never attaches a trace and resolution stays allocation-free — the exact invariant this branch reverses. explain's report field meant both "the chain shown was walked just now" and "go out for real", and was being passed to loadPluginAgents, which --live documents as the only thing that may open external connections. Renamed to `walked` and restored explainLive as the sole input to that decision. A stored Detail is an error string on the failure paths, with no bound. The measured "no row reaches the WITHOUT ROWID overflow limit" only holds while it is bounded, so cap it at 200 runes. offlineGate was a factory returning a constant closure; make it a plain gateFunc like its sibling passthroughGate. Collapse five copies of the age-a-queue-row loop in the worker tests into one helper. * refactor(artwork): drop the offline explain walk, now that traces are stored `artwork explain` reported the external tier without calling it, so a diagnostic could not add load to a provider already rate-limiting us. Reading the stored trace answers that better: it reports what the agents actually returned, not what would be tried. Nothing could reach the offline gate any more. It was installed only for a walk with --live unset, which now happens for disc artwork alone, and disc rejects the external candidate before any gate call. That made the gate, its sentinel error, the would-try outcome and two of explain's verdicts unreachable. Removes offlineGate, errOfflineSkipped, OutcomeWouldTry, the NewTracingResolver live parameter and the CreateArtworkResolver argument threaded through wire. Verified against a copy of a real library: disc artwork with "external" first in DiscArtPriority and external services enabled still records the skip and issues no agent call. * fix(artwork): make explain's no-network guarantee structural, not incidental Serving falls back disc -> album and track -> disc -> album. The resolver layer explain uses has no such fallback today, so dropping the offline gate did not leak. But the guarantee rested on which chains happen to lack an external tier, and the serving layer already shows the fallback shape someone could mirror. Without --live the tracing resolver is now built with no agents at all, so no chain and no fallback added later can reach a provider. That is stronger than the gate it replaces, which only intercepted the call. The test pins it against exactly that regression: with the guard removed and the serving fallback mirrored into resolveDisc, it fails. * refactor(artwork): trim the trace plumbing EncodeTrace was exported for nobody: only this package writes traces, and cmd reads them. It becomes a ChainTrace method, which also drops the copy Steps made for a caller that only wanted to serialize. explain's report carried queuedSteps and failureSteps, both pure functions of the queue and state rows already in the struct, which let a test set the two out of step with each other. formatExplain derives them, as it already does for every other display value. The trace row format and its tabwriter empty-cell rule lived in two places, and the "nothing was ever recorded" predicate in three. * fix(artwork): clear the queue trace on a fresh re-enqueue Enqueue's conflict clause reset attempts to 0 but left the new trace column, so after a scan or refresh re-enqueued a previously-failed item artwork explain showed "Attempts: 0" next to the prior lifecycle's "Last attempt failed" trace. Clear trace in Enqueue (a fresh lifecycle has no last attempt); EnqueuePreservingBackoff still keeps it. * fix(artwork): treat a processing-stage error as indeterminate in explain A read/hash/decode/store failure records an OutcomeError step and writes an absent row, but explainResult only mapped external errors and unreadable candidates to indeterminate, so the default verdict read "not resolved" — presenting a processing failure as a definitive miss. The worker retries these exactly as it retries an unreadable candidate, so classify any OutcomeError as indeterminate too. * fix(artwork): record a trace step when a chainless resolver faults Playlist and radio resolvers walk no priority chain, so a fault (unreadable upload/sidecar, or an m3u fetch error with no grid) returned localError/extError without recording any trace step. The attempt then encoded [], leaving artwork explain with an empty "Last attempt failed" and "Gave up after". Record a fallback step in the faulted-no-image branch when nothing else did, and carry the source label through resolveLocalFile so the step can name it. * fix(artwork): trace the m3u failure at its source, not via the empty guard A playlist's grid sampling records album-chain steps into the shared trace, so the processor's empty-trace fallback no longer fires when the m3u remote image fetch failed — the error that forced the retry was omitted from explain. Record it where it happens, in resolvePlaylist's external step, as external:m3u. * test(artwork): skip the chainless-fault spec on Windows The spec provokes an open fault with a non-directory parent, but Windows maps that to a not-exist error, so localError is never set and the item resolves absent instead of failed. The sibling failed-on-unreadable-upload spec skips Windows for the same class of reason. * fix(artwork): don't label an absent empty-chain row as pre-tracing explain reported "resolved before traces were recorded" for any stored row with an empty chain, but an empty CoverArtPriority records a real, empty [] chain and resolves absent. A recorded resolution that finds an image always records its winning candidate, so only a row with a hash and no chain predates tracing; split on the hash and report an absent empty chain plainly instead. * fix(db): retimestamp the artwork trace migration after rebase master merged a 2026-08-18 migration, so the original 2026-08-16 timestamp is now older than the newest on the base branch and Goose would silently skip it on an already-upgraded database. Bumped past it; the SQL is unchanged. * fix(artwork): keep the m3u error detail in the trace The m3u trace step recorded OutcomeError with no detail because resolveExternalStep collapsed the gate's error to a bool, so explain showed only "external:m3u error -" and could not tell a timeout from an HTTP error or an open breaker. Return the error (normalizing not-found to nil so it stays a definitive miss, not a failure) and store its message as the step detail; encodeSteps already bounds it. * docs(artwork): note the give-up write relies on serial draining recordGiveUp writes last_failure unconditionally; that is only correct because the drain resolves each item serially, so no concurrent success can store artwork between the write and the queue delete. Record the invariant at the call site. --- cmd/artwork.go | 108 ++++++++--- cmd/artwork_test.go | 108 +++++++---- core/artwork/agent_images.go | 6 +- core/artwork/artwork.go | 13 +- core/artwork/processor.go | 21 ++- core/artwork/processor_test.go | 29 +++ core/artwork/resolve.go | 26 ++- core/artwork/resolve_test.go | 31 ++++ core/artwork/trace.go | 107 ++++++++--- core/artwork/trace_test.go | 170 +++++++++++------- core/artwork/worker.go | 20 ++- core/artwork/worker_test.go | 95 ++++++++-- ...260819204637_add_artwork_trace_columns.sql | 9 + model/artwork.go | 10 +- persistence/artwork_queue_repository.go | 11 +- persistence/artwork_queue_repository_test.go | 27 ++- persistence/artwork_repository.go | 10 ++ persistence/artwork_repository_test.go | 45 +++++ tests/mock_artwork_queue_repo.go | 3 +- tests/mock_artwork_repo.go | 14 ++ 20 files changed, 662 insertions(+), 201 deletions(-) create mode 100644 db/migrations/20260819204637_add_artwork_trace_columns.sql diff --git a/cmd/artwork.go b/cmd/artwork.go index aeaec0e43..63f0d0917 100644 --- a/cmd/artwork.go +++ b/cmd/artwork.go @@ -36,8 +36,9 @@ var ( func init() { artworkExplainCmd.Flags().BoolVar(&explainLive, "live", false, - "perform real external lookups instead of reporting what would be tried; "+ - "also initializes plugin agents, which may open external connections") + "walk the chain again now, performing real external lookups, instead of reporting the "+ + "stored trace of the last resolution; also initializes plugin agents, which may open "+ + "external connections") artworkReprocessCmd.Flags().StringSliceVar(&reprocessKinds, "kind", nil, "kinds to reprocess ("+kindPrefixes(artwork.RecheckKinds)+"); repeatable") artworkReprocessCmd.Flags().StringSliceVar(&reprocessSources, "source", nil, @@ -640,10 +641,6 @@ func explainResult(source string, steps []artwork.TraceStep) string { if s.Outcome == artwork.OutcomeHit { break } - if s.Outcome == artwork.OutcomeWouldTry { - return "resolved from " + source + - " (offline: a higher-priority external candidate was not tried; re-run with --live)" - } // An external winner discards the earlier error, so the resolver settles it with no retry. if s.Outcome == artwork.OutcomeError && strings.HasPrefix(s.Candidate, artwork.ExternalPrefix) && !strings.HasPrefix(source, artwork.ExternalPrefix) { @@ -655,14 +652,12 @@ func explainResult(source string, steps []artwork.TraceStep) string { } for _, s := range steps { switch { - case s.Outcome == artwork.OutcomeWouldTry: - return "indeterminate (external agents not called; re-run with --live)" case s.Outcome == artwork.OutcomeError && strings.HasPrefix(s.Candidate, artwork.ExternalPrefix): return "indeterminate (an external lookup failed; the item may resolve on a later attempt)" - // The worker treats an unreadable local candidate exactly as it treats a failed external one: - // it retries instead of settling absent, so the verdict must not read as a clean miss. - case s.Outcome == artwork.OutcomeUnreadable: - return "indeterminate (a candidate exists but could not be read; the worker retries rather than settling absent)" + // A stage error or an unreadable candidate means a source was found but not processed; the + // worker retries rather than settling absent, so neither reads as a clean miss. + case s.Outcome == artwork.OutcomeError, s.Outcome == artwork.OutcomeUnreadable: + return "indeterminate (a candidate was found but could not be processed; the worker retries rather than settling absent)" } } return "not resolved" @@ -684,22 +679,55 @@ func explainConfig(kind model.Kind) (name, value string) { } type explainReport struct { - kind model.Kind - id string - name string - stored *model.ItemArtwork - queued *model.ArtworkQueueItem - agents string + kind model.Kind + id string + name string + stored *model.ItemArtwork + queued *model.ArtworkQueueItem + agents string + // steps is the chain walk: recorded when the item was resolved, or performed just now when walked. steps []artwork.TraceStep source string + walked bool resolveErr error } +// explainChainOrigin says whether the operator is reading history or a walk performed just now, +// since the two can disagree after a config change. +func explainChainOrigin(rep explainReport) string { + if rep.walked { + return "walked now" + } + if rep.stored != nil { + return "recorded " + formatTime(rep.stored.AttemptedAt) + } + return "not recorded" +} + +// writeSteps prints the trace rows. An empty last cell would end tabwriter's column block and +// break the alignment, so a missing detail is rendered as a dash. +func writeSteps(w io.Writer, indent string, steps []artwork.TraceStep) { + for _, s := range steps { + fmt.Fprintf(w, "%s%s\t%s\t%s\n", indent, s.Candidate, s.Outcome, cmp.Or(s.Detail, "-")) + } +} + +// writeStepTable prints a secondary trace, and nothing at all when there is none to show. +func writeStepTable(w io.Writer, title string, steps []artwork.TraceStep) { + if len(steps) == 0 { + return + } + // No tab on the title: it closes the preceding column block, so these rows align among themselves. + fmt.Fprintf(w, " %s:\n", title) + writeSteps(w, " ", steps) +} + func formatExplain(rep explainReport) string { var sb strings.Builder w := newTabWriter(&sb) explainable := artwork.Explainable(rep.kind) stateful := artwork.KeepsState(rep.kind) + unrecorded := !rep.walked && rep.stored == nil fmt.Fprintln(w, "Item") fmt.Fprintf(w, " Kind:\t%s (%s)\n", rep.kind, rep.kind.Prefix()) @@ -732,6 +760,12 @@ func formatExplain(rep explainReport) string { fmt.Fprintf(w, " Attempts:\t%d\n", rep.queued.Attempts) fmt.Fprintf(w, " Retry at:\t%s\n", formatTime(rep.queued.RetryAt)) } + if rep.queued != nil { + writeStepTable(w, "Last attempt failed", artwork.DecodeTrace(rep.queued.Trace, "")) + } + if rep.stored != nil { + writeStepTable(w, "Gave up after", artwork.DecodeTrace(rep.stored.LastFailure, "")) + } fmt.Fprintln(w, "\nConfig") if setting, value := explainConfig(rep.kind); setting == "" { @@ -743,15 +777,22 @@ func formatExplain(rep explainReport) string { } } - fmt.Fprintln(w, "\nChain") - if !explainable { + fmt.Fprintf(w, "\nChain (%s)\n", explainChainOrigin(rep)) + switch { + case !explainable: fmt.Fprintf(w, " (%s artwork does not walk a priority chain)\n", rep.kind) - } else { + case unrecorded: + fmt.Fprintln(w, " (no resolution recorded yet; re-run with --live to walk the chain now)") + case !rep.walked && len(rep.steps) == 0 && rep.stored.Hash != "": + // A stored image with no chain can only predate trace recording: a recorded resolution that + // found an image always records its winning candidate. + fmt.Fprintln(w, " (this item was resolved before traces were recorded; re-run with --live)") + case !rep.walked && len(rep.steps) == 0: + // Absent with no chain: an empty priority list walked nothing, or a pre-tracing absent row. + fmt.Fprintln(w, " (no candidates were recorded; re-run with --live to walk the chain now)") + default: fmt.Fprintln(w, " CANDIDATE\tOUTCOME\tDETAIL") - for _, s := range rep.steps { - // A row with an empty last cell would end tabwriter's column block, breaking alignment. - fmt.Fprintf(w, " %s\t%s\t%s\n", s.Candidate, s.Outcome, cmp.Or(s.Detail, "-")) - } + writeSteps(w, " ", rep.steps) } fmt.Fprintln(w, "\nResult") @@ -760,6 +801,8 @@ func formatExplain(rep explainReport) string { fmt.Fprintf(w, " resolution failed: %s\n", rep.resolveErr) case !explainable: fmt.Fprintln(w, " not evaluated (no chain was walked; see Stored above)") + case unrecorded: + fmt.Fprintln(w, " not evaluated (nothing recorded; re-run with --live to walk the chain now)") default: fmt.Fprintf(w, " %s\n", explainResult(rep.source, rep.steps)) } @@ -807,6 +850,8 @@ func runExplain(ctx context.Context, args []string) { } } + // Disc artwork keeps no row, so it has no stored trace and can only be explained by walking now. + rep.walked = explainLive || !artwork.KeepsState(kind) if artwork.Explainable(kind) { // Only artist and album reach an agent, and the load must precede the resolver, which reads // the same manager. @@ -815,11 +860,16 @@ func runExplain(ctx context.Context, args []string) { defer func() { _ = mgr.Stop() }() rep.agents = explainAgents(conf.Server.Agents, availableImageAgents(ds, mgr, kind)) } - trace := &artwork.ChainTrace{} - rep.source, rep.resolveErr = CreateArtworkResolver(trace, explainLive).Resolve(ctx, kind, id) - rep.steps = trace.Steps() + switch { + case rep.walked: + trace := &artwork.ChainTrace{} + rep.source, rep.resolveErr = CreateArtworkResolver(trace, explainLive).Resolve(ctx, kind, id) + rep.steps = trace.Steps() + case rep.stored != nil: + rep.steps = artwork.DecodeTrace(rep.stored.Trace, rep.stored.SourcePath) + rep.source = rep.stored.Source + } } - fmt.Print(formatExplain(rep)) // The steps taken before a failed walk are the diagnosis, so report them before exiting. if rep.resolveErr != nil { diff --git a/cmd/artwork_test.go b/cmd/artwork_test.go index 8b50ba775..38a1b79cb 100644 --- a/cmd/artwork_test.go +++ b/cmd/artwork_test.go @@ -145,6 +145,15 @@ var _ = Describe("explainResult", func() { "the worker retries an unreadable candidate instead of settling absent, so this is not a clean miss") }) + It("reports indeterminate when a processing stage errored after a candidate was found", func() { + steps := []artwork.TraceStep{ + {Candidate: "cover.*", Outcome: "hit", Detail: "/music/cover.jpg"}, + {Candidate: "store", Outcome: "error", Detail: "disk full"}, + } + Expect(explainResult("", steps)).To(ContainSubstring("indeterminate"), + "a stage error is a processing failure the worker retries, not a definitive miss") + }) + It("does not qualify a hit that an earlier unreadable candidate preceded", func() { // chainState.try stamps only the external error onto a hit and drops the local one, so the // worker settles this as found; warning about it would be a false alarm. @@ -164,34 +173,6 @@ var _ = Describe("explainResult", func() { "a failed network call is not evidence that the item has no artwork") }) - It("qualifies a win a skipped higher-priority external candidate could have taken", func() { - steps := []artwork.TraceStep{ - {Candidate: "external:deezer", Outcome: "would-try"}, - {Candidate: "artist.*", Outcome: "hit", Detail: "/music/artist.jpg"}, - } - res := explainResult("artist.*", steps) - Expect(res).To(ContainSubstring("resolved from artist.*")) - Expect(res).To(ContainSubstring("--live"), - "offline, the winner is only the winner because the external tier was skipped") - }) - - It("does not qualify a win that no skipped candidate outranked", func() { - steps := []artwork.TraceStep{ - {Candidate: "artist.*", Outcome: "hit"}, - {Candidate: "external:deezer", Outcome: "would-try"}, - } - Expect(explainResult("artist.*", steps)).To(Equal("resolved from artist.*")) - }) - - It("reports indeterminate when external agents were never called", func() { - steps := []artwork.TraceStep{ - {Candidate: "artist.*", Outcome: "miss"}, - {Candidate: "external:deezer", Outcome: "would-try"}, - } - Expect(explainResult("", steps)).To(ContainSubstring("indeterminate"), - "an offline run must not claim an item is unresolvable when external agents were skipped") - }) - It("qualifies a win a failed higher-priority external lookup could have taken", func() { steps := []artwork.TraceStep{ {Candidate: "external:deezer", Outcome: "error", Detail: "context deadline exceeded"}, @@ -252,9 +233,10 @@ var _ = Describe("formatExplain", func() { id: "ar-1", name: "Radiohead", agents: "lastfm,spotify", + walked: true, steps: []artwork.TraceStep{ {Candidate: "upload", Outcome: "skipped", Detail: "no uploaded image"}, - {Candidate: "external:deezer", Outcome: "would-try"}, + {Candidate: "external:deezer", Outcome: "error", Detail: "context deadline exceeded"}, }, source: "", } @@ -267,7 +249,6 @@ var _ = Describe("formatExplain", func() { Expect(out).To(ContainSubstring("ArtistArtPriority")) Expect(out).To(ContainSubstring("lastfm,spotify")) Expect(out).To(ContainSubstring("external:deezer")) - Expect(out).To(ContainSubstring("would-try")) Expect(out).To(ContainSubstring("indeterminate")) }) @@ -304,7 +285,7 @@ var _ = Describe("formatExplain", func() { out := formatExplain(rep) Expect(out).To(ContainSubstring("resolution failed: no such directory")) Expect(out).ToNot(ContainSubstring("indeterminate")) - Expect(out).To(ContainSubstring("would-try"), "the steps taken before the failure still print") + Expect(out).To(ContainSubstring("external:deezer"), "the steps taken before the failure still print") }) It("says a kind that does not walk a chain has no chain, without an empty table", func() { @@ -329,6 +310,7 @@ var _ = Describe("formatExplain", func() { kind: model.KindDiscArtwork, id: "al-1:2", name: "OK Computer (disc 2)", steps: []artwork.TraceStep{{Candidate: "cover.jpg", Outcome: "hit", Detail: "/music/cover.jpg"}}, source: "folder", + walked: true, } out := formatExplain(rep) @@ -341,10 +323,74 @@ var _ = Describe("formatExplain", func() { Expect(out).To(ContainSubstring("resolved from folder")) }) + Context("stored traces", func() { + BeforeEach(func() { + rep.walked = false + rep.steps = nil + }) + + It("labels a recorded chain with when it was recorded, not as a walk done now", func() { + attempted := time.Date(2026, 8, 13, 10, 0, 0, 0, time.UTC) + rep.stored = &model.ItemArtwork{Source: "folder", Hash: "abc", AttemptedAt: attempted} + rep.steps = []artwork.TraceStep{{Candidate: "artist.*", Outcome: "hit", Detail: "/music/artist.jpg"}} + rep.source = "folder" + + out := formatExplain(rep) + Expect(out).To(ContainSubstring("Chain (recorded 2026-08-13T10:00:00Z)")) + Expect(out).To(ContainSubstring("/music/artist.jpg")) + Expect(out).To(ContainSubstring("resolved from folder")) + }) + + It("says so when the item has never been resolved", func() { + out := formatExplain(rep) + Expect(out).To(ContainSubstring("no resolution recorded yet")) + Expect(out).To(ContainSubstring("--live")) + Expect(out).ToNot(ContainSubstring("not resolved"), + "nothing was recorded, which is not the same as resolving to nothing") + }) + + It("distinguishes a row written before traces existed from one with an empty chain", func() { + rep.stored = &model.ItemArtwork{Source: "folder", Hash: "abc", AttemptedAt: time.Now()} + + Expect(formatExplain(rep)).To(ContainSubstring("resolved before traces were recorded")) + }) + + It("does not call an absent row with an empty recorded chain a pre-tracing row", func() { + // An empty priority list records a real but empty chain and resolves absent; that is not a + // legacy row, so it must not be reported as resolved before tracing existed. + rep.stored = &model.ItemArtwork{Source: "", Hash: "", AttemptedAt: time.Now()} + + out := formatExplain(rep) + Expect(out).ToNot(ContainSubstring("resolved before traces were recorded")) + Expect(out).To(ContainSubstring("no candidates were recorded")) + Expect(out).To(ContainSubstring("not resolved"), "the Result still reports the absence plainly") + }) + + It("prints why the last attempt failed and why it gave up", func() { + rep.queued = &model.ArtworkQueueItem{Priority: model.ArtworkPriorityScan, Attempts: 3, + Trace: `[{"c":"decode","o":"error","d":"bad header"}]`} + rep.stored = &model.ItemArtwork{Source: "folder", Hash: "abc", AttemptedAt: time.Now(), + LastFailure: `[{"c":"read","o":"error","d":"i/o timeout"}]`} + + out := formatExplain(rep) + Expect(out).To(ContainSubstring("Last attempt failed")) + Expect(out).To(ContainSubstring("bad header")) + Expect(out).To(ContainSubstring("Gave up after")) + Expect(out).To(ContainSubstring("i/o timeout")) + }) + + It("omits the failure tables when there is no failure to report", func() { + out := formatExplain(rep) + Expect(out).ToNot(ContainSubstring("Last attempt failed")) + Expect(out).ToNot(ContainSubstring("Gave up after")) + }) + }) + It("reports the setting that governs media file artwork", func() { conf.Server.EnableMediaFileCoverArt = false rep = explainReport{ kind: model.KindMediaFileArtwork, id: "mf-1", name: "Airbag", + walked: true, steps: []artwork.TraceStep{ {Candidate: "embedded", Outcome: "skipped", Detail: "EnableMediaFileCoverArt is off"}, }, diff --git a/core/artwork/agent_images.go b/core/artwork/agent_images.go index a6f746959..95596dabc 100644 --- a/core/artwork/agent_images.go +++ b/core/artwork/agent_images.go @@ -58,7 +58,7 @@ func fetchArtistImage(ctx context.Context, ag *agents.Agents, gate gateFunc, ar return nil, "", false } for _, a := range imageAgents { - reader, _, err := gate(a.Name, func() (io.ReadCloser, string, error) { + reader, path, 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 @@ -69,6 +69,7 @@ func fetchArtistImage(ctx context.Context, ag *agents.Agents, gate gateFunc, ar } return fromURL(ctx, u) }) + recordAgent(ctx, a.Name, reader, path, err) if reader != nil { return reader, a.Name, false } @@ -90,7 +91,7 @@ func fetchAlbumImage(ctx context.Context, ag *agents.Agents, gate gateFunc, al m return nil, "", false } for _, a := range imageAgents { - reader, _, err := gate(a.Name, func() (io.ReadCloser, string, error) { + reader, path, 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 @@ -101,6 +102,7 @@ func fetchAlbumImage(ctx context.Context, ag *agents.Agents, gate gateFunc, al m } return fromURL(ctx, u) }) + recordAgent(ctx, a.Name, reader, path, err) if reader != nil { return reader, a.Name, false } diff --git a/core/artwork/artwork.go b/core/artwork/artwork.go index 7edc80e99..e8458a0f9 100644 --- a/core/artwork/artwork.go +++ b/core/artwork/artwork.go @@ -393,16 +393,15 @@ type TracingResolver struct { trace *ChainTrace } -// NewTracingResolver builds a TracingResolver that records its priority-chain walk. With live -// false the external tier is reported but never called. +// NewTracingResolver builds a TracingResolver that records its priority-chain walk. Without live +// it gets no agents at all, so neither a chain nor any fallback added later can reach a provider; +// with it, one item is at most one call per agent, so the rate limiter and breaker are bypassed. func NewTracingResolver(ds model.DataStore, ag *agents.Agents, ffm ffmpeg.FFmpeg, t *ChainTrace, live bool) *TracingResolver { - gate := offlineGate(t) + inner := newLocalResolver(ds, ffm) if live { - // A diagnostic must show the provider's real answer, and one item is at most one call - // per agent, so --live deliberately bypasses the rate limiter and circuit breaker. - gate = tracingGate(t, passthroughGate) + inner = newResolver(ds, ag, ffm, passthroughGate) } - return &TracingResolver{inner: newResolver(ds, ag, ffm, gate), trace: t} + return &TracingResolver{inner: inner, trace: t} } // Resolve walks kind's sources for id, recording the walk, and reports the winning source diff --git a/core/artwork/processor.go b/core/artwork/processor.go index 4d38ced95..fdb28189a 100644 --- a/core/artwork/processor.go +++ b/core/artwork/processor.go @@ -2,6 +2,7 @@ package artwork import ( "bytes" + "cmp" "context" "encoding/base64" "errors" @@ -89,12 +90,21 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o res, err := p.resolver.resolve(ctx, item) if err != nil { + traceStage(ctx, "resolve", err) 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. + // A chainless resolver (playlist/radio) records no step, so leave a fallback or explain is blank. + if t := traceFrom(ctx); len(t.Steps()) == 0 { + outcome := OutcomeError + if res.localError { + outcome = OutcomeUnreadable + } + t.add(TraceStep{Candidate: cmp.Or(res.source, "source"), Outcome: outcome}) + } 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 @@ -106,6 +116,7 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o readStart := time.Now() data, err := readCapped(res.reader) if err != nil { + traceStage(ctx, "read", err) log.Warn(ctx, "Artwork: Failed to read resolved image", "kind", item.ItemKind, "id", item.ItemID, "source", res.source, err) return outcomeFailed, nil } @@ -115,6 +126,7 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o hashStart := time.Now() hash, err := hashImage(bytes.NewReader(data)) if err != nil { + traceStage(ctx, "hash", err) log.Warn(ctx, "Artwork: Failed to hash image", "kind", item.ItemKind, "id", item.ItemID, err) return outcomeFailed, nil } @@ -138,19 +150,22 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o art, err = undecodedArtwork(hash), nil } if err != nil { + traceStage(ctx, "decode", err) 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: + traceStage(ctx, "lookup", err) 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) + ia, err := p.persist(ctx, repo, item, art, res, data) if err != nil { + traceStage(ctx, "store", err) log.Warn(ctx, "Artwork: Failed to persist resolved image", "kind", item.ItemKind, "id", item.ItemID, err) return outcomeFailed, nil } @@ -165,7 +180,7 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o // 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, +func (p *processor) persist(ctx context.Context, repo model.ArtworkRepository, item model.ArtworkQueueItem, art *model.Artwork, res resolution, data []byte, ) (*model.ItemArtwork, error) { if p.pruneLock != nil { @@ -188,6 +203,7 @@ func (p *processor) persist(repo model.ArtworkRepository, item model.ArtworkQueu SourcePath: sourcePath, RefMtime: refMtime, AttemptedAt: time.Now(), + Trace: traceFrom(ctx).encode(sourcePath), } // PutItemArtwork stamps UpdatedAt on ia, so the returned struct matches the persisted row. if err := repo.PutItemArtwork(ia); err != nil { @@ -203,6 +219,7 @@ func writeAbsent(ctx context.Context, repo model.ArtworkRepository, item model.A ItemID: item.ItemID, ImageType: item.ImageType, AttemptedAt: time.Now(), + Trace: traceFrom(ctx).encode(""), }) if err != nil { log.Warn(ctx, "Artwork: Failed to persist absent state", "kind", item.ItemKind, "id", item.ItemID, err) diff --git a/core/artwork/processor_test.go b/core/artwork/processor_test.go index 1ada8415d..0ca5a308e 100644 --- a/core/artwork/processor_test.go +++ b/core/artwork/processor_test.go @@ -229,6 +229,35 @@ var _ = Describe("processor.acquire", func() { Expect(err).To(MatchError(model.ErrNotFound), "an unreadable upload must not be recorded as absent") }) + // Playlist/radio resolvers walk no chain, so a fault records no step; without a fallback, + // explain would show a give-up with an empty "Gave up after" table. + It("chainless fault: records a fallback trace step naming the faulted source", func() { + if runtime.GOOS == "windows" { + // os.Open under a non-directory maps to a not-exist error on Windows, so no localError. + Skip("cannot provoke an open fault via a non-directory parent 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-tr.jpg") + // A plain file where the upload's parent should be makes os.Open fault with ENOTDIR, + // deterministically and regardless of the test user's privileges. + Expect(os.MkdirAll(filepath.Dir(filepath.Dir(upload)), 0o755)).To(Succeed()) + Expect(os.WriteFile(filepath.Dir(upload), []byte("x"), 0o600)).To(Succeed()) + radioRepo.Data["ra-tr"] = &model.Radio{ID: "ra-tr", Name: "Station", UploadedImage: "ra-tr.jpg"} + + trace := &ChainTrace{} + out, _ := proc.acquire(withTrace(ctx, trace), model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra-tr"}) + Expect(out).To(Equal(outcomeFailed)) + + steps := trace.Steps() + Expect(steps).To(HaveLen(1), "a radio fault must leave one step so explain is not blank") + Expect(steps[0].Candidate).To(Equal("upload")) + Expect(steps[0].Outcome).To(Equal(OutcomeUnreadable)) + }) + It("failed-on-extError: leaves the item's state untouched", func() { conf.Server.CoverArtPriority = "external" ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ diff --git a/core/artwork/resolve.go b/core/artwork/resolve.go index d25f76460..f42beb9f1 100644 --- a/core/artwork/resolve.go +++ b/core/artwork/resolve.go @@ -37,7 +37,7 @@ type resolution struct { // transient external failure still retries; localErr is dropped, as the scanner re-lists changes. type chainState struct { extErr, localErr bool - trace *ChainTrace // nil unless the CLI asked for a trace + trace *ChainTrace // nil only where no caller attached one } // try stamps the accumulated external failure onto a hit, and records the miss otherwise. @@ -354,10 +354,13 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso } 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 { + if res, ok, err := resolveExternalStep(r.ext.gate, "m3u", sf); ok { return res, nil - } else if isErr { + } else if err != nil { extErr = true + // Record it here with its detail: once album sampling adds its own steps, the processor's + // empty-trace fallback no longer fires, and the error that forced the retry would be lost. + traceFrom(ctx).add(TraceStep{Candidate: ExternalPrefix + "m3u", Outcome: OutcomeError, Detail: err.Error()}) } } @@ -461,14 +464,17 @@ func (r *resolver) resolveDisc(ctx context.Context, id string) (resolution, erro return dr.selectImage(ctx, r.ffmpeg, conf.Server.DiscArtPriority, &chain) } -// 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) { +// resolveExternalStep runs a single external sourceFunc through the named gate. A not-found is a +// definitive "no", returned as (_, false, nil); any other error is a failure the caller records. +func resolveExternalStep(gate gateFunc, name string, sf sourceFunc) (resolution, bool, error) { r, path, err := gate(name, sf) if r != nil { - return resolution{reader: r, source: externalCandidate, sourcePath: path}, true, false + return resolution{reader: r, source: externalCandidate, sourcePath: path}, true, nil } - return resolution{}, false, err != nil && !errors.Is(err, model.ErrNotFound) + if errors.Is(err, model.ErrNotFound) { + return resolution{}, false, nil + } + return resolution{}, false, err } // classifyPlaylistImage splits a playlist ExternalImageURL into a local filesystem path or a @@ -561,7 +567,9 @@ func resolveLocalFile(path, source string) (resolution, bool) { } f, err := os.Open(path) if err != nil { - return resolution{localError: !errors.Is(err, fs.ErrNotExist)}, false + // Carry the source label even on a fault, so a resolver with no chain (playlist/radio) can + // still name what faulted in the trace. + return resolution{source: source, localError: !errors.Is(err, fs.ErrNotExist)}, false } return resolution{reader: f, source: source, sourcePath: path, refMtime: mtimeOf(path)}, true } diff --git a/core/artwork/resolve_test.go b/core/artwork/resolve_test.go index 8b4c11c8c..236e76b9b 100644 --- a/core/artwork/resolve_test.go +++ b/core/artwork/resolve_test.go @@ -520,6 +520,37 @@ var _ = Describe("resolveItem", func() { Expect(gatedNames).To(Equal([]string{"m3u"}), "the playlist URL fetch is gated under \"m3u\"") }) + It("records the m3u failure in the trace even when album sampling adds its own steps", func() { + conf.Server.EnableM3UExternalAlbumArt = true + folderRepo.result = nil // the sampled album yields no tile, so the m3u failure is what forced the retry + + plRepo := tests.CreateMockPlaylistRepo() + plRepo.SetData(model.Playlists{{ID: "plm3u", Name: "Playlist", ExternalImageURL: "http://example.com/cover.jpg"}}) + plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}} + ds.MockedPlaylist = plRepo + + gate := func(string, func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { + return nil, "", errors.New("network down") + } + + trace := &ChainTrace{} + res, err := newResolver(ds, ag, ffm, gate).resolve(withTrace(ctx, trace), + model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plm3u"}) + Expect(err).ToNot(HaveOccurred()) + Expect(res.extError).To(BeTrue()) + + steps := trace.Steps() + var m3u *TraceStep + for i := range steps { + if steps[i].Candidate == ExternalPrefix+"m3u" && steps[i].Outcome == OutcomeError { + m3u = &steps[i] + } + } + Expect(m3u).ToNot(BeNil(), "the m3u fetch error must be traced at its source, not left to the empty-trace fallback") + Expect(m3u.Detail).To(Equal("network down"), + "the trace must carry the underlying error so explain can tell a timeout from an HTTP error") + }) + 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 diff --git a/core/artwork/trace.go b/core/artwork/trace.go index 5d02fa4ff..bca2f2c7d 100644 --- a/core/artwork/trace.go +++ b/core/artwork/trace.go @@ -2,10 +2,12 @@ package artwork import ( "context" - "errors" + "encoding/json" "io" "slices" "sync" + + "github.com/navidrome/navidrome/utils/str" ) // Outcome is what the priority chain observed for one candidate; the CLI renders and branches on these. @@ -16,7 +18,6 @@ const ( OutcomeMiss Outcome = "miss" OutcomeUnreadable Outcome = "unreadable" OutcomeSkipped Outcome = "skipped" - OutcomeWouldTry Outcome = "would-try" OutcomeError Outcome = "error" ) @@ -34,8 +35,8 @@ type TraceStep struct { Detail string } -// ChainTrace collects the walk of a single resolution. The artwork worker never attaches -// one; only the CLI does, so resolution stays allocation-free in the hot path. +// ChainTrace collects the walk of a single resolution: the worker attaches one per queue +// item so it can be stored, and the CLI attaches one per explain. type ChainTrace struct { mu sync.Mutex steps []TraceStep @@ -59,6 +60,65 @@ func (t *ChainTrace) Steps() []TraceStep { return slices.Clone(t.steps) } +// maxTraceDetail bounds a stored Detail, which on the failure paths is an error string of +// unknown length. Past ~1kB a row spills to an overflow page, slowing every scan of the table. +const maxTraceDetail = 200 + +// storedStep is the persisted shape of a TraceStep. The keys are single letters because a trace +// is written for every item, and the encoded length is repeated across the whole library. +type storedStep struct { + C string `json:"c"` + O Outcome `json:"o"` + D string `json:"d,omitempty"` +} + +// encode serializes the trace for storage, without the copy Steps would make for a caller +// that only wants to write it. +func (t *ChainTrace) encode(sourcePath string) string { + if t == nil { + return encodeSteps(nil, sourcePath) + } + t.mu.Lock() + defer t.mu.Unlock() + return encodeSteps(t.steps, sourcePath) +} + +// encodeSteps writes the stored form. A hit's Detail is the winning source's path, which the +// same row already stores as source_path, so it is dropped and DecodeTrace puts it back. +func encodeSteps(steps []TraceStep, sourcePath string) string { + out := make([]storedStep, 0, len(steps)) + for _, s := range steps { + d := s.Detail + if s.Outcome == OutcomeHit && d == sourcePath { + d = "" + } + out = append(out, storedStep{C: s.Candidate, O: s.Outcome, D: str.TruncateRunes(d, maxTraceDetail, "...")}) + } + b, _ := json.Marshal(out) // []storedStep is all strings, so this cannot fail + return string(b) +} + +// DecodeTrace reverses the stored form. A trace that will not parse is reported as no trace at all, +// since a diagnostic command must not fail on a bad row. +func DecodeTrace(encoded, sourcePath string) []TraceStep { + if encoded == "" { + return nil + } + var stored []storedStep + if err := json.Unmarshal([]byte(encoded), &stored); err != nil { + return nil + } + steps := make([]TraceStep, 0, len(stored)) + for _, s := range stored { + d := s.D + if d == "" && s.O == OutcomeHit { + d = sourcePath + } + steps = append(steps, TraceStep{Candidate: s.C, Outcome: s.O, Detail: d}) + } + return steps +} + type traceCtxKey struct{} func withTrace(ctx context.Context, t *ChainTrace) context.Context { @@ -70,30 +130,23 @@ func traceFrom(ctx context.Context) *ChainTrace { return t } -var errOfflineSkipped = errors.New("artwork: external lookup skipped (offline)") - -// tracingGate records each external agent's outcome without changing what the gate returns. -func tracingGate(t *ChainTrace, inner gateFunc) gateFunc { - return func(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { - r, path, err := inner(name, f) - candidate := ExternalPrefix + name - switch { - case r != nil: - t.add(TraceStep{Candidate: candidate, Outcome: OutcomeHit, Detail: path}) - case isTransientExternal(err): - t.add(TraceStep{Candidate: candidate, Outcome: OutcomeError, Detail: err.Error()}) - default: - t.add(TraceStep{Candidate: candidate, Outcome: OutcomeMiss}) - } - return r, path, err +// recordAgent files what one external agent answered. The agent loops call this rather than a +// gate wrapper, because only they hold the context that carries the trace. +func recordAgent(ctx context.Context, name string, r io.ReadCloser, path string, err error) { + t := traceFrom(ctx) + candidate := ExternalPrefix + name + switch { + case r != nil: + t.add(TraceStep{Candidate: candidate, Outcome: OutcomeHit, Detail: path}) + case isTransientExternal(err): + t.add(TraceStep{Candidate: candidate, Outcome: OutcomeError, Detail: err.Error()}) + default: + t.add(TraceStep{Candidate: candidate, Outcome: OutcomeMiss}) } } -// offlineGate reports which agents would be asked without asking them, so a diagnostic -// command cannot add load to a provider that is already rate-limiting us. -func offlineGate(t *ChainTrace) gateFunc { - return func(name string, _ func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { - t.add(TraceStep{Candidate: ExternalPrefix + name, Outcome: OutcomeWouldTry}) - return nil, "", errOfflineSkipped - } +// traceStage records a failure from the stages that run after the priority chain has already +// picked a winner: most ways an item can fail are here, not in the chain walk. +func traceStage(ctx context.Context, stage string, err error) { + traceFrom(ctx).add(TraceStep{Candidate: stage, Outcome: OutcomeError, Detail: err.Error()}) } diff --git a/core/artwork/trace_test.go b/core/artwork/trace_test.go index 5a54c9e91..a16347457 100644 --- a/core/artwork/trace_test.go +++ b/core/artwork/trace_test.go @@ -24,13 +24,63 @@ var _ = Describe("trace vocabulary", func() { // what `artwork explain` tells an operator, so it must be made deliberately. It("pins the wire values the CLI reads", func() { Expect([]Outcome{ - OutcomeHit, OutcomeMiss, OutcomeUnreadable, OutcomeSkipped, OutcomeWouldTry, OutcomeError, - }).To(Equal([]Outcome{"hit", "miss", "unreadable", "skipped", "would-try", "error"})) + OutcomeHit, OutcomeMiss, OutcomeUnreadable, OutcomeSkipped, OutcomeError, + }).To(Equal([]Outcome{"hit", "miss", "unreadable", "skipped", "error"})) Expect(externalCandidate).To(Equal("external")) Expect(ExternalPrefix).To(Equal("external:")) }) }) +var _ = Describe("encodeSteps/DecodeTrace", func() { + It("round-trips a trace", func() { + steps := []TraceStep{ + {Candidate: "cover.png", Outcome: OutcomeMiss}, + {Candidate: "cover.*", Outcome: OutcomeHit, Detail: "/music/a/cover.jpg"}, + } + Expect(DecodeTrace(encodeSteps(steps, ""), "")).To(Equal(steps)) + }) + + It("encodes an empty trace as an empty JSON array", func() { + Expect(encodeSteps(nil, "")).To(Equal("[]")) + Expect(DecodeTrace("[]", "")).To(BeEmpty()) + }) + + It("tolerates a row written before the column existed", func() { + Expect(DecodeTrace("", "")).To(BeEmpty()) + }) + + // The hit detail repeats source_path byte for byte, and that column is on the same row. + It("drops a hit detail that repeats sourcePath, and restores it on read", func() { + path := "/music/artist/album/cover.jpg" + steps := []TraceStep{{Candidate: "cover.*", Outcome: OutcomeHit, Detail: path}} + encoded := encodeSteps(steps, path) + Expect(encoded).NotTo(ContainSubstring(path)) + Expect(DecodeTrace(encoded, path)).To(Equal(steps)) + }) + + It("keeps a detail that differs from sourcePath", func() { + steps := []TraceStep{{Candidate: "external:deezer", Outcome: OutcomeHit, Detail: "https://cdn/x.jpg"}} + Expect(DecodeTrace(encodeSteps(steps, "/music/a/cover.jpg"), "/music/a/cover.jpg")).To(Equal(steps)) + }) + + // A row past ~1kB spills to an overflow page on these WITHOUT ROWID tables, which would + // slow every scan; Detail is an error string on the failure paths, so it needs a bound. + It("bounds a detail so one long error cannot inflate the row", func() { + steps := []TraceStep{{Candidate: "decode", Outcome: OutcomeError, Detail: strings.Repeat("x", 5000)}} + + got := DecodeTrace(encodeSteps(steps, ""), "") + + Expect(len(got[0].Detail)).To(BeNumerically("<=", 210)) + Expect(got[0].Detail).To(HaveSuffix("...")) + Expect(got[0].Candidate).To(Equal("decode"), "truncating the detail must not disturb the step") + }) + + It("only restores sourcePath onto a detail-less hit", func() { + steps := []TraceStep{{Candidate: "cover.*", Outcome: OutcomeMiss}} + Expect(DecodeTrace(encodeSteps(steps, "/music/a/cover.jpg"), "/music/a/cover.jpg")).To(Equal(steps)) + }) +}) + var _ = Describe("chainTrace", func() { It("returns nil when no trace is attached", func() { Expect(traceFrom(context.Background())).To(BeNil()) @@ -113,63 +163,41 @@ var _ = Describe("chainState tracing", func() { }) }) -var _ = Describe("external gate tracing", func() { - hit := func() (io.ReadCloser, string, error) { - return io.NopCloser(strings.NewReader("x")), "http://img", nil - } - miss := func() (io.ReadCloser, string, error) { return nil, "", agents.ErrNotFound } - boom := func() (io.ReadCloser, string, error) { return nil, "", errors.New("returned status 429") } +var _ = Describe("external agent tracing", func() { + var ( + t *ChainTrace + ctx context.Context + body io.ReadCloser + ) + BeforeEach(func() { + t = &ChainTrace{} + ctx = withTrace(context.Background(), t) + body = io.NopCloser(strings.NewReader("x")) + }) It("records a hit with the image path", func() { - t := &ChainTrace{} - g := tracingGate(t, passthroughGate) - - r, _, err := g("deezer", hit) - - Expect(err).ToNot(HaveOccurred()) - Expect(r).ToNot(BeNil()) + recordAgent(ctx, "deezer", body, "http://img", nil) Expect(t.Steps()).To(Equal([]TraceStep{ {Candidate: "external:deezer", Outcome: OutcomeHit, Detail: "http://img"}, })) }) It("records a miss for a not-found", func() { - t := &ChainTrace{} - _, _, _ = tracingGate(t, passthroughGate)("deezer", miss) + recordAgent(ctx, "deezer", nil, "", agents.ErrNotFound) Expect(t.Steps()[0].Outcome).To(Equal(OutcomeMiss)) }) It("records a miss for a model not-found", func() { - t := &ChainTrace{} - notFound := func() (io.ReadCloser, string, error) { return nil, "", model.ErrNotFound } - _, _, _ = tracingGate(t, passthroughGate)("deezer", notFound) + recordAgent(ctx, "deezer", nil, "", model.ErrNotFound) Expect(t.Steps()[0].Outcome).To(Equal(OutcomeMiss), "both not-found flavours are definitive answers, not faults") }) It("records an error with its reason", func() { - t := &ChainTrace{} - _, _, _ = tracingGate(t, passthroughGate)("apple-music", boom) + recordAgent(ctx, "apple-music", nil, "", errors.New("returned status 429")) Expect(t.Steps()[0].Outcome).To(Equal(OutcomeError)) Expect(t.Steps()[0].Detail).To(ContainSubstring("429")) }) - - It("never calls the agent in offline mode", func() { - t := &ChainTrace{} - called := false - counting := func() (io.ReadCloser, string, error) { - called = true - return hit() - } - - _, _, err := offlineGate(t)("deezer", counting) - - Expect(called).To(BeFalse(), "offline mode must not perform external requests") - Expect(err).To(MatchError(errOfflineSkipped)) - Expect(t.Steps()).To(Equal([]TraceStep{ - {Candidate: "external:deezer", Outcome: OutcomeWouldTry}, - })) - }) }) var _ = Describe("resolveAlbum tracing", func() { @@ -409,51 +437,51 @@ var _ = Describe("NewTracingResolver", func() { t = &ChainTrace{} }) - Context("offline", func() { + Context("resolving", func() { var fake *fakeImageAgent BeforeEach(func() { - fake = &fakeImageAgent{name: "offline-probe"} + // Misses, so the chain falls through to the local tier and both are traced. + fake = &fakeImageAgent{name: "probe", err: agents.ErrNotFound} albumRepo.SetData(model.Albums{{ ID: "al1", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}, }}) artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) }) - It("reports the external tier without asking any agent", func() { - source, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, false).Resolve(context.Background(), model.KindAlbumArtwork, "al1") + It("asks the agents and records what each answered", func() { + source, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).Resolve(context.Background(), model.KindAlbumArtwork, "al1") Expect(err).ToNot(HaveOccurred()) Expect(source).To(Equal("embedded")) - Expect(fake.albumCalls).To(BeZero(), "offline mode must not add load to an external provider") - Expect(t.Steps()).To(ContainElement(TraceStep{Candidate: "external:offline-probe", Outcome: OutcomeWouldTry})) + Expect(fake.albumCalls).To(Equal(1)) + Expect(t.Steps()).To(ContainElement(TraceStep{Candidate: "external:probe", Outcome: OutcomeMiss})) }) It("records the local chain steps too", func() { - _, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, false).Resolve(context.Background(), model.KindAlbumArtwork, "al1") + _, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).Resolve(context.Background(), model.KindAlbumArtwork, "al1") Expect(err).ToNot(HaveOccurred()) last := t.Steps()[len(t.Steps())-1] - Expect(last.Candidate).To(Equal("embedded"), "the local chain must be traced, not just the external gate") + Expect(last.Candidate).To(Equal("embedded"), "the local chain must be traced, not just the external tier") Expect(last.Outcome).To(Equal(OutcomeHit)) }) It("never persists artwork state", func() { - _, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, false).Resolve(context.Background(), model.KindAlbumArtwork, "al1") + _, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).Resolve(context.Background(), model.KindAlbumArtwork, "al1") Expect(err).ToNot(HaveOccurred()) Expect(artworkRepo.ItemData).To(BeEmpty(), - "an offline resolution carries extError, which must never be recorded as a real provider failure") + "explain is read-only; a diagnostic walk must never become the stored answer") Expect(queueRepo.Data).To(BeEmpty()) }) It("resolves an artist without persisting anything", func() { - source, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, false).Resolve(context.Background(), model.KindArtistArtwork, "ar1") + source, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).Resolve(context.Background(), model.KindArtistArtwork, "ar1") Expect(err).ToNot(HaveOccurred()) Expect(source).To(BeEmpty()) - Expect(fake.artistCalls).To(BeZero()) - Expect(t.Steps()).To(ContainElement(TraceStep{Candidate: "external:offline-probe", Outcome: OutcomeWouldTry})) + Expect(fake.artistCalls).To(Equal(1)) Expect(artworkRepo.ItemData).To(BeEmpty()) Expect(queueRepo.Data).To(BeEmpty()) }) @@ -465,31 +493,39 @@ var _ = Describe("NewTracingResolver", func() { ID: "al2", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/no-such-file.mp3", FolderIDs: []string{"f1"}, }}) - source, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, false).Resolve(context.Background(), model.KindAlbumArtwork, "al2") + source, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).Resolve(context.Background(), model.KindAlbumArtwork, "al2") Expect(err).ToNot(HaveOccurred()) Expect(source).To(Equal("embedded")) Expect(ffm.IsClosed()).To(BeTrue(), "nothing downstream closes it, so a leak is one file handle per invocation") }) + // Serving falls back disc -> album and track -> disc -> album. The resolver does not, but + // if it ever did, an explain without --live would start calling providers uninvited. + It("cannot reach a provider without live, whatever the chain does", func() { + conf.Server.DiscArtPriority = "external, cover.*" + conf.Server.CoverArtPriority = "external, cover.*" + conf.Server.EnableMediaFileCoverArt = true + mfRepo := tests.CreateMockMediaFileRepo() + mfRepo.SetData(model.MediaFiles{{ID: "mf1", LibraryID: 0, HasCoverArt: true, + Path: "tests/fixtures/artist/an-album/test.mp3"}}) + ds.MockedMediaFile = mfRepo + offline := NewTracingResolver(ds, imageAgents(fake), ffm, t, false) + + _, err := offline.Resolve(context.Background(), model.KindDiscArtwork, "al1:1") + Expect(err).ToNot(HaveOccurred()) + _, err = offline.Resolve(context.Background(), model.KindMediaFileArtwork, "mf1") + Expect(err).ToNot(HaveOccurred()) + + Expect(fake.albumCalls).To(BeZero()) + Expect(fake.artistCalls).To(BeZero()) + }) + It("propagates a lookup error", func() { - _, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, false).Resolve(context.Background(), model.KindAlbumArtwork, "nope") + _, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).Resolve(context.Background(), model.KindAlbumArtwork, "nope") Expect(err).To(MatchError(model.ErrNotFound)) }) }) - - It("asks the agents when live is true", func() { - fake := &fakeImageAgent{name: "live-probe", err: agents.ErrNotFound} - albumRepo.SetData(model.Albums{{ - ID: "al1", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}, - }}) - - _, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).Resolve(context.Background(), model.KindAlbumArtwork, "al1") - - Expect(err).ToNot(HaveOccurred()) - Expect(fake.albumCalls).To(Equal(1)) - Expect(t.Steps()).To(ContainElement(TraceStep{Candidate: "external:live-probe", Outcome: OutcomeMiss})) - }) }) var _ = Describe("resolveDisc tracing", func() { diff --git a/core/artwork/worker.go b/core/artwork/worker.go index be8495305..6271e54bf 100644 --- a/core/artwork/worker.go +++ b/core/artwork/worker.go @@ -235,6 +235,8 @@ func (w *Worker) broadcastRefresh(ctx context.Context, found []model.ArtworkQueu func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outcome, *acquired) { item.ImageType = cmp.Or(item.ImageType, model.ImageTypePrimary) + trace := &ChainTrace{} + ctx = withTrace(ctx, trace) out, got := w.proc.acquire(ctx, item) queue := w.proc.ds.ArtworkQueue(ctx) @@ -247,10 +249,11 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc } case outcomeFoundStale, outcomeFailed: retryAt := time.Now().Add(backoff(item.Attempts)) + encoded := trace.encode("") 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 { + if err := queue.MarkFailedIfUnchanged(item.ItemKind, item.ItemID, item.ImageType, item.RetryAt, retryAt, encoded); 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, @@ -265,6 +268,9 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc writeAbsent(ctx, w.proc.ds.Artwork(ctx), item) settled = "recorded absent" } + // The queue row is about to go, taking the only record of the failure with it. This write is + // unconditional (not CAS-guarded) — safe only because the drain resolves each item serially. + w.recordGiveUp(ctx, item, encoded) 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 { @@ -274,6 +280,18 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc return out, got } +// recordGiveUp keeps the last failure on the state row after the queue row is deleted. An item +// that never resolved has no row to update, and creating one would settle it absent. +func (w *Worker) recordGiveUp(ctx context.Context, item model.ArtworkQueueItem, trace string) { + kind, ok := model.ParseKind(item.ItemKind) + if !ok { + return + } + if err := w.proc.ds.Artwork(ctx).PutLastFailure(kind, item.ItemID, item.ImageType, trace); err != nil { + log.Warn(ctx, "Artwork: Could not record the last failure", "kind", item.ItemKind, "id", item.ItemID, err) + } +} + func (w *Worker) hasResolvedArtwork(ctx context.Context, item model.ArtworkQueueItem) bool { kind, ok := model.ParseKind(item.ItemKind) if !ok { diff --git a/core/artwork/worker_test.go b/core/artwork/worker_test.go index 53b6a43b2..248e400e1 100644 --- a/core/artwork/worker_test.go +++ b/core/artwork/worker_test.go @@ -95,6 +95,17 @@ func (f *fakeEventBroker) getEvents() []events.Event { var _ events.Broker = (*fakeEventBroker)(nil) +// expireQueued ages a row past the retry budget, so the next drain settles it instead of retrying. +func expireQueued(q *tests.MockArtworkQueueRepo, id string) { + GinkgoHelper() + for k, v := range q.Data { + if v.ItemID == id { + v.EnqueuedAt = time.Now().Add(-(giveUpAfter + time.Hour)) + q.Data[k] = v + } + } +} + func findQueued(q *tests.MockArtworkQueueRepo, kind, id string) *model.ArtworkQueueItem { for _, it := range q.Data { if it.ItemKind == kind && it.ItemID == id { @@ -318,12 +329,7 @@ var _ = Describe("Worker", func() { 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 - } - } + expireQueued(queueRepo, "al9") n, err := w.drain(ctx, 1) Expect(err).ToNot(HaveOccurred()) @@ -345,12 +351,7 @@ var _ = Describe("Worker", func() { 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 - } - } + expireQueued(queueRepo, "al10") n, err := w.drain(ctx, 1) Expect(err).ToNot(HaveOccurred()) @@ -362,6 +363,67 @@ var _ = Describe("Worker", func() { Expect(ia.Hash).To(Equal("cafebabe"), "a persistent outage must not discard served art") }) + It("records on the queue row why the last attempt failed", func() { + conf.Server.CoverArtPriority = "external" + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al11", 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: "al11"})).To(Succeed()) + + _, err := w.drain(ctx, 1) + Expect(err).ToNot(HaveOccurred()) + + it := findQueued(queueRepo, "al", "al11") + Expect(it).ToNot(BeNil()) + Expect(DecodeTrace(it.Trace, "")).To(ContainElement(SatisfyAll( + HaveField("Candidate", "external:failAgent"), + HaveField("Outcome", OutcomeError), + HaveField("Detail", ContainSubstring("agent timed out")), + )), "a retrying row must say why it is retrying") + }) + + // The give-up path settles absent before recording, so the row exists by the time the + // failure is written. Recording first would silently lose it for every unresolved item. + It("keeps the failure for an item that never resolved at all", func() { + conf.Server.CoverArtPriority = "external" + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al13", 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: "al13"})).To(Succeed()) + expireQueued(queueRepo, "al13") + + _, err := w.drain(ctx, 1) + Expect(err).ToNot(HaveOccurred()) + + ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al13", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred(), "settling absent must create the row the failure is written to") + Expect(ia.Hash).To(BeEmpty()) + Expect(DecodeTrace(ia.LastFailure, "")).ToNot(BeEmpty()) + }) + + It("keeps the failure on the state row after the queue row is deleted", func() { + conf.Server.CoverArtPriority = "external" + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al12", Name: "Album"}}) + Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ + ItemKind: "al", ItemID: "al12", 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: "al12"})).To(Succeed()) + expireQueued(queueRepo, "al12") + + _, err := w.drain(ctx, 1) + Expect(err).ToNot(HaveOccurred()) + + Expect(findQueued(queueRepo, "al", "al12")).To(BeNil()) + ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al12", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(DecodeTrace(ia.LastFailure, "")).ToNot(BeEmpty(), + "the queue row is gone, so this is the only remaining record of the failure") + Expect(ia.Hash).To(Equal("cafebabe"), "recording the failure must not disturb the 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() { @@ -371,12 +433,7 @@ var _ = Describe("Worker", func() { {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 - } - } + expireQueued(queueRepo, "mfX") n, err := w.drain(ctx, 1) Expect(err).ToNot(HaveOccurred()) @@ -386,6 +443,8 @@ var _ = Describe("Worker", func() { _, 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") + // Known gap: with no row and no absent settle, there is nowhere to keep the failure. + // Creating one here would write an empty hash, which every reader treats as absent. }) It("resolves a private playlist under an admin context instead of failing forever", func() { diff --git a/db/migrations/20260819204637_add_artwork_trace_columns.sql b/db/migrations/20260819204637_add_artwork_trace_columns.sql new file mode 100644 index 000000000..90fbf9725 --- /dev/null +++ b/db/migrations/20260819204637_add_artwork_trace_columns.sql @@ -0,0 +1,9 @@ +-- +goose Up +ALTER TABLE item_artwork ADD COLUMN trace jsonb NOT NULL DEFAULT '[]'; +ALTER TABLE item_artwork ADD COLUMN last_failure jsonb NOT NULL DEFAULT '[]'; +ALTER TABLE artwork_queue ADD COLUMN trace jsonb NOT NULL DEFAULT '[]'; + +-- +goose Down +ALTER TABLE artwork_queue DROP COLUMN trace; +ALTER TABLE item_artwork DROP COLUMN last_failure; +ALTER TABLE item_artwork DROP COLUMN trace; diff --git a/model/artwork.go b/model/artwork.go index ea724265a..6107e9ffa 100644 --- a/model/artwork.go +++ b/model/artwork.go @@ -51,6 +51,10 @@ type ItemArtwork struct { 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"` + // Trace is the encoded walk that produced this state; LastFailure is the walk of the attempt + // that exhausted the retry budget. Both are JSON, read back with artwork.DecodeTrace. + Trace string `structs:"trace"` + LastFailure string `structs:"last_failure"` // 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"` @@ -91,6 +95,8 @@ type ArtworkQueueItem struct { Attempts int `structs:"attempts"` RetryAt time.Time `structs:"retry_at"` EnqueuedAt time.Time `structs:"enqueued_at"` + // Trace is why the last attempt failed. Only Get reads it; the drain projects it away. + Trace string `structs:"trace"` } // Queue priorities: higher drains first. @@ -109,6 +115,8 @@ type ArtworkRepository interface { PurgeOrphans(createdBefore time.Time) (int64, error) GetItemArtwork(kind Kind, id, imageType string) (*ItemArtwork, error) PutItemArtwork(ia *ItemArtwork) error + // PutLastFailure records the trace of the attempt that exhausted the retry budget. + PutLastFailure(kind Kind, id, imageType, trace string) error DeleteForItems(kind Kind, ids []string) error // GetInfoForItems hydrates a page in one batched query. GetInfoForItems(kind Kind, ids []string) (map[string]ItemArtworkInfo, error) @@ -145,7 +153,7 @@ type ArtworkQueueRepository interface { 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 + MarkFailedIfUnchanged(kind, id, imageType string, seenRetryAt, retryAt time.Time, trace string) 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) diff --git a/persistence/artwork_queue_repository.go b/persistence/artwork_queue_repository.go index 1ff754dc3..ba9fb6f1a 100644 --- a/persistence/artwork_queue_repository.go +++ b/persistence/artwork_queue_repository.go @@ -18,6 +18,7 @@ import ( const enqueueChunkSize = 100 // Every insert writes these, in this order; the INSERT..SELECT forms must project them to match. +// DequeueBatch also selects exactly these, to leave the drain's rows free of the trace it never reads. var enqueueColumns = []string{"item_kind", "item_id", "image_type", "priority", "attempts", "retry_at", "enqueued_at"} type artworkQueueRepository struct { @@ -42,11 +43,12 @@ func (r *artworkQueueRepository) Get(kind model.Kind, id, imageType string) (*mo return &res, nil } -// Enqueue also resets enqueued_at, so a fresh request does not inherit an old row's spent retry budget. +// Enqueue starts a fresh lifecycle: it resets enqueued_at (so a fresh request does not inherit an old +// row's spent retry budget) and clears trace (so explain does not show a prior failure at attempts 0). 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) + attempts = 0, enqueued_at = excluded.enqueued_at, trace = '[]'`, items) } func (r *artworkQueueRepository) EnqueuePreservingBackoff(items ...model.ArtworkQueueItem) error { @@ -159,7 +161,7 @@ func (r *artworkQueueRepository) enqueue(conflict string, items []model.ArtworkQ } func (r *artworkQueueRepository) DequeueBatch(n int, kinds ...string) ([]model.ArtworkQueueItem, error) { - sel := Select("*").From(r.tableName). + sel := Select(enqueueColumns...).From(r.tableName). Where(LtOrEq{"retry_at": time.Now()}). OrderBy("priority DESC", "enqueued_at ASC"). Limit(uint64(n)) @@ -171,10 +173,11 @@ func (r *artworkQueueRepository) DequeueBatch(n int, kinds ...string) ([]model.A return res, err } -func (r *artworkQueueRepository) MarkFailedIfUnchanged(kind, id, imageType string, seenRetryAt, retryAt time.Time) error { +func (r *artworkQueueRepository) MarkFailedIfUnchanged(kind, id, imageType string, seenRetryAt, retryAt time.Time, trace string) error { upd := Update(r.tableName). Set("attempts", Expr("attempts + 1")). Set("retry_at", retryAt). + Set("trace", trace). Where(Eq{"item_kind": kind, "item_id": id, "image_type": imageType, "retry_at": seenRetryAt}) _, err := r.executeSQL(upd) return err diff --git a/persistence/artwork_queue_repository_test.go b/persistence/artwork_queue_repository_test.go index d11d89a1f..84f3e2986 100644 --- a/persistence/artwork_queue_repository_test.go +++ b/persistence/artwork_queue_repository_test.go @@ -127,19 +127,42 @@ var _ = Describe("ArtworkQueueRepository", func() { 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()) + 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()) + Expect(repo.MarkFailedIfUnchanged("al", "m1", model.ImageTypePrimary, current, future, `[{"c":"read","o":"error"}]`)).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 clears a prior lifecycle's failure trace; EnqueuePreservingBackoff keeps it", func() { + // Fail an attempt so the queue row carries a failure trace. + Expect(repo.Enqueue(item("al", "t1", model.ArtworkPriorityScan))).To(Succeed()) + backOff("al", "t1", time.Now().Add(-time.Hour)) + got, _ := repo.DequeueBatch(10) + Expect(got).To(HaveLen(1)) + future := time.Now().Add(48 * time.Hour) + Expect(repo.MarkFailedIfUnchanged("al", "t1", model.ImageTypePrimary, got[0].RetryAt, future, `[{"c":"read","o":"error"}]`)).To(Succeed()) + + // A continuation of the same lifecycle must retain the trace. + Expect(repo.EnqueuePreservingBackoff(item("al", "t1", model.ArtworkPriorityBump))).To(Succeed()) + kept, err := repo.Get(model.KindAlbumArtwork, "t1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(kept.Trace).To(Equal(`[{"c":"read","o":"error"}]`)) + + // A fresh Enqueue resets attempts to 0, so the stale failure trace must be cleared with it. + Expect(repo.Enqueue(item("al", "t1", model.ArtworkPriorityScan))).To(Succeed()) + fresh, err := repo.Get(model.KindAlbumArtwork, "t1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(fresh.Attempts).To(BeZero()) + Expect(fresh.Trace).To(Equal("[]"), "a fresh lifecycle has no last-attempt trace") + }) + 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)) diff --git a/persistence/artwork_repository.go b/persistence/artwork_repository.go index 22662b575..89eb1d415 100644 --- a/persistence/artwork_repository.go +++ b/persistence/artwork_repository.go @@ -134,11 +134,21 @@ func (r *artworkRepository) PutItemArtwork(ia *model.ItemArtwork) error { } 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, + trace=excluded.trace, last_failure=excluded.last_failure, attempted_at=excluded.attempted_at, updated_at=excluded.updated_at`) _, err = r.items.executeSQL(ins) return err } +// PutLastFailure records why an item exhausted its retry budget. It only updates an existing row: +// inserting one would write an empty hash, which the rest of the system reads as a settled absent. +func (r *artworkRepository) PutLastFailure(kind model.Kind, id, imageType, trace string) error { + upd := Update(itemArtworkTable).Set("last_failure", trace). + Where(Eq{"item_kind": kind.Prefix(), "item_id": id, "image_type": imageType}) + _, err := r.items.executeSQL(upd) + 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 { diff --git a/persistence/artwork_repository_test.go b/persistence/artwork_repository_test.go index 683dc2d0f..a9687f76b 100644 --- a/persistence/artwork_repository_test.go +++ b/persistence/artwork_repository_test.go @@ -28,6 +28,51 @@ var _ = Describe("ArtworkRepository", func() { repo = NewArtworkRepository(context.Background(), GetDBXBuilder()) }) + Context("resolution traces", func() { + const traceJSON = `[{"c":"cover.*","o":"hit"}]` + + It("round-trips the trace with the state row", func() { + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "t1", + ImageType: model.ImageTypePrimary, Hash: "h1", Trace: traceJSON})).To(Succeed()) + + got, err := repo.GetItemArtwork(model.KindAlbumArtwork, "t1", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred()) + Expect(got.Trace).To(Equal(traceJSON)) + Expect(got.LastFailure).To(BeEmpty()) + }) + + It("replaces the trace when the item is resolved again", func() { + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "t2", + ImageType: model.ImageTypePrimary, Trace: traceJSON})).To(Succeed()) + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "t2", + ImageType: model.ImageTypePrimary, Trace: `[{"c":"embedded","o":"hit"}]`})).To(Succeed()) + + got, _ := repo.GetItemArtwork(model.KindAlbumArtwork, "t2", model.ImageTypePrimary) + Expect(got.Trace).To(Equal(`[{"c":"embedded","o":"hit"}]`)) + }) + + It("records a last failure on an existing row", func() { + Expect(repo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "t3", + ImageType: model.ImageTypePrimary, Hash: "h3"})).To(Succeed()) + + Expect(repo.PutLastFailure(model.KindAlbumArtwork, "t3", model.ImageTypePrimary, + `[{"c":"decode","o":"error"}]`)).To(Succeed()) + + got, _ := repo.GetItemArtwork(model.KindAlbumArtwork, "t3", model.ImageTypePrimary) + Expect(got.LastFailure).To(Equal(`[{"c":"decode","o":"error"}]`)) + Expect(got.Hash).To(Equal("h3"), "recording a failure must not disturb the served artwork") + }) + + // Inserting here would write hash='', which every reader treats as a settled absent. + It("never creates a row for an item that has no state", func() { + Expect(repo.PutLastFailure(model.KindAlbumArtwork, "ghost", model.ImageTypePrimary, + `[{"c":"decode","o":"error"}]`)).To(Succeed()) + + _, err := repo.GetItemArtwork(model.KindAlbumArtwork, "ghost", model.ImageTypePrimary) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + }) + 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"} diff --git a/tests/mock_artwork_queue_repo.go b/tests/mock_artwork_queue_repo.go index c8e915daa..f8f57e8d9 100644 --- a/tests/mock_artwork_queue_repo.go +++ b/tests/mock_artwork_queue_repo.go @@ -118,7 +118,7 @@ func (m *MockArtworkQueueRepo) DequeueBatch(n int, kinds ...string) ([]model.Art return res, nil } -func (m *MockArtworkQueueRepo) MarkFailedIfUnchanged(kind, id, imageType string, seenRetryAt, retryAt time.Time) error { +func (m *MockArtworkQueueRepo) MarkFailedIfUnchanged(kind, id, imageType string, seenRetryAt, retryAt time.Time, trace string) error { m.mu.Lock() defer m.mu.Unlock() if m.Err != nil { @@ -128,6 +128,7 @@ func (m *MockArtworkQueueRepo) MarkFailedIfUnchanged(kind, id, imageType string, if it, ok := m.Data[k]; ok && it.RetryAt.Equal(seenRetryAt) { it.Attempts++ it.RetryAt = retryAt + it.Trace = trace m.Data[k] = it } return nil diff --git a/tests/mock_artwork_repo.go b/tests/mock_artwork_repo.go index 2ace0daba..5d76a0169 100644 --- a/tests/mock_artwork_repo.go +++ b/tests/mock_artwork_repo.go @@ -122,6 +122,20 @@ func (m *MockArtworkRepo) GetItemArtwork(kind model.Kind, id, imageType string) return nil, model.ErrNotFound } +func (m *MockArtworkRepo) PutLastFailure(kind model.Kind, id, imageType, trace string) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.Err != nil { + return m.Err + } + key := iaKey(kind.Prefix(), id, imageType) + if ia, ok := m.ItemData[key]; ok { + ia.LastFailure = trace + m.ItemData[key] = ia + } + return nil +} + func (m *MockArtworkRepo) PutItemArtwork(ia *model.ItemArtwork) error { m.mu.Lock() defer m.mu.Unlock() From ffc68e29dbf53baf11ea6ae06d225a2ddea64e78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Fri, 21 Aug 2026 14:03:59 -0400 Subject: [PATCH 15/78] feat(cli): add `artwork cancel` to call off queued artwork work (#6006) * feat(cli): add `artwork cancel` to call off queued artwork work A bulk backfill had no off switch. Changing an artwork setting bumps the config fingerprint, which enqueues every entity in the library, and the only way to stop it was to turn agents off -- which changes the fingerprint again and enqueues a second full backfill. The escape hatch was the trap. `artwork cancel` deletes pending queue rows selected by --kind and/or --priority, with the --dry-run/confirm/-y flow `reprocess` already uses. Cancelling by priority is the point: it drops a runaway backfill while leaving the bump-priority rows an operator queued by hand. It only touches the queue. Resolved artwork and the item_artwork state behind `artwork explain` are left alone, and the trace of why a cancelled item last failed goes with its row. Preserving that trace would mean writing it to last_failure, which `explain` prints under "Gave up after" -- reporting a cancellation as an exhausted retry budget. The help text says the trace is discarded instead. Two limits the help text states, because neither is guessable: work already dequeued is not interrupted, and an item with no artwork state yet can be queued again by the hourly missing-artwork recheck. Cancel calls off queued work; it does not stop the worker. --kind validates against RefreshableKinds, not the RecheckKinds `reprocess` uses: the queue holds media file rows, so --all has to reach them. PurgeQueued follows the repository's naming rule -- it finds its own rows and reports how many went -- and ignores retry_at, since a row still backing off is pending work. The preview reuses CountByKindAndPriority rather than adding a counter. reprocessConfirm became confirmUnlessYes(yes, in, verb) now that two commands prompt. * refactor(cli): share the artwork queue filter between the preview and the delete Follow-up cleanup on the previous commit; no change to what the command does, apart from --all, noted below. The "which rows does cancel touch" predicate was written three times: once as SQL in PurgeQueued, once in Go in cmd's matchingQueueStats, and once more in the mock. The preview and the delete could therefore drift, and the mock would keep the tests green while they did. persistence now has one artworkQueueFilter, shared by PurgeQueued and a new CountQueued, and cmd does no filtering at all. That also makes the preview cheaper. It counted the whole queue and filtered in Go, so `artwork cancel --kind al` scanned every row of every kind to print a handful. CountQueued pushes the filter into SQL, which the drain index serves as a range seek. CountByKindAndPriority is gone: it is CountQueued(nil, nil). --all now selects with an empty filter instead of enumerating RefreshableKinds. It is what the flag help already claimed, and the enumeration was narrower than its own documentation -- a queue row whose item_kind this build does not know survived `--all` with no flag combination able to remove it. It also restores SQLite's truncate path: measured with EXPLAIN QUERY PLAN, a bare DELETE plans to nothing, while `WHERE (1=1)` -- which an empty squirrel And renders -- plans to a full index scan. A test pins the filter's emptiness so that cannot regress silently. Also folded together three copies of the parse-and-dedup loop (parseAll), two copies of the queue-stats table (printQueueStats, now shared with `artwork status`), two copies of the stat sum (queueTotal), and four copies of the kind-to-prefix mapping (model.KindPrefixes). The PurgeQueued specs became one DescribeTable that asserts count and delete agree on every selection. * docs(cli): say when `artwork cancel` evaluates its selection The help text covered the two limits that surprise an operator after the fact, but not the one that bites during the prompt: the count is a preview, and the filters run again on confirm. A scan or a manual refresh landing in between is cancelled without ever appearing in the table the operator agreed to. Deleting only the previewed rows was considered and rejected. The exposure is one item re-resolving on next view instead of immediately: clearing an item's artwork state is what every recovery path selects on, so a lost Bump row from artwork.Refresh comes back at the same priority via provisional() on the next request, and otherwise within the hour via EnqueueAllMissing. Buying a guarantee against that costs the truncate path on --all, the flag that exists for a 29k-item backfill. * refactor(cli): share one set of flag targets across the artwork subcommands reprocess and cancel each declared their own kinds/all/dry-run/yes variables, but cobra only ever parses the one subcommand being run, so the two sets could never hold values at the same time. backup.go already binds one backupDir across two subcommands and one force across two more; this follows that. Ten package-level variables become six. Each command keeps its own help string and its own valid-kind list, so --kind still reports RecheckKinds for reprocess and RefreshableKinds for cancel, and --source and --priority stay registered only on the command that has them. The priority lookup table is now knownPriorities, freeing the artworkPriorities name for the flag. The new name also reads better against priorityName's fallback for a value it does not know. --- cmd/artwork.go | 223 +++++++++++++++---- cmd/artwork_test.go | 168 +++++++++++++- model/artwork.go | 7 +- model/artwork_id.go | 7 + persistence/artwork_queue_repository.go | 40 +++- persistence/artwork_queue_repository_test.go | 71 +++++- tests/mock_artwork_queue_repo.go | 29 ++- 7 files changed, 479 insertions(+), 66 deletions(-) diff --git a/cmd/artwork.go b/cmd/artwork.go index 63f0d0917..b193abeaf 100644 --- a/cmd/artwork.go +++ b/cmd/artwork.go @@ -26,12 +26,14 @@ import ( var explainLive bool +// Only one subcommand runs per invocation, so reprocess and cancel bind the same flag targets. var ( - reprocessKinds []string - reprocessSources []string - reprocessAll bool - reprocessDryRun bool - reprocessYes bool + artworkKinds []string + artworkSources []string + artworkPriorities []string + artworkAll bool + artworkDryRun bool + artworkYes bool ) func init() { @@ -39,17 +41,26 @@ func init() { "walk the chain again now, performing real external lookups, instead of reporting the "+ "stored trace of the last resolution; also initializes plugin agents, which may open "+ "external connections") - artworkReprocessCmd.Flags().StringSliceVar(&reprocessKinds, "kind", nil, + artworkReprocessCmd.Flags().StringSliceVar(&artworkKinds, "kind", nil, "kinds to reprocess ("+kindPrefixes(artwork.RecheckKinds)+"); repeatable") - artworkReprocessCmd.Flags().StringSliceVar(&reprocessSources, "source", nil, + artworkReprocessCmd.Flags().StringSliceVar(&artworkSources, "source", nil, "only items currently resolved from these sources (e.g. folder, external:deezer, absent)") - artworkReprocessCmd.Flags().BoolVar(&reprocessAll, "all", false, "reprocess every kind") - artworkReprocessCmd.Flags().BoolVar(&reprocessDryRun, "dry-run", false, + artworkReprocessCmd.Flags().BoolVar(&artworkAll, "all", false, "reprocess every kind") + artworkReprocessCmd.Flags().BoolVar(&artworkDryRun, "dry-run", false, "report what would be queued and exit without queueing") - artworkReprocessCmd.Flags().BoolVarP(&reprocessYes, "yes", "y", false, "skip the confirmation prompt") + artworkReprocessCmd.Flags().BoolVarP(&artworkYes, "yes", "y", false, "skip the confirmation prompt") + artworkCancelCmd.Flags().StringSliceVar(&artworkKinds, "kind", nil, + "kinds to cancel ("+kindPrefixes(artwork.RefreshableKinds)+"); repeatable") + artworkCancelCmd.Flags().StringSliceVar(&artworkPriorities, "priority", nil, + "only rows queued at these priorities ("+priorityNames()+"); repeatable") + artworkCancelCmd.Flags().BoolVar(&artworkAll, "all", false, "cancel every kind at every priority") + artworkCancelCmd.Flags().BoolVar(&artworkDryRun, "dry-run", false, + "report what would be cancelled and exit without cancelling") + artworkCancelCmd.Flags().BoolVarP(&artworkYes, "yes", "y", false, "skip the confirmation prompt") artworkCmd.AddCommand(artworkExplainCmd) artworkCmd.AddCommand(artworkRefreshCmd) artworkCmd.AddCommand(artworkReprocessCmd) + artworkCmd.AddCommand(artworkCancelCmd) artworkCmd.AddCommand(artworkStatusCmd) rootCmd.AddCommand(artworkCmd) } @@ -93,6 +104,22 @@ var artworkReprocessCmd = &cobra.Command{ }, } +var artworkCancelCmd = &cobra.Command{ + Use: "cancel", + Short: "Cancel pending artwork work in bulk, by kind and/or queue priority", + Long: "Cancel pending artwork work in bulk, by kind and/or queue priority.\n\n" + + "Only the queue is touched: resolved artwork and the state behind `artwork explain` are\n" + + "left alone, and the trace of why a cancelled item last failed goes with its queue row.\n\n" + + "Work already picked up is not interrupted, and an item with no artwork yet can be\n" + + "queued again by the hourly re-check. The selection is applied again when you confirm,\n" + + "so anything queued after the preview is cancelled too. Use it to call off a bulk\n" + + "backfill, not to stop the worker.", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + runCancel(cmd.Context()) + }, +} + var artworkStatusCmd = &cobra.Command{ Use: "status", Short: "Report the artwork queue, where artwork resolves from, and the backfill state", @@ -133,9 +160,11 @@ type statusReport struct { current string } -func (r statusReport) queueTotal() int64 { +func (r statusReport) queueTotal() int64 { return queueTotal(r.queue) } + +func queueTotal(stats []model.ArtworkQueueStat) int64 { var n int64 - for _, s := range r.queue { + for _, s := range stats { n += s.Count } return n @@ -155,7 +184,7 @@ func collectStatus(ctx context.Context, ds model.DataStore) (statusReport, error q := ds.ArtworkQueue(ctx) var rep statusReport var err error - if rep.queue, err = q.CountByKindAndPriority(); err != nil { + if rep.queue, err = q.CountQueued(nil, nil); err != nil { return rep, fmt.Errorf("breaking the artwork queue down by kind: %w", err) } @@ -195,11 +224,7 @@ func formatStatus(rep statusReport) string { if len(rep.queue) == 0 { fmt.Fprintln(w, " (empty)") } else { - fmt.Fprintln(w, " KIND\tPRIORITY\tITEMS") - for _, s := range rep.queue { - fmt.Fprintf(w, " %s\t%s\t%d\n", kindName(s.ItemKind), priorityName(s.Priority), s.Count) - } - fmt.Fprintf(w, " TOTAL\t\t%d\n", rep.queueTotal()) + printQueueStats(w, rep.queue, rep.queueTotal(), "ITEMS", " ") } fmt.Fprintln(w, "\nSources") @@ -246,6 +271,15 @@ func backfillState(rep statusReport) string { return "up to date" } +// printQueueStats writes the shared queue breakdown; the caller owns the tab writer and flushes it. +func printQueueStats(w io.Writer, stats []model.ArtworkQueueStat, total int64, countHeader, indent string) { + fmt.Fprintf(w, "%sKIND\tPRIORITY\t%s\n", indent, countHeader) + for _, s := range stats { + fmt.Fprintf(w, "%s%s\t%s\t%d\n", indent, kindName(s.ItemKind), priorityName(s.Priority), s.Count) + } + fmt.Fprintf(w, "%sTOTAL\t\t%d\n", indent, total) +} + func kindName(prefix string) string { if k, ok := model.ParseKind(prefix); ok { return k.String() @@ -253,22 +287,44 @@ func kindName(prefix string) string { return prefix } +type artworkPriority struct { + name string + value int +} + +// knownPriorities is the one listing behind both the name and the parse, so they cannot drift. +var knownPriorities = []artworkPriority{ + {"bump", model.ArtworkPriorityBump}, + {"scan", model.ArtworkPriorityScan}, + {"backfill", model.ArtworkPriorityBackfill}, + {"recheck", model.ArtworkPriorityRecheck}, +} + +// priorityName falls back to the number: a row written by a newer version still has to print. func priorityName(p int) string { - switch p { - case model.ArtworkPriorityRecheck: - return "recheck" - case model.ArtworkPriorityBackfill: - return "backfill" - case model.ArtworkPriorityScan: - return "scan" - case model.ArtworkPriorityBump: - return "bump" + for _, ap := range knownPriorities { + if ap.value == p { + return ap.name + } } return strconv.Itoa(p) } +func priorityNames() string { + return strings.Join(slice.Map(knownPriorities, func(ap artworkPriority) string { return ap.name }), ", ") +} + +func parseArtworkPriority(s string) (int, error) { + for _, ap := range knownPriorities { + if ap.name == s { + return ap.value, nil + } + } + return 0, fmt.Errorf("invalid priority %q, expected one of: %s", s, priorityNames()) +} + func runReprocess(ctx context.Context) { - kinds, err := selectedKinds(reprocessKinds, reprocessSources, reprocessAll) + kinds, err := selectedKinds(artworkKinds, artworkSources, artworkAll) if err != nil { log.Fatal(ctx, err) } @@ -285,8 +341,8 @@ func runReprocess(ctx context.Context) { imageAgents = imageAgentCount(ds, mgr) } - if err := reprocessArtwork(ctx, ds, kinds, repositorySources(reprocessSources), imageAgents, - reprocessDryRun, reprocessConfirm(reprocessYes, os.Stdin), os.Stdout); err != nil { + if err := reprocessArtwork(ctx, ds, kinds, repositorySources(artworkSources), imageAgents, + artworkDryRun, confirmUnlessYes(artworkYes, os.Stdin, "re-resolve"), os.Stdout); err != nil { log.Fatal(ctx, err) } } @@ -299,16 +355,9 @@ func selectedKinds(kinds, sources []string, all bool) ([]model.Kind, error) { if len(kinds) == 0 { return nil, fmt.Errorf("no selector given: pass --kind, --source or --all") } - out := make([]model.Kind, 0, len(kinds)) - for _, k := range kinds { - kind, err := parseArtworkKind(k, artwork.RecheckKinds) - if err != nil { - return nil, err - } - out = append(out, kind) - } - // A repeated kind would be counted twice, overstating the cost the operator confirms. - return slice.Unique(out), nil + return parseAll(kinds, func(s string) (model.Kind, error) { + return parseArtworkKind(s, artwork.RecheckKinds) + }) } // absentSource is how the stored empty source — resolved, no image — is spelled on the CLI. @@ -327,11 +376,11 @@ func displaySource(s string) string { return cmp.Or(s, absentSource) } type confirmFunc func(out io.Writer, total, external int64) bool -func reprocessConfirm(yes bool, in io.Reader) confirmFunc { +func confirmUnlessYes(yes bool, in io.Reader, verb string) confirmFunc { if yes { return func(io.Writer, int64, int64) bool { return true } } - return promptConfirm(in) + return promptConfirm(in, verb) } // externalEstimate claims no bound: a local hit ends the walk before any agent is asked, and the @@ -379,13 +428,13 @@ func configuredAgents() []string { return names } -func promptConfirm(in io.Reader) confirmFunc { +func promptConfirm(in io.Reader, verb string) confirmFunc { return func(out io.Writer, total, external int64) bool { var cost string if external > 0 { cost = fmt.Sprintf(" %s", externalLookupLine(external)) } - fmt.Fprintf(out, "\nThis will re-resolve %d items.%s Continue? [y/N] ", total, cost) + fmt.Fprintf(out, "\nThis will %s %d items.%s Continue? [y/N] ", verb, total, cost) var answer string if _, err := fmt.Fscanln(in, &answer); err != nil { return false @@ -477,6 +526,90 @@ func reprocessArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kin return nil } +func runCancel(ctx context.Context) { + kinds, priorities, err := cancelSelection(artworkKinds, artworkPriorities, artworkAll) + if err != nil { + log.Fatal(ctx, err) + } + + defer db.Init(ctx)() + ds, ctx := getAdminContext(ctx) + + if err := cancelArtwork(ctx, ds, kinds, priorities, artworkDryRun, + confirmUnlessYes(artworkYes, os.Stdin, "cancel"), os.Stdout); err != nil { + log.Fatal(ctx, err) + } +} + +// cancelSelection leaves --all as the empty filter the repository reads as "every one", so a row +// whose kind this build does not know still gets cancelled. +func cancelSelection(kinds, priorities []string, all bool) ([]model.Kind, []int, error) { + if all { + return nil, nil, nil + } + if len(kinds) == 0 && len(priorities) == 0 { + return nil, nil, fmt.Errorf("no selector given: pass --kind, --priority or --all") + } + // RefreshableKinds, not RecheckKinds: media files are queued, so --kind must reach them. + outKinds, err := parseAll(kinds, func(s string) (model.Kind, error) { + return parseArtworkKind(s, artwork.RefreshableKinds) + }) + if err != nil { + return nil, nil, err + } + outPriorities, err := parseAll(priorities, parseArtworkPriority) + if err != nil { + return nil, nil, err + } + return outKinds, outPriorities, nil +} + +// parseAll drops repeats: a doubled selector would overstate the total the operator confirms. +func parseAll[T comparable](values []string, parse func(string) (T, error)) ([]T, error) { + out := make([]T, 0, len(values)) + for _, v := range values { + parsed, err := parse(v) + if err != nil { + return nil, err + } + out = append(out, parsed) + } + return slice.Unique(out), nil +} + +func cancelArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kind, priorities []int, + dryRun bool, confirm confirmFunc, out io.Writer) error { + q := ds.ArtworkQueue(ctx) + matched, err := q.CountQueued(kinds, priorities) + if err != nil { + return fmt.Errorf("counting queued artwork: %w", err) + } + total := queueTotal(matched) + w := newTabWriter(out) + printQueueStats(w, matched, total, "MATCHED", "") + w.Flush() + + switch { + case total == 0: + fmt.Fprintln(out, "\nNothing matches this selection.") + return nil + case dryRun: + fmt.Fprintln(out, "\nDry run: nothing was cancelled.") + return nil + case !confirm(out, total, 0): + fmt.Fprintln(out, "Aborted: nothing was cancelled.") + return nil + } + + cancelled, err := q.PurgeQueued(kinds, priorities) + if err != nil { + return fmt.Errorf("cancelling queued artwork: %w", err) + } + // Count and delete are separate statements, so a drain in between makes these two differ. + fmt.Fprintf(out, "Cancelled %d of %d matched items.\n", cancelled, total) + return nil +} + // printReprocessPreview also states the external estimate, which --dry-run must show because it // skips the prompt that would otherwise carry it. func printReprocessPreview(out io.Writer, kinds []model.Kind, matched []int64, total, external int64, sources []string) { @@ -542,7 +675,7 @@ var explainKinds = []model.Kind{ } func kindPrefixes(kinds []model.Kind) string { - return strings.Join(slice.Map(kinds, func(k model.Kind) string { return k.Prefix() }), ", ") + return strings.Join(model.KindPrefixes(kinds), ", ") } func parseArtworkKind(s string, valid []model.Kind) (model.Kind, error) { diff --git a/cmd/artwork_test.go b/cmd/artwork_test.go index 38a1b79cb..1d2bdae73 100644 --- a/cmd/artwork_test.go +++ b/cmd/artwork_test.go @@ -549,36 +549,36 @@ var _ = Describe("promptConfirm", func() { BeforeEach(func() { out.Reset() }) It("states the external cost and accepts an explicit yes", func() { - Expect(promptConfirm(strings.NewReader("y\n"))(&out, 42, 7)).To(BeTrue()) + Expect(promptConfirm(strings.NewReader("y\n"), "re-resolve")(&out, 42, 7)).To(BeTrue()) Expect(out.String()).To(ContainSubstring("re-resolve 42 items")) Expect(out.String()).To(ContainSubstring("External lookups: ~7 estimated")) }) It("defaults to no on anything else", func() { - Expect(promptConfirm(strings.NewReader("\n"))(&out, 1, 1)).To(BeFalse()) - Expect(promptConfirm(strings.NewReader("nope\n"))(&out, 1, 1)).To(BeFalse()) - Expect(promptConfirm(strings.NewReader(""))(&out, 1, 1)).To(BeFalse()) + Expect(promptConfirm(strings.NewReader("\n"), "re-resolve")(&out, 1, 1)).To(BeFalse()) + Expect(promptConfirm(strings.NewReader("nope\n"), "re-resolve")(&out, 1, 1)).To(BeFalse()) + Expect(promptConfirm(strings.NewReader(""), "re-resolve")(&out, 1, 1)).To(BeFalse()) }) It("drops the external clause when no lookup will be made", func() { - Expect(promptConfirm(strings.NewReader("y\n"))(&out, 3, 0)).To(BeTrue()) - Expect(out.String()).To(ContainSubstring("re-resolve 3 items.")) + Expect(promptConfirm(strings.NewReader("y\n"), "cancel")(&out, 3, 0)).To(BeTrue()) + Expect(out.String()).To(ContainSubstring("cancel 3 items.")) Expect(out.String()).ToNot(ContainSubstring("External lookups")) }) }) -var _ = Describe("reprocessConfirm", func() { +var _ = Describe("confirmUnlessYes", func() { var out strings.Builder BeforeEach(func() { out.Reset() }) It("prompts when --yes was not given", func() { - Expect(reprocessConfirm(false, strings.NewReader("n\n"))(&out, 5, 5)).To(BeFalse()) + Expect(confirmUnlessYes(false, strings.NewReader("n\n"), "re-resolve")(&out, 5, 5)).To(BeFalse()) Expect(out.String()).To(ContainSubstring("Continue?")) }) It("bypasses the prompt only for --yes", func() { - Expect(reprocessConfirm(true, strings.NewReader(""))(&out, 5, 5)).To(BeTrue()) + Expect(confirmUnlessYes(true, strings.NewReader(""), "re-resolve")(&out, 5, 5)).To(BeTrue()) Expect(out.String()).To(BeEmpty(), "--yes must not print a prompt it never reads") }) }) @@ -1059,3 +1059,153 @@ var _ = Describe("configuredAgents", func() { Expect(configuredAgents()).To(BeEmpty()) }) }) + +var _ = Describe("parseArtworkPriority", func() { + It("accepts every name status prints", func() { + for _, p := range []int{model.ArtworkPriorityRecheck, model.ArtworkPriorityBackfill, + model.ArtworkPriorityScan, model.ArtworkPriorityBump} { + Expect(parseArtworkPriority(priorityName(p))).To(Equal(p)) + } + }) + + It("rejects an unknown name and lists the valid ones", func() { + _, err := parseArtworkPriority("urgent") + Expect(err).To(MatchError(ContainSubstring(`invalid priority "urgent"`))) + Expect(err).To(MatchError(ContainSubstring("backfill"))) + }) + + // Accepting the raw numbers would make the help text a lie and let a typo like 11 select nothing. + It("rejects the numeric form", func() { + _, err := parseArtworkPriority("10") + Expect(err).To(HaveOccurred()) + }) +}) + +var _ = Describe("artwork cancel selection", func() { + It("errors when no selector is given", func() { + _, _, err := cancelSelection(nil, nil, false) + Expect(err).To(MatchError(ContainSubstring("no selector given"))) + }) + + // Empty, not an enumeration of the known kinds: --all must also take a queue row whose kind + // this build does not recognise. + It("selects with no filter at all for --all", func() { + kinds, priorities, err := cancelSelection(nil, nil, true) + Expect(err).ToNot(HaveOccurred()) + Expect(kinds).To(BeEmpty()) + Expect(priorities).To(BeEmpty()) + }) + + // The queue holds media file rows, so --all must reach them. + It("accepts media file artwork, which reprocess does not", func() { + kinds, _, err := cancelSelection([]string{"mf"}, nil, false) + Expect(err).ToNot(HaveOccurred()) + Expect(kinds).To(Equal([]model.Kind{model.KindMediaFileArtwork})) + }) + + It("treats a priority filter on its own as a complete selection", func() { + kinds, priorities, err := cancelSelection(nil, []string{"backfill"}, false) + Expect(err).ToNot(HaveOccurred()) + Expect(kinds).To(BeEmpty(), "no kind filter means every kind") + Expect(priorities).To(Equal([]int{model.ArtworkPriorityBackfill})) + }) + + It("returns only the named kinds and priorities", func() { + kinds, priorities, err := cancelSelection([]string{"ar", "al"}, []string{"backfill", "scan"}, false) + Expect(err).ToNot(HaveOccurred()) + Expect(kinds).To(Equal([]model.Kind{model.KindArtistArtwork, model.KindAlbumArtwork})) + Expect(priorities).To(Equal([]int{model.ArtworkPriorityBackfill, model.ArtworkPriorityScan})) + }) + + It("counts a repeated kind and a repeated priority once", func() { + kinds, priorities, err := cancelSelection([]string{"ar", "ar"}, []string{"bump", "bump"}, false) + Expect(err).ToNot(HaveOccurred()) + Expect(kinds).To(HaveLen(1)) + Expect(priorities).To(HaveLen(1)) + }) + + It("rejects an unknown kind", func() { + _, _, err := cancelSelection([]string{"zz"}, nil, false) + Expect(err).To(MatchError(ContainSubstring(`invalid kind "zz"`))) + }) + + It("rejects a kind that is never queued", func() { + _, _, err := cancelSelection([]string{"dc"}, nil, false) + Expect(err).To(MatchError(ContainSubstring("invalid kind"))) + }) + + It("rejects an unknown priority", func() { + _, _, err := cancelSelection(nil, []string{"urgent"}, false) + Expect(err).To(MatchError(ContainSubstring("invalid priority"))) + }) +}) + +var _ = Describe("cancelArtwork", func() { + var ds *tests.MockDataStore + var queue *tests.MockArtworkQueueRepo + var out strings.Builder + ctx := context.Background() + accept := func(io.Writer, int64, int64) bool { return true } + decline := func(io.Writer, int64, int64) bool { return false } + + BeforeEach(func() { + ds = &tests.MockDataStore{} + queue = ds.ArtworkQueue(ctx).(*tests.MockArtworkQueueRepo) + out.Reset() + Expect(queue.Enqueue( + model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-1", ImageType: model.ImageTypePrimary, + Priority: model.ArtworkPriorityBackfill}, + model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-2", ImageType: model.ImageTypePrimary, + Priority: model.ArtworkPriorityBump}, + model.ArtworkQueueItem{ItemKind: "al", ItemID: "al-1", ImageType: model.ImageTypePrimary, + Priority: model.ArtworkPriorityBackfill}, + )).To(Succeed()) + }) + + It("previews the per-kind breakdown and cancels nothing on a dry run", func() { + Expect(cancelArtwork(ctx, ds, []model.Kind{model.KindArtistArtwork}, nil, true, accept, &out)).To(Succeed()) + + Expect(out.String()).To(ContainSubstring("artist")) + Expect(out.String()).To(ContainSubstring("backfill")) + Expect(out.String()).To(ContainSubstring("TOTAL")) + Expect(out.String()).To(ContainSubstring("Dry run")) + Expect(queue.Count()).To(BeNumerically("==", 3)) + }) + + It("cancels nothing when the operator declines", func() { + Expect(cancelArtwork(ctx, ds, nil, nil, false, decline, &out)).To(Succeed()) + + Expect(out.String()).To(ContainSubstring("Aborted")) + Expect(queue.Count()).To(BeNumerically("==", 3)) + }) + + It("deletes the selected rows and leaves the rest queued", func() { + Expect(cancelArtwork(ctx, ds, nil, []int{model.ArtworkPriorityBackfill}, false, accept, &out)).To(Succeed()) + + Expect(queue.Count()).To(BeNumerically("==", 1)) + _, err := queue.Get(model.KindArtistArtwork, "ar-2", model.ImageTypePrimary) + Expect(err).ToNot(HaveOccurred(), "a non-matching priority must stay queued") + Expect(out.String()).To(ContainSubstring("Cancelled 2 of 2 matched items.")) + }) + + It("cancels every kind and priority when neither filter is given", func() { + Expect(cancelArtwork(ctx, ds, nil, nil, false, accept, &out)).To(Succeed()) + Expect(queue.Count()).To(BeZero()) + }) + + It("stops at a selection that matches nothing instead of prompting", func() { + refuse := func(io.Writer, int64, int64) bool { + Fail("must not prompt when nothing matches") + return false + } + Expect(cancelArtwork(ctx, ds, []model.Kind{model.KindPlaylistArtwork}, nil, false, refuse, &out)).To(Succeed()) + + Expect(out.String()).To(ContainSubstring("Nothing matches this selection.")) + Expect(queue.Count()).To(BeNumerically("==", 3)) + }) + + It("reports a queue read failure instead of reporting nothing to cancel", func() { + queue.Err = errors.New("read failed") + Expect(cancelArtwork(ctx, ds, nil, nil, false, accept, &out)).To(MatchError(ContainSubstring("read failed"))) + }) +}) diff --git a/model/artwork.go b/model/artwork.go index 6107e9ffa..b484b0500 100644 --- a/model/artwork.go +++ b/model/artwork.go @@ -157,13 +157,16 @@ type ArtworkQueueRepository interface { // 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) - // CountByKindAndPriority reports the pending queue rows grouped by kind and priority. - CountByKindAndPriority() ([]ArtworkQueueStat, error) + // CountQueued reports the pending rows matching the kinds and priorities, grouped by both; + // an empty filter means every one. + CountQueued(kinds []Kind, priorities []int) ([]ArtworkQueueStat, error) // CountAbsent reports the absent states of a kind, and how many of those EnqueueStaleAbsent // would pick up at the given cutoff. CountAbsent(kind Kind, attemptedBefore time.Time) (ArtworkAbsentStat, error) // PurgeDangling removes queue rows whose entity no longer exists. PurgeDangling() (int64, error) + // PurgeQueued removes pending rows matching the kinds and priorities; an empty filter means every one. + PurgeQueued(kinds []Kind, priorities []int) (int64, error) } type ArtworkQueueStat struct { diff --git a/model/artwork_id.go b/model/artwork_id.go index 634a6442f..e827e935a 100644 --- a/model/artwork_id.go +++ b/model/artwork_id.go @@ -6,6 +6,8 @@ import ( "strconv" "strings" "time" + + "github.com/navidrome/navidrome/utils/slice" ) type Kind struct { @@ -40,6 +42,11 @@ var artworkKindMap = map[string]Kind{ KindRadioArtwork.prefix: KindRadioArtwork, } +// KindPrefixes leaves the typed Kind domain for the item_kind column, or for a help string. +func KindPrefixes(kinds []Kind) []string { + return slice.Map(kinds, func(k Kind) string { return k.prefix }) +} + // 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) { diff --git a/persistence/artwork_queue_repository.go b/persistence/artwork_queue_repository.go index ba9fb6f1a..5db122a8b 100644 --- a/persistence/artwork_queue_repository.go +++ b/persistence/artwork_queue_repository.go @@ -191,19 +191,45 @@ func (r *artworkQueueRepository) PurgeDangling() (int64, error) { return purgeDangling(r.sqlRepository) } +// artworkQueueFilter returns no conditions for an empty filter, so an unfiltered DELETE keeps +// SQLite's truncate path. It ignores retry_at: a backing-off row is pending work too. +func artworkQueueFilter(kinds []model.Kind, priorities []int) And { + var f And + if len(kinds) > 0 { + f = append(f, Eq{"item_kind": model.KindPrefixes(kinds)}) + } + if len(priorities) > 0 { + f = append(f, Eq{"priority": priorities}) + } + return f +} + +// CountQueued shares its filter with PurgeQueued, so a preview cannot count rows the delete misses. +func (r *artworkQueueRepository) CountQueued(kinds []model.Kind, priorities []int) ([]model.ArtworkQueueStat, error) { + sel := Select("item_kind", "priority", "count(*) as count").From(r.tableName). + GroupBy("item_kind", "priority").OrderBy("item_kind", "priority desc") + if f := artworkQueueFilter(kinds, priorities); len(f) > 0 { + sel = sel.Where(f) + } + var res []model.ArtworkQueueStat + err := r.queryAll(sel, &res) + return res, err +} + +func (r *artworkQueueRepository) PurgeQueued(kinds []model.Kind, priorities []int) (int64, error) { + del := Delete(r.tableName) + if f := artworkQueueFilter(kinds, priorities); len(f) > 0 { + del = del.Where(f) + } + return r.executeSQL(del) +} + 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 } -func (r *artworkQueueRepository) CountByKindAndPriority() ([]model.ArtworkQueueStat, error) { - var res []model.ArtworkQueueStat - err := r.queryAll(Select("item_kind", "priority", "count(*) as count").From(r.tableName). - GroupBy("item_kind", "priority").OrderBy("item_kind", "priority desc"), &res) - return res, err -} - // CountAbsent matches EnqueueStaleAbsent on hash, so the stale count is what a recheck would queue. func (r *artworkQueueRepository) CountAbsent(kind model.Kind, attemptedBefore time.Time) (model.ArtworkAbsentStat, error) { var res model.ArtworkAbsentStat diff --git a/persistence/artwork_queue_repository_test.go b/persistence/artwork_queue_repository_test.go index 84f3e2986..35eebdd6d 100644 --- a/persistence/artwork_queue_repository_test.go +++ b/persistence/artwork_queue_repository_test.go @@ -411,7 +411,7 @@ var _ = Describe("ArtworkQueueRepository", func() { Expect(repo.Enqueue(item("ar", "a3", model.ArtworkPriorityBump))).To(Succeed()) Expect(repo.Enqueue(item("al", "b1", model.ArtworkPriorityScan))).To(Succeed()) - Expect(repo.CountByKindAndPriority()).To(ConsistOf( + Expect(repo.CountQueued(nil, nil)).To(ConsistOf( model.ArtworkQueueStat{ItemKind: "ar", Priority: model.ArtworkPriorityBackfill, Count: 2}, model.ArtworkQueueStat{ItemKind: "ar", Priority: model.ArtworkPriorityBump, Count: 1}, model.ArtworkQueueStat{ItemKind: "al", Priority: model.ArtworkPriorityScan, Count: 1}, @@ -419,7 +419,7 @@ var _ = Describe("ArtworkQueueRepository", func() { }) It("reports an empty queue as no rows", func() { - Expect(repo.CountByKindAndPriority()).To(BeEmpty()) + Expect(repo.CountQueued(nil, nil)).To(BeEmpty()) }) It("counts absent states and how many are due for recheck", func() { @@ -442,4 +442,71 @@ var _ = Describe("ArtworkQueueRepository", func() { Expect(repo.CountAbsent(model.KindRadioArtwork, time.Now())).To(Equal(model.ArtworkAbsentStat{})) }) }) + + Describe("PurgeQueued", func() { + queuedIDs := func() []string { + GinkgoHelper() + got, err := repo.DequeueBatch(100) + Expect(err).ToNot(HaveOccurred()) + return slice.Map(got, func(it model.ArtworkQueueItem) string { return it.ItemID }) + } + + BeforeEach(func() { + Expect(repo.Enqueue( + item("ar", "ar-backfill", model.ArtworkPriorityBackfill), + item("ar", "ar-bump", model.ArtworkPriorityBump), + item("al", "al-backfill", model.ArtworkPriorityBackfill), + item("mf", "mf-scan", model.ArtworkPriorityScan), + )).To(Succeed()) + }) + + // CountQueued feeds the preview and PurgeQueued does the delete; they share one filter, so + // every selection must count exactly what it deletes. + DescribeTable("selects the same rows to count and to delete", + func(kinds []model.Kind, priorities []int, deleted int, remaining []string) { + counted, err := repo.CountQueued(kinds, priorities) + Expect(err).ToNot(HaveOccurred()) + var total int64 + for _, s := range counted { + total += s.Count + } + Expect(total).To(BeNumerically("==", deleted), "the preview must match the delete") + + Expect(repo.PurgeQueued(kinds, priorities)).To(BeNumerically("==", deleted)) + Expect(queuedIDs()).To(ConsistOf(remaining)) + }, + Entry("only the given kinds", []model.Kind{model.KindArtistArtwork}, nil, + 2, []string{"al-backfill", "mf-scan"}), + Entry("only the given priorities", nil, []int{model.ArtworkPriorityBackfill}, + 2, []string{"ar-bump", "mf-scan"}), + Entry("the intersection of both", []model.Kind{model.KindArtistArtwork}, []int{model.ArtworkPriorityBackfill}, + 1, []string{"ar-bump", "al-backfill", "mf-scan"}), + Entry("everything, when neither filter is given", nil, nil, + 4, []string{}), + Entry("several kinds and priorities at once", + []model.Kind{model.KindArtistArtwork, model.KindMediaFileArtwork}, + []int{model.ArtworkPriorityBackfill, model.ArtworkPriorityScan}, + 2, []string{"ar-bump", "al-backfill"}), + Entry("nothing, leaving the queue alone", []model.Kind{model.KindPlaylistArtwork}, nil, + 0, []string{"ar-backfill", "ar-bump", "al-backfill", "mf-scan"}), + ) + + It("deletes a row that is still backing off", func() { + backOff("ar", "ar-bump", time.Now().Add(time.Hour)) + + Expect(repo.PurgeQueued([]model.Kind{model.KindArtistArtwork}, nil)).To(BeNumerically("==", 2)) + Expect(repo.Get(model.KindArtistArtwork, "ar-bump", model.ImageTypePrimary)). + Error().To(MatchError(model.ErrNotFound)) + }) + + // A WHERE clause, even one that matches everything, costs SQLite its truncate optimization + // and turns `artwork cancel --all` into a full scan of the queue. + It("adds no conditions at all for an empty filter", func() { + Expect(artworkQueueFilter(nil, nil)).To(BeEmpty()) + Expect(artworkQueueFilter([]model.Kind{model.KindArtistArtwork}, nil)).To(HaveLen(1)) + Expect(artworkQueueFilter(nil, []int{model.ArtworkPriorityBump})).To(HaveLen(1)) + Expect(artworkQueueFilter([]model.Kind{model.KindArtistArtwork}, []int{model.ArtworkPriorityBump})). + To(HaveLen(2)) + }) + }) }) diff --git a/tests/mock_artwork_queue_repo.go b/tests/mock_artwork_queue_repo.go index f8f57e8d9..70f7084ff 100644 --- a/tests/mock_artwork_queue_repo.go +++ b/tests/mock_artwork_queue_repo.go @@ -167,6 +167,30 @@ func (m *MockArtworkQueueRepo) PurgeDangling() (int64, error) { return purged, nil } +// queueFilterMatches mirrors artworkQueueFilter, so the mock cannot let a preview and a delete disagree. +func queueFilterMatches(it model.ArtworkQueueItem, kinds []model.Kind, priorities []int) bool { + prefixes := model.KindPrefixes(kinds) + return (len(prefixes) == 0 || slices.Contains(prefixes, it.ItemKind)) && + (len(priorities) == 0 || slices.Contains(priorities, it.Priority)) +} + +func (m *MockArtworkQueueRepo) PurgeQueued(kinds []model.Kind, priorities []int) (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 { + if !queueFilterMatches(it, kinds, priorities) { + continue + } + delete(m.Data, k) + purged++ + } + return purged, nil +} + func (m *MockArtworkQueueRepo) Count() (int64, error) { m.mu.Lock() defer m.mu.Unlock() @@ -176,7 +200,7 @@ func (m *MockArtworkQueueRepo) Count() (int64, error) { return int64(len(m.Data)), nil } -func (m *MockArtworkQueueRepo) CountByKindAndPriority() ([]model.ArtworkQueueStat, error) { +func (m *MockArtworkQueueRepo) CountQueued(kinds []model.Kind, priorities []int) ([]model.ArtworkQueueStat, error) { m.mu.Lock() defer m.mu.Unlock() if m.Err != nil { @@ -184,6 +208,9 @@ func (m *MockArtworkQueueRepo) CountByKindAndPriority() ([]model.ArtworkQueueSta } var res []model.ArtworkQueueStat for _, it := range m.Data { + if !queueFilterMatches(it, kinds, priorities) { + continue + } i := slices.IndexFunc(res, func(s model.ArtworkQueueStat) bool { return s.ItemKind == it.ItemKind && s.Priority == it.Priority }) From 07b6411c0bc3ff0c2aafe8c8cfac4d00f2a6ddcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Fri, 21 Aug 2026 15:23:07 -0400 Subject: [PATCH 16/78] perf(artwork): cap the stale-absent recheck at 100 items per kind per hour (#6007) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(artwork): drip the stale-absent recheck instead of bursting it daily Each hourly housekeeping tick now re-queues at most 100 absent states per kind, oldest attempts first, instead of everything older than 24h at once. External agents see a flat ~100 requests/hour per agent instead of hourly bursts of ~2,000, and the effective recheck interval self-scales with the size of the absent pool (~4 days at 10k absent artists) while small libraries keep the 24h floor. * feat(artwork): trust an absent artwork state for a week before rechecking With the recheck now dripped at 100 items per kind per hour, the 24h floor only governed small libraries, where the drip cap never binds; they still re-asked every agent daily. A 7-day floor cuts that cost 7x and, for large libraries, becomes the binding limit over the drip cycle (~5.7k calls/day instead of ~9.6k at 10k absent artists). Among comparable servers, this is still the second-most-eager recheck: gonic retries misses every 30 days, Jellyfin and Funkwhale never do. * refactor(artwork): state the drip's backpressure contract where it bites Review follow-ups: the recheck limit deliberately caps the *selection*, not the insertions — already-queued rows use up budget, so a stalled drain admits no new work instead of building a recovery burst. Say so in the interface doc, mirror it in the mock by truncating the sorted candidates (matching the SQL's LIMIT-before-ON CONFLICT), and teach `artwork status` and the worker doc the post-drip wording. Also pin the one cmd fixture that still assumed a 24h recheck window. --- cmd/artwork.go | 3 ++- cmd/artwork_test.go | 7 ++++--- core/artwork/housekeeping.go | 8 ++++++-- core/artwork/housekeeping_test.go | 19 +++++++++++++++++-- core/artwork/worker.go | 3 ++- model/artwork.go | 9 +++++---- persistence/artwork_queue_repository.go | 9 +++++---- persistence/artwork_queue_repository_test.go | 19 ++++++++++++++++++- tests/mock_artwork_queue_repo.go | 16 +++++++++++----- 9 files changed, 70 insertions(+), 23 deletions(-) diff --git a/cmd/artwork.go b/cmd/artwork.go index b193abeaf..01de071bb 100644 --- a/cmd/artwork.go +++ b/cmd/artwork.go @@ -238,7 +238,8 @@ func formatStatus(rep statusReport) string { for _, a := range rep.absent { fmt.Fprintf(w, " %s\t%d\t%d\n", a.kind, a.Total, a.Stale) } - fmt.Fprintf(w, " (rechecked once the last attempt is older than %gh)\n", artwork.StaleAbsentAge.Hours()) + fmt.Fprintf(w, " (eligible once the last attempt is older than %gh; re-queued %d per kind per hour, oldest first)\n", + artwork.StaleAbsentAge.Hours(), artwork.StaleAbsentRecheckBatch) fmt.Fprintln(w, "\nBackfill") fmt.Fprintf(w, " State:\t%s\n", backfillState(rep)) diff --git a/cmd/artwork_test.go b/cmd/artwork_test.go index 1d2bdae73..cc63ed86f 100644 --- a/cmd/artwork_test.go +++ b/cmd/artwork_test.go @@ -818,7 +818,7 @@ var _ = Describe("collectStatus", func() { ImageType: model.ImageTypePrimary, Source: source, Hash: hash, AttemptedAt: attempted})).To(Succeed()) } put(model.KindArtistArtwork, "ar-1", "external:deezer", "h1", time.Now()) - put(model.KindArtistArtwork, "ar-2", "", "", time.Now().Add(-48*time.Hour)) + put(model.KindArtistArtwork, "ar-2", "", "", time.Now().Add(-artwork.StaleAbsentAge-time.Hour)) put(model.KindArtistArtwork, "ar-3", "", "", time.Now()) put(model.KindAlbumArtwork, "al-1", "folder", "h2", time.Now()) Expect(queue.Enqueue(model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-9", @@ -909,8 +909,9 @@ var _ = Describe("formatStatus", func() { Expect(absent).To(MatchRegexp(`artist\s+2\s+1`)) }) - It("states the recheck window the absent counts are bucketed against", func() { - Expect(formatStatus(rep)).To(ContainSubstring("24h")) + It("states the recheck window and the drip rate the absent counts are bucketed against", func() { + Expect(formatStatus(rep)).To(ContainSubstring("168h")) + Expect(formatStatus(rep)).To(ContainSubstring("100 per kind per hour")) }) It("leads with the queued backlog, which is the finding, not with the fingerprint verdict", func() { diff --git a/core/artwork/housekeeping.go b/core/artwork/housekeeping.go index f2996d044..fed5757c8 100644 --- a/core/artwork/housekeeping.go +++ b/core/artwork/housekeeping.go @@ -18,7 +18,11 @@ import ( ) // StaleAbsentAge is how long an absent state is trusted before a recheck retries it. -const StaleAbsentAge = 24 * time.Hour +const StaleAbsentAge = 7 * 24 * time.Hour + +// StaleAbsentRecheckBatch caps how many absent states each hourly tick re-queues per kind, +// oldest first, so external agents see a flat drip instead of a daily burst. +const StaleAbsentRecheckBatch = 100 // RecheckKinds omits media files: they resolve embedded-only, at scan or on view. var RecheckKinds = []model.Kind{ @@ -125,7 +129,7 @@ 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 { + if _, err := queue.EnqueueStaleAbsent(kind, cutoff, StaleAbsentRecheckBatch); err != nil { return err } } diff --git a/core/artwork/housekeeping_test.go b/core/artwork/housekeeping_test.go index 4ea15ab04..4f229bff8 100644 --- a/core/artwork/housekeeping_test.go +++ b/core/artwork/housekeeping_test.go @@ -2,6 +2,7 @@ package artwork import ( "context" + "fmt" "slices" "time" @@ -235,8 +236,8 @@ var _ = Describe("Housekeeping", func() { }) 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) + old := time.Now().Add(-StaleAbsentAge - time.Hour) + recent := time.Now().Add(-StaleAbsentAge + 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} @@ -259,6 +260,20 @@ var _ = Describe("Housekeeping", func() { Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ar", "ar2")).To(BeNil()) Expect(findQueued(queueRepo.MockArtworkQueueRepo, "al", "al2")).To(BeNil()) }) + + It("caps each tick at the recheck batch, oldest attempts first", func() { + for i := range StaleAbsentRecheckBatch + 1 { + id := fmt.Sprintf("ar%d", i) + artRepo.ItemData[id] = model.ItemArtwork{ItemKind: "ar", ItemID: id, ImageType: model.ImageTypePrimary, + Hash: "", AttemptedAt: time.Now().Add(-StaleAbsentAge - time.Duration(i+1)*time.Minute)} + } + + Expect(enqueueStaleAbsentAll(ctx, ds)).To(Succeed()) + + Expect(queueRepo.Data).To(HaveLen(StaleAbsentRecheckBatch)) + // ar0 has the newest attempted_at of the cohort, so it is the one left out. + Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ar", "ar0")).To(BeNil()) + }) }) Describe("EnqueueMissingAll", func() { diff --git a/core/artwork/worker.go b/core/artwork/worker.go index 6271e54bf..0f947f6d7 100644 --- a/core/artwork/worker.go +++ b/core/artwork/worker.go @@ -137,7 +137,8 @@ func (w *Worker) Backfill(ctx context.Context) (bool, error) { return backfill(ctx, w.proc.ds) } -// EnqueueStaleAbsentAll requeues known-absent entries older than StaleAbsentAge. +// EnqueueStaleAbsentAll requeues known-absent entries older than StaleAbsentAge, at most +// StaleAbsentRecheckBatch per kind, oldest first. func (w *Worker) EnqueueStaleAbsentAll(ctx context.Context) error { return enqueueStaleAbsentAll(ctx, w.proc.ds) } diff --git a/model/artwork.go b/model/artwork.go index b484b0500..3c0df209b 100644 --- a/model/artwork.go +++ b/model/artwork.go @@ -134,8 +134,9 @@ type ArtworkQueueRepository interface { // 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) + // EnqueueStaleAbsent inserts queue rows (priority Recheck) for absent states older than cutoff, oldest + // first; limit caps the selection, so already-queued rows use up budget (backpressure when the drain stalls). + EnqueueStaleAbsent(kind Kind, attemptedBefore time.Time, limit int) (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. @@ -160,8 +161,8 @@ type ArtworkQueueRepository interface { // CountQueued reports the pending rows matching the kinds and priorities, grouped by both; // an empty filter means every one. CountQueued(kinds []Kind, priorities []int) ([]ArtworkQueueStat, error) - // CountAbsent reports the absent states of a kind, and how many of those EnqueueStaleAbsent - // would pick up at the given cutoff. + // CountAbsent reports the absent states of a kind, and how many are past the given cutoff, + // eligible for EnqueueStaleAbsent (which drains them limit rows per call). CountAbsent(kind Kind, attemptedBefore time.Time) (ArtworkAbsentStat, error) // PurgeDangling removes queue rows whose entity no longer exists. PurgeDangling() (int64, error) diff --git a/persistence/artwork_queue_repository.go b/persistence/artwork_queue_repository.go index 5db122a8b..1c0077fc7 100644 --- a/persistence/artwork_queue_repository.go +++ b/persistence/artwork_queue_repository.go @@ -56,11 +56,12 @@ func (r *artworkQueueRepository) EnqueuePreservingBackoff(items ...model.Artwork priority = MAX(priority, excluded.priority)`, items) } -func (r *artworkQueueRepository) EnqueueStaleAbsent(kind model.Kind, attemptedBefore time.Time) (int64, error) { +func (r *artworkQueueRepository) EnqueueStaleAbsent(kind model.Kind, attemptedBefore time.Time, limit int) (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) + FROM `+itemArtworkTable+` WHERE item_kind = ? AND hash = '' AND attempted_at < ? + ORDER BY attempted_at LIMIT ?`, + model.ArtworkPriorityRecheck, now, now, kind.Prefix(), attemptedBefore, limit) } func (r *artworkQueueRepository) EnqueueAllMissing(kind model.Kind, priority int) (int64, error) { @@ -230,7 +231,7 @@ func (r *artworkQueueRepository) Count() (int64, error) { return res.Count, err } -// CountAbsent matches EnqueueStaleAbsent on hash, so the stale count is what a recheck would queue. +// CountAbsent matches EnqueueStaleAbsent on hash, so the stale count is the pool a recheck drains from. func (r *artworkQueueRepository) CountAbsent(kind model.Kind, attemptedBefore time.Time) (model.ArtworkAbsentStat, error) { var res model.ArtworkAbsentStat err := r.queryOne(Select("count(*) as total"). diff --git a/persistence/artwork_queue_repository_test.go b/persistence/artwork_queue_repository_test.go index 35eebdd6d..1673a5b0e 100644 --- a/persistence/artwork_queue_repository_test.go +++ b/persistence/artwork_queue_repository_test.go @@ -244,7 +244,7 @@ var _ = Describe("ArtworkQueueRepository", func() { 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)) + n, err := repo.EnqueueStaleAbsent(model.KindArtistArtwork, time.Now().Add(-24*time.Hour), 100) Expect(err).ToNot(HaveOccurred()) Expect(n).To(Equal(int64(1))) @@ -255,6 +255,23 @@ var _ = Describe("ArtworkQueueRepository", func() { Expect(items[0].Priority).To(Equal(model.ArtworkPriorityRecheck)) }) + It("enqueues only the oldest stale absent states up to the limit", func() { + awRepo := NewArtworkRepository(context.Background(), GetDBXBuilder()) + now := time.Now() + Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "oldest", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: now.Add(-72 * time.Hour)})).To(Succeed()) + Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "older", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: now.Add(-60 * time.Hour)})).To(Succeed()) + Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "old", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: now.Add(-48 * time.Hour)})).To(Succeed()) + + n, err := repo.EnqueueStaleAbsent(model.KindArtistArtwork, now.Add(-24*time.Hour), 2) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(int64(2))) + + items, err := repo.DequeueBatch(10) + Expect(err).ToNot(HaveOccurred()) + ids := slice.Map(items, func(it model.ArtworkQueueItem) string { return it.ItemID }) + Expect(ids).To(ConsistOf("oldest", "older")) + }) + 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()) diff --git a/tests/mock_artwork_queue_repo.go b/tests/mock_artwork_queue_repo.go index 70f7084ff..51ddf4b61 100644 --- a/tests/mock_artwork_queue_repo.go +++ b/tests/mock_artwork_queue_repo.go @@ -272,18 +272,24 @@ func (m *MockArtworkQueueRepo) EnqueuePreservingBackoff(items ...model.ArtworkQu return nil } -func (m *MockArtworkQueueRepo) EnqueueStaleAbsent(kind model.Kind, attemptedBefore time.Time) (int64, error) { +func (m *MockArtworkQueueRepo) EnqueueStaleAbsent(kind model.Kind, attemptedBefore time.Time, limit int) (int64, error) { m.mu.Lock() defer m.mu.Unlock() if m.Err != nil || m.ItemArtworkSource == nil { return 0, m.Err } + var stale []model.ItemArtwork + for _, ia := range m.ItemArtworkSource.ItemData { + if ia.ItemKind == kind.Prefix() && ia.Hash == "" && ia.AttemptedAt.Before(attemptedBefore) { + stale = append(stale, ia) + } + } + slices.SortFunc(stale, func(a, b model.ItemArtwork) int { return a.AttemptedAt.Compare(b.AttemptedAt) }) + // The limit caps the selection, like the SQL's LIMIT before ON CONFLICT: queued rows use up budget. + stale = stale[:min(limit, len(stale))] now := time.Now() var inserted int64 - for _, ia := range m.ItemArtworkSource.ItemData { - if ia.ItemKind != kind.Prefix() || ia.Hash != "" || !ia.AttemptedAt.Before(attemptedBefore) { - continue - } + for _, ia := range stale { k := iaKey(ia.ItemKind, ia.ItemID, ia.ImageType) if _, ok := m.Data[k]; ok { // DO NOTHING: never touch existing queue rows continue From 295886cb9a432d375afa796ed8a32db0775464af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Fri, 21 Aug 2026 20:27:42 -0400 Subject: [PATCH 17/78] feat(artwork): report what a config-fingerprint backfill enqueued (#6010) * feat(artwork): report what a config-fingerprint backfill enqueued A backfill re-resolves every entity, and on a large library that is tens of thousands of external agent calls. It announced itself with a single line carrying nothing but an elapsed time, so the size of the job was invisible until the request volume showed up hours later. Log the item count, the per-kind breakdown, and a ceiling on the external lookups the queued work can cost. The ceiling reuses ExternalLookupsPerItem, the same estimator behind the `artwork reprocess` preview, so the two agree on what an item can cost. backfill now returns a summary instead of a bare bool, which keeps the counts assertable without capturing log output. Worker.Backfill keeps its (bool, error) signature, so its caller is unchanged, and it reads the agent count off its own resolver. * refactor(artwork): share the image-agent count and take it lazily Counting image agents was written twice, once in the CLI for the `artwork reprocess` preview and again as a resolver method for the backfill log. Two copies of "which agents count as image agents" can drift, and the CLI estimate and the server log would then disagree silently. Move the derivation next to the type it builds, as NewImageAgentCount, and call it from both. The resolver method goes away with it: hanging the census on the resolver forced two nil guards that its only caller could never trigger, because Worker always builds a resolver with agents. The Worker keeps the *agents.Agents it is already handed instead of reaching through the processor and resolver to find it. Pass the count as a func. Building the agent list constructs every enabled agent (each one an HTTP client and a cache goroutine) only to take its length, and a backfill returns early on an unchanged fingerprint, which is what happens on nearly every restart. * docs(artwork): say what the backfill lookup estimate does not bound The comment called the number a ceiling, which the CLI comment on the same estimate already contradicts: externalEstimate "claims no bound". Both are right about the local-source case and only one of them mentions that a retried item asks its agents again. --- cmd/artwork.go | 7 +----- core/artwork/housekeeping.go | 35 ++++++++++++++++++++------- core/artwork/housekeeping_test.go | 39 ++++++++++++++++++++++++------- core/artwork/resolve.go | 9 +++++++ core/artwork/worker.go | 7 ++++-- 5 files changed, 73 insertions(+), 24 deletions(-) diff --git a/cmd/artwork.go b/cmd/artwork.go index 01de071bb..8b9e28f0e 100644 --- a/cmd/artwork.go +++ b/cmd/artwork.go @@ -339,7 +339,7 @@ func runReprocess(ctx context.Context) { if needsImageAgents(kinds) { mgr := loadPluginAgents(ctx, false) defer func() { _ = mgr.Stop() }() - imageAgents = imageAgentCount(ds, mgr) + imageAgents = artwork.NewImageAgentCount(agents.GetAgents(ds, mgr)) } if err := reprocessArtwork(ctx, ds, kinds, repositorySources(artworkSources), imageAgents, @@ -397,11 +397,6 @@ func externalLookupLine(n int64) string { return fmt.Sprintf("External lookups: %s.", externalEstimate(n)) } -func imageAgentCount(ds model.DataStore, mgr *plugins.Manager) artwork.ImageAgentCount { - ag := agents.GetAgents(ds, mgr) - return artwork.ImageAgentCount{Artist: len(ag.ArtistImageAgents()), Album: len(ag.AlbumImageAgents())} -} - // loadPluginAgents loads the plugins named in Agents, so the CLI resolves through the same agents a // running server would. A load failure is reported, not fatal: the built-in agents still answer. func loadPluginAgents(ctx context.Context, runInit bool) *plugins.Manager { diff --git a/core/artwork/housekeeping.go b/core/artwork/housekeeping.go index fed5757c8..9456a5584 100644 --- a/core/artwork/housekeeping.go +++ b/core/artwork/housekeeping.go @@ -72,18 +72,27 @@ func ConfigFingerprint() string { return fmt.Sprintf("%016x", xxh3.Hash([]byte(raw))) } +// backfillSummary is what a backfill enqueued. MaxExternalLookups is an upper estimate for one +// attempt per item, not a bound: a local hit ends the walk, and a retry asks the agents again. +type backfillSummary struct { + Ran bool + PerKind map[string]int64 + Items int64 + MaxExternalLookups int64 +} + // backfill enqueues artwork resolution for every entity when the config fingerprint changed. -func backfill(ctx context.Context, ds model.DataStore) (bool, error) { +func backfill(ctx context.Context, ds model.DataStore, agentCount func() ImageAgentCount) (backfillSummary, error) { start := time.Now() ctx = auth.WithAdminUser(ctx, ds) current := ConfigFingerprint() props := ds.Property(ctx) stored, err := props.DefaultGet(consts.ArtConfFingerprintPropertyKey, "") if err != nil { - return false, err + return backfillSummary{}, err } if stored == current { - return false, nil + return backfillSummary{}, nil } // Artists first: few entities, most external-dependent, so they get a queue headstart. @@ -96,21 +105,31 @@ func backfill(ctx context.Context, ds model.DataStore) (bool, error) { {model.KindPlaylistArtwork, func() ([]string, error) { return ds.Playlist(ctx).GetAllIDs() }}, {model.KindRadioArtwork, func() ([]string, error) { return ds.Radio(ctx).GetAllIDs() }}, } + // Counted here, not by the caller: building the agent list constructs every enabled agent, and + // an unchanged fingerprint returns above without ever needing the number. + agents := agentCount() + summary := backfillSummary{Ran: true, PerKind: map[string]int64{}} for _, k := range kinds { ids, err := k.fetch() if err != nil { - return false, err + return backfillSummary{}, err } if err := enqueueBackfillKind(ctx, ds, k.kind, ids); err != nil { - return false, err + return backfillSummary{}, err } + n := int64(len(ids)) + summary.PerKind[k.kind.Prefix()] = n + summary.Items += n + summary.MaxExternalLookups += n * ExternalLookupsPerItem(k.kind, agents) } if err := props.Put(consts.ArtConfFingerprintPropertyKey, current); err != nil { - return false, err + return backfillSummary{}, err } - log.Info(ctx, "Artwork: Config fingerprint changed, backfill enqueued", "elapsed", time.Since(start)) - return true, nil + log.Info(ctx, "Artwork: Config fingerprint changed, backfill enqueued", "items", summary.Items, + "byKind", summary.PerKind, "maxExternalLookups", summary.MaxExternalLookups, + "elapsed", time.Since(start)) + return summary, nil } func enqueueBackfillKind(ctx context.Context, ds model.DataStore, kind model.Kind, ids []string) error { diff --git a/core/artwork/housekeeping_test.go b/core/artwork/housekeeping_test.go index 4f229bff8..7aecd2760 100644 --- a/core/artwork/housekeeping_test.go +++ b/core/artwork/housekeeping_test.go @@ -39,6 +39,8 @@ func adminUserRepo() *tests.MockedUserRepo { return repo } +func noAgents() ImageAgentCount { return ImageAgentCount{} } + // 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 { @@ -164,9 +166,14 @@ var _ = Describe("Housekeeping", func() { seedEntities() Expect(propRepo.Put(consts.ArtConfFingerprintPropertyKey, ConfigFingerprint())).To(Succeed()) - did, err := backfill(ctx, ds) + counted := false + s, err := backfill(ctx, ds, func() ImageAgentCount { + counted = true + return ImageAgentCount{Artist: 3, Album: 2} + }) Expect(err).ToNot(HaveOccurred()) - Expect(did).To(BeFalse()) + Expect(s).To(Equal(backfillSummary{})) + Expect(counted).To(BeFalse(), "building the agent list constructs every agent; an unchanged fingerprint must not pay for it") count, err := queueRepo.Count() Expect(err).ToNot(HaveOccurred()) @@ -176,9 +183,9 @@ var _ = Describe("Housekeeping", func() { It("runs the backfill when no fingerprint was ever stored", func() { seedEntities() - did, err := backfill(ctx, ds) + s, err := backfill(ctx, ds, noAgents) Expect(err).ToNot(HaveOccurred()) - Expect(did).To(BeTrue()) + Expect(s.Ran).To(BeTrue()) count, err := queueRepo.Count() Expect(err).ToNot(HaveOccurred()) @@ -197,9 +204,9 @@ var _ = Describe("Housekeeping", func() { tracks: &tests.MockPlaylistTrackRepo{}, } - did, err := backfill(ctx, vds) + s, err := backfill(ctx, vds, noAgents) Expect(err).ToNot(HaveOccurred()) - Expect(did).To(BeTrue()) + Expect(s.Ran).To(BeTrue()) Expect(findQueued(queueRepo.MockArtworkQueueRepo, "pl", "plPrivate")).ToNot(BeNil()) }) @@ -207,9 +214,9 @@ var _ = Describe("Housekeeping", func() { seedEntities() Expect(propRepo.Put(consts.ArtConfFingerprintPropertyKey, "stale-fingerprint")).To(Succeed()) - did, err := backfill(ctx, ds) + s, err := backfill(ctx, ds, noAgents) Expect(err).ToNot(HaveOccurred()) - Expect(did).To(BeTrue()) + Expect(s.Ran).To(BeTrue()) Expect(queueRepo.callKinds).ToNot(BeEmpty()) firstOther := slices.IndexFunc(queueRepo.callKinds, func(k string) bool { return k != "ar" }) @@ -224,6 +231,22 @@ var _ = Describe("Housekeeping", func() { Expect(it.ItemKind).To(BeElementOf("ar", "al", "pl", "ra")) } }) + + It("reports what it enqueued, per kind and as an external-lookup ceiling", func() { + conf.Server.ArtistArtPriority = "artist.*, external" + conf.Server.CoverArtPriority = "cover.*, external" + conf.Server.EnableM3UExternalAlbumArt = false + seedEntities() + + s, err := backfill(ctx, ds, func() ImageAgentCount { return ImageAgentCount{Artist: 3, Album: 2} }) + Expect(err).ToNot(HaveOccurred()) + Expect(s.Ran).To(BeTrue()) + + Expect(s.PerKind).To(Equal(map[string]int64{"ar": 2, "al": 1, "pl": 1, "ra": 1})) + Expect(s.Items).To(Equal(int64(5))) + // 2 artists x 3 agents, 1 album x 2, 1 playlist grid x 2, and radios never fetch. + Expect(s.MaxExternalLookups).To(Equal(int64(6 + 2 + PlaylistGridSamples*2))) + }) }) Describe("EnqueueStaleAbsentAll", func() { diff --git a/core/artwork/resolve.go b/core/artwork/resolve.go index f42beb9f1..6663679fa 100644 --- a/core/artwork/resolve.go +++ b/core/artwork/resolve.go @@ -137,6 +137,15 @@ func MayFetchExternal(kind model.Kind) bool { // ImageAgentCount is how many enabled agents provide artist and album images. type ImageAgentCount struct{ Artist, Album int } +// NewImageAgentCount counts what an external step would consult, so an estimate and the gate that +// guards it cannot disagree about which agents exist. +func NewImageAgentCount(ag *agents.Agents) ImageAgentCount { + if ag == nil { + return ImageAgentCount{} + } + return ImageAgentCount{Artist: len(ag.ArtistImageAgents()), Album: len(ag.AlbumImageAgents())} +} + // ExternalLookupsPerItem reports what resolving one item of this kind can cost: every image agent is // tried, and a zero count still bills one, so agents the caller cannot see never read as free. func ExternalLookupsPerItem(kind model.Kind, agents ImageAgentCount) int64 { diff --git a/core/artwork/worker.go b/core/artwork/worker.go index 0f947f6d7..0358708c0 100644 --- a/core/artwork/worker.go +++ b/core/artwork/worker.go @@ -40,6 +40,7 @@ type drainPool struct { // independently, and pruneMu serializes prune against the store-write window. type Worker struct { proc *processor + agents *agents.Agents cache cache.FileCache ffmpeg ffmpeg.FFmpeg broker events.Broker @@ -54,6 +55,7 @@ type Worker struct { 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}, + agents: ag, cache: imgCache, ffmpeg: ffmpeg, broker: broker, @@ -132,9 +134,10 @@ func (w *Worker) RunPrune(ctx context.Context) error { } // Backfill enqueues every entity for re-resolution when the artwork config fingerprint changed, -// artists first. It reports whether anything was enqueued. +// artists first. It reports whether the backfill ran. func (w *Worker) Backfill(ctx context.Context) (bool, error) { - return backfill(ctx, w.proc.ds) + s, err := backfill(ctx, w.proc.ds, func() ImageAgentCount { return NewImageAgentCount(w.agents) }) + return s.Ran, err } // EnqueueStaleAbsentAll requeues known-absent entries older than StaleAbsentAge, at most From 59810c3d59af724f46c73fd8add72ec5cde1776e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 22 Aug 2026 20:36:24 -0400 Subject: [PATCH 18/78] feat(jellyfin): non-expiring, audience-scoped tokens revocable by password change (#6013) * feat(auth): add per-user token_epoch column and bump method * feat(auth): add aud and ep claims, omitted when zero * feat(auth): add CreateAPIToken for non-expiring, audience-scoped tokens * feat(auth): add CheckClaims for epoch and audience validation * feat(jellyfin): issue non-expiring, jellyfin-scoped access tokens * fix(subsonic): reject API-scoped and revoked tokens on the jwt path * fix(server): reject API-scoped and revoked tokens on the native API * fix(server): pin the token-subject guard and stop leaking test config Adds a regression spec for the DevAutoLogin/ExtAuth guard in tokenAllowed, switches its comparison to case-insensitive to match the user lookup's own COLLATE NOCASE semantics, and restores Subsonic JWT test config after each spec instead of leaking SessionTimeout. * feat(request): add a token epoch holder for handler-to-middleware signalling * refactor(server): write the refreshed JWT header after the handler runs * feat(auth): revoke all tokens for a user when their password changes * fix(server): restore Unwrap on the JWT refresh writer so SSE write deadlines apply * test(auth): pin that non-session tokens reject API access tokens * test(jellyfin): pin token scoping and epoch revocation end to end Exercises auth.CreateAPIToken and CheckClaims against the real Jellyfin router and SQLite DB: the minted token has no exp and is aud-scoped to jellyfin, and bumping token_epoch through the real UserRepository revokes an already-issued token on the next protected request. * test(nativeapi): pin the token-epoch handoff through a real password-change request Drive a self password change through the real Authenticator/JWTRefresher chain and a real SQLite-backed userRepository, so the epoch handoff between Put and the refreshed-token writer is verified end to end, not as two separately-tested halves. Also fix tokenAllowed to read the enriched ctx it was given instead of r.Context(), so its warning log carries the username. * refactor(server): drop tokenAllowed's now-unused request parameter Finding-2 already moved every use to ctx; r was dead weight. Also note in the new nativeapi test why it must stay the package's only real-DB spec: db.Db() is a process-wide singleton its cleanup closes for good. * refactor(auth): remove duplication in claim decoding and token minting * refactor(auth): group aud with the standard JWT claims * refactor(auth): read aud with the standard-claim accessor pattern * fix(log): redact every api_key spelling the Jellyfin API accepts * fix(auth): bind session tokens to the user id, not just the username * fix(auth): return the token epoch from the same atomic increment * fix(auth): bump the token epoch in the same statement as the password write * chore(auth): trim comments to the why-only budget --- adapters/lastfm/auth_router_test.go | 9 + core/auth/auth.go | 59 ++++++- core/auth/auth_test.go | 109 ++++++++++++ core/auth/claims.go | 32 +++- core/auth/claims_test.go | 40 +++++ core/stream/token_test.go | 10 ++ .../20260822062750_add_user_token_epoch.sql | 7 + log/log.go | 5 +- log/log_test.go | 11 ++ model/request/request.go | 49 +++++- model/request/request_suite_test.go | 17 ++ model/request/request_test.go | 40 +++++ model/user.go | 2 + persistence/user_repository.go | 35 +++- persistence/user_repository_test.go | 157 ++++++++++++++++++ server/auth.go | 94 +++++++++-- server/auth_test.go | 135 +++++++++++++++ server/jellyfin/README.md | 2 + server/jellyfin/auth.go | 2 +- server/jellyfin/e2e/auth_test.go | 37 +++++ server/jellyfin/middlewares.go | 4 + server/jellyfin/middlewares_test.go | 46 +++++ .../user_password_token_refresh_test.go | 80 +++++++++ server/subsonic/middlewares.go | 4 +- server/subsonic/middlewares_test.go | 31 ++++ 25 files changed, 971 insertions(+), 46 deletions(-) create mode 100644 db/migrations/20260822062750_add_user_token_epoch.sql create mode 100644 model/request/request_suite_test.go create mode 100644 model/request/request_test.go create mode 100644 server/nativeapi/user_password_token_refresh_test.go diff --git a/adapters/lastfm/auth_router_test.go b/adapters/lastfm/auth_router_test.go index 4cbbd4298..1f65c059e 100644 --- a/adapters/lastfm/auth_router_test.go +++ b/adapters/lastfm/auth_router_test.go @@ -214,5 +214,14 @@ var _ = Describe("auth_router", func() { _, err = verifyLinkToken(nonExpiringToken) Expect(err).To(MatchError("link token missing expiration")) }) + + It("rejects a Jellyfin access token", func() { + usr := &model.User{ID: "u1", UserName: "johndoe"} + tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin) + Expect(err).ToNot(HaveOccurred()) + + _, err = verifyLinkToken(tokenStr) + Expect(err).To(HaveOccurred()) + }) }) }) diff --git a/core/auth/auth.go b/core/auth/auth.go index b1e2667bd..b36bb2696 100644 --- a/core/auth/auth.go +++ b/core/auth/auth.go @@ -4,6 +4,8 @@ import ( "cmp" "context" "crypto/sha256" + "errors" + "slices" "sync" "time" @@ -26,6 +28,13 @@ var ( PublicTokenAuth *jwtauth.JWTAuth ) +// Audiences a session token can be scoped to. A token with no audience is accepted anywhere. +const ( + AudienceJellyfin = "jellyfin" + AudienceSubsonic = "subsonic" + AudienceNative = "native" +) + // Init creates the JWTAuth objects from the secrets stored in the DB. // Missing or undecryptable secrets are regenerated and stored. func Init(ds model.DataStore) { @@ -66,15 +75,20 @@ func CreateExpiringPublicToken(exp time.Time, claims Claims) (string, error) { return token, err } -func CreateToken(u *model.User) (string, error) { - claims := Claims{ +func userClaims(u *model.User, audience []string) Claims { + return Claims{ Issuer: consts.JWTIssuer, Subject: u.UserName, IssuedAt: time.Now(), UserID: u.ID, IsAdmin: u.IsAdmin, + Epoch: u.TokenEpoch, + Audience: audience, } - token, _, err := TokenAuth.Encode(claims.ToMap()) +} + +func CreateToken(u *model.User) (string, error) { + token, _, err := TokenAuth.Encode(userClaims(u, nil).ToMap()) if err != nil { return "", err } @@ -82,10 +96,20 @@ func CreateToken(u *model.User) (string, error) { return TouchToken(token) } +// CreateAPIToken mints a non-expiring token scoped to one API, matching how Jellyfin +// clients expect tokens to behave. Revocation is by token epoch, not expiry. +func CreateAPIToken(u *model.User, audience string) (string, error) { + _, token, err := TokenAuth.Encode(userClaims(u, []string{audience}).ToMap()) + return token, err +} + func TouchToken(token jwt.Token) (string, error) { - claims := ClaimsFromToken(token). - WithExpiresAt(time.Now().UTC().Add(conf.Server.SessionTimeout)) - _, newToken, err := TokenAuth.Encode(claims.ToMap()) + return TouchClaims(ClaimsFromToken(token)) +} + +func TouchClaims(c Claims) (string, error) { + c = c.WithExpiresAt(time.Now().UTC().Add(conf.Server.SessionTimeout)) + _, newToken, err := TokenAuth.Encode(c.ToMap()) return newToken, err } @@ -106,6 +130,29 @@ func ValidatePublic(tokenStr string) (Claims, error) { return ClaimsFromToken(token), nil } +var ( + ErrTokenRevoked = errors.New("token revoked") + ErrWrongAudience = errors.New("token not valid for this API") + ErrWrongUser = errors.New("token issued for a different user") +) + +// CheckClaims gates a session token against the user it names. Callers must have already +// verified the signature; this adds revocation and API scoping on top. +func CheckClaims(c Claims, usr model.User, audience string) error { + // Usernames can be reused: deleting a user and recreating the name yields a new random id + // at epoch 0, which an old token would otherwise match. + if c.UserID != "" && c.UserID != usr.ID { + return ErrWrongUser + } + if c.Epoch != usr.TokenEpoch { + return ErrTokenRevoked + } + if len(c.Audience) > 0 && !slices.Contains(c.Audience, audience) { + return ErrWrongAudience + } + return nil +} + func WithAdminUser(ctx context.Context, ds model.DataStore) context.Context { u, err := ds.User(ctx).FindFirstAdmin() if err != nil { diff --git a/core/auth/auth_test.go b/core/auth/auth_test.go index e5cbb2352..c86dcd08c 100644 --- a/core/auth/auth_test.go +++ b/core/auth/auth_test.go @@ -151,4 +151,113 @@ var _ = Describe("Auth", func() { Expect(decodedClaims.ExpiresAt.Sub(yesterday)).To(BeNumerically(">=", oneDay)) }) }) + + Describe("CreateAPIToken", func() { + var usr *model.User + + BeforeEach(func() { + usr = &model.User{ID: "123", UserName: "johndoe", TokenEpoch: 4} + }) + + It("does not expire", func() { + tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin) + Expect(err).ToNot(HaveOccurred()) + + claims, err := auth.Validate(tokenStr) + Expect(err).ToNot(HaveOccurred()) + Expect(claims.ExpiresAt.IsZero()).To(BeTrue()) + }) + + It("carries the audience and the user's epoch", func() { + tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin) + Expect(err).ToNot(HaveOccurred()) + + claims, err := auth.Validate(tokenStr) + Expect(err).ToNot(HaveOccurred()) + Expect(claims.Audience).To(Equal([]string{"jellyfin"})) + Expect(claims.Epoch).To(Equal(4)) + Expect(claims.Subject).To(Equal("johndoe")) + Expect(claims.UserID).To(Equal("123")) + }) + }) + + Describe("CreateToken with an epoch", func() { + It("carries the epoch and still expires", func() { + usr := &model.User{ID: "123", UserName: "johndoe", TokenEpoch: 9} + tokenStr, err := auth.CreateToken(usr) + Expect(err).ToNot(HaveOccurred()) + + claims, err := auth.Validate(tokenStr) + Expect(err).ToNot(HaveOccurred()) + Expect(claims.Epoch).To(Equal(9)) + Expect(claims.Audience).To(BeEmpty()) + Expect(claims.ExpiresAt).To(BeTemporally(">", time.Now())) + }) + }) + + Describe("TouchClaims", func() { + It("preserves custom claims and refreshes the expiry", func() { + tokenStr, err := auth.TouchClaims(auth.Claims{Subject: "johndoe", UserID: "123", Epoch: 5}) + Expect(err).ToNot(HaveOccurred()) + + claims, err := auth.Validate(tokenStr) + Expect(err).ToNot(HaveOccurred()) + Expect(claims.Epoch).To(Equal(5)) + Expect(claims.Subject).To(Equal("johndoe")) + Expect(claims.ExpiresAt).To(BeTemporally(">", time.Now())) + }) + }) + + Describe("CheckClaims", func() { + usr := model.User{ID: "123", UserName: "johndoe", TokenEpoch: 2} + + It("accepts a matching epoch and audience", func() { + c := auth.Claims{Epoch: 2, Audience: []string{auth.AudienceJellyfin}} + Expect(auth.CheckClaims(c, usr, auth.AudienceJellyfin)).To(Succeed()) + }) + + It("accepts a token with no audience on any API", func() { + c := auth.Claims{Epoch: 2} + Expect(auth.CheckClaims(c, usr, auth.AudienceNative)).To(Succeed()) + Expect(auth.CheckClaims(c, usr, auth.AudienceJellyfin)).To(Succeed()) + Expect(auth.CheckClaims(c, usr, auth.AudienceSubsonic)).To(Succeed()) + }) + + It("rejects a stale epoch", func() { + c := auth.Claims{Epoch: 1, Audience: []string{auth.AudienceJellyfin}} + Expect(auth.CheckClaims(c, usr, auth.AudienceJellyfin)).To(MatchError(auth.ErrTokenRevoked)) + }) + + It("rejects a token minted for another API", func() { + c := auth.Claims{Epoch: 2, Audience: []string{auth.AudienceJellyfin}} + Expect(auth.CheckClaims(c, usr, auth.AudienceNative)).To(MatchError(auth.ErrWrongAudience)) + Expect(auth.CheckClaims(c, usr, auth.AudienceSubsonic)).To(MatchError(auth.ErrWrongAudience)) + }) + + It("accepts a multi-audience token that includes this API", func() { + c := auth.Claims{Epoch: 2, Audience: []string{"other", auth.AudienceNative}} + Expect(auth.CheckClaims(c, usr, auth.AudienceNative)).To(Succeed()) + }) + + It("accepts a pre-upgrade token against a never-bumped user", func() { + fresh := model.User{ID: "456", UserName: "newbie"} + Expect(auth.CheckClaims(auth.Claims{}, fresh, auth.AudienceNative)).To(Succeed()) + }) + + It("accepts a token whose user id matches", func() { + c := auth.Claims{UserID: "123", Epoch: 2} + Expect(auth.CheckClaims(c, usr, auth.AudienceNative)).To(Succeed()) + }) + + It("rejects a token for a deleted user recreated under the same name", func() { + recreated := model.User{ID: "new-random-id", UserName: "johndoe"} + c := auth.Claims{UserID: "123", Audience: []string{auth.AudienceJellyfin}} + Expect(auth.CheckClaims(c, recreated, auth.AudienceJellyfin)).To(MatchError(auth.ErrWrongUser)) + }) + + It("accepts a token that carries no user id", func() { + fresh := model.User{ID: "456", UserName: "newbie"} + Expect(auth.CheckClaims(auth.Claims{}, fresh, auth.AudienceNative)).To(Succeed()) + }) + }) }) diff --git a/core/auth/claims.go b/core/auth/claims.go index c7e6f02fe..42f7e4f2f 100644 --- a/core/auth/claims.go +++ b/core/auth/claims.go @@ -11,7 +11,8 @@ import ( type Claims struct { // Standard JWT claims Issuer string - Subject string // username for session tokens + Subject string // username for session tokens + Audience []string // which API may accept this token; empty means any IssuedAt time.Time ExpiresAt time.Time @@ -22,6 +23,7 @@ type Claims struct { Format string // "f" - audio format BitRate int // "b" - audio bitrate ShareID string // "sid" - share ID for share stream tokens + Epoch int // "ep" - the user's token_epoch at mint time } // ToMap converts Claims to a map[string]any for use with TokenAuth.Encode(). @@ -34,6 +36,9 @@ func (c Claims) ToMap() map[string]any { if c.Subject != "" { m[jwt.SubjectKey] = c.Subject } + if len(c.Audience) > 0 { + m[jwt.AudienceKey] = c.Audience + } if !c.IssuedAt.IsZero() { m[jwt.IssuedAtKey] = c.IssuedAt.UTC().Unix() } @@ -58,6 +63,9 @@ func (c Claims) ToMap() map[string]any { if c.ShareID != "" { m["sid"] = c.ShareID } + if c.Epoch != 0 { + m["ep"] = c.Epoch + } return m } @@ -73,6 +81,7 @@ func ClaimsFromToken(token jwt.Token) Claims { c.Subject, _ = token.Subject() c.IssuedAt, _ = token.IssuedAt() c.ExpiresAt, _ = token.Expiration() + c.Audience, _ = token.Audience() var uid string if err := token.Get("uid", &uid); err == nil { @@ -90,15 +99,24 @@ func ClaimsFromToken(token jwt.Token) Claims { if err := token.Get("f", &f); err == nil { c.Format = f } - if err := token.Get("b", &c.BitRate); err != nil { - var bf float64 - if err := token.Get("b", &bf); err == nil { - c.BitRate = int(bf) - } - } + c.BitRate = intClaim(token, "b") var sid string if err := token.Get("sid", &sid); err == nil { c.ShareID = sid } + c.Epoch = intClaim(token, "ep") return c } + +// intClaim reads a numeric claim, which a parsed token may decode as either int or float64. +func intClaim(token jwt.Token, key string) int { + var i int + if err := token.Get(key, &i); err == nil { + return i + } + var f float64 + if err := token.Get(key, &f); err == nil { + return int(f) + } + return 0 +} diff --git a/core/auth/claims_test.go b/core/auth/claims_test.go index 8820fd295..69d054031 100644 --- a/core/auth/claims_test.go +++ b/core/auth/claims_test.go @@ -105,4 +105,44 @@ var _ = Describe("Claims", func() { }) }) + Describe("Audience and Epoch claims", func() { + It("omits both when zero", func() { + m := auth.Claims{ID: "artwork-id"}.ToMap() + Expect(m).ToNot(HaveKey("aud")) + Expect(m).ToNot(HaveKey("ep")) + }) + + It("includes them when set", func() { + m := auth.Claims{Subject: "u", Epoch: 3, Audience: []string{"jellyfin"}}.ToMap() + Expect(m).To(HaveKeyWithValue("ep", 3)) + Expect(m).To(HaveKeyWithValue("aud", []string{"jellyfin"})) + }) + + It("round-trips through a signed token", func() { + tokenAuth := jwtauth.New("HS256", []byte("test-secret"), nil) + _, tokenStr, err := tokenAuth.Encode(auth.Claims{ + Subject: "u", Epoch: 7, Audience: []string{"jellyfin"}, + }.ToMap()) + Expect(err).ToNot(HaveOccurred()) + + token, err := jwtauth.VerifyToken(tokenAuth, tokenStr) + Expect(err).ToNot(HaveOccurred()) + claims := auth.ClaimsFromToken(token) + Expect(claims.Epoch).To(Equal(7)) + Expect(claims.Audience).To(Equal([]string{"jellyfin"})) + }) + + It("reads a token that has neither claim", func() { + tokenAuth := jwtauth.New("HS256", []byte("test-secret"), nil) + _, tokenStr, err := tokenAuth.Encode(auth.Claims{Subject: "u"}.ToMap()) + Expect(err).ToNot(HaveOccurred()) + + token, err := jwtauth.VerifyToken(tokenAuth, tokenStr) + Expect(err).ToNot(HaveOccurred()) + claims := auth.ClaimsFromToken(token) + Expect(claims.Epoch).To(BeZero()) + Expect(claims.Audience).To(BeEmpty()) + }) + }) + }) diff --git a/core/stream/token_test.go b/core/stream/token_test.go index 7409a7532..4f0d8066c 100644 --- a/core/stream/token_test.go +++ b/core/stream/token_test.go @@ -232,6 +232,16 @@ var _ = Describe("Token", func() { _, err := svc.ResolveRequestFromToken(ctx, token, mf, 0) Expect(err).To(MatchError(ErrTokenStale)) }) + + It("rejects a Jellyfin access token", func() { + mf := &model.MediaFile{ID: "song-1", UpdatedAt: sourceTime} + usr := &model.User{ID: "u1", UserName: "johndoe"} + tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin) + Expect(err).ToNot(HaveOccurred()) + + _, err = svc.ResolveRequestFromToken(ctx, tokenStr, mf, 0) + Expect(err).To(MatchError(ErrTokenInvalid)) + }) }) Describe("paramsFromToken", func() { diff --git a/db/migrations/20260822062750_add_user_token_epoch.sql b/db/migrations/20260822062750_add_user_token_epoch.sql new file mode 100644 index 000000000..bd37ddeb4 --- /dev/null +++ b/db/migrations/20260822062750_add_user_token_epoch.sql @@ -0,0 +1,7 @@ +-- +goose Up + +ALTER TABLE user ADD COLUMN token_epoch INTEGER NOT NULL DEFAULT 0; + +-- +goose Down + +ALTER TABLE user DROP COLUMN token_epoch; diff --git a/log/log.go b/log/log.go index 1c4ee3b4b..f7977c0fa 100644 --- a/log/log.go +++ b/log/log.go @@ -47,8 +47,9 @@ var redacted = &Hook{ // External services query params. Values can be JWTs (dots, dashes), so match everything up // to the next query separator or whitespace, not just word chars. A [\w]+ class would stop - // at a JWT's first '.' and leak its payload and signature. - "([^\\w]api_key=)[^&\\s]+", + // at a JWT's first '.' and leak its payload and signature. Case-insensitive with an + // optional underscore: the API accepts api_key, apikey and ApiKey alike. + "(?i)([^\\w]api_?key=)[^&\\s]+", }, } diff --git a/log/log_test.go b/log/log_test.go index 7b6ecfc32..f0e2e0eae 100644 --- a/log/log_test.go +++ b/log/log_test.go @@ -264,5 +264,16 @@ var _ = Describe("Logger", func() { msg := "/jellyfin/Audio/abc/universal?static=true&api_key=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiJ9.c2ln-X_1&other=1" Expect(Redact(msg)).To(Equal("/jellyfin/Audio/abc/universal?static=true&api_key=[REDACTED]&other=1")) }) + + DescribeTable("redacts every api_key spelling the Jellyfin API accepts", + func(param string) { + msg := "/jellyfin/Audio/abc/File?" + param + "=SECRET&other=1" + Expect(Redact(msg)).To(Equal("/jellyfin/Audio/abc/File?" + param + "=[REDACTED]&other=1")) + }, + Entry("api_key", "api_key"), + Entry("apikey", "apikey"), + Entry("ApiKey", "ApiKey"), + Entry("APIKEY", "APIKEY"), + ) }) }) diff --git a/model/request/request.go b/model/request/request.go index 8d7919298..2b1cfb9ef 100644 --- a/model/request/request.go +++ b/model/request/request.go @@ -2,6 +2,7 @@ package request import ( "context" + "sync/atomic" "github.com/navidrome/navidrome/model" ) @@ -9,15 +10,16 @@ import ( type contextKey string const ( - User = contextKey("user") - Username = contextKey("username") - Client = contextKey("client") - Version = contextKey("version") - Player = contextKey("player") - Transcoding = contextKey("transcoding") - ClientUniqueId = contextKey("clientUniqueId") - ReverseProxyIp = contextKey("reverseProxyIp") - InternalAuth = contextKey("internalAuth") // Used for internal API calls, e.g., from the plugins + User = contextKey("user") + Username = contextKey("username") + Client = contextKey("client") + Version = contextKey("version") + Player = contextKey("player") + Transcoding = contextKey("transcoding") + ClientUniqueId = contextKey("clientUniqueId") + ReverseProxyIp = contextKey("reverseProxyIp") + InternalAuth = contextKey("internalAuth") // Used for internal API calls, e.g., from the plugins + TokenEpochHolder = contextKey("tokenEpochHolder") ) var allKeys = []contextKey{ @@ -125,3 +127,32 @@ func AddValues(ctx, requestCtx context.Context) context.Context { } return ctx } + +type tokenEpochHolder struct { + value atomic.Int64 +} + +// WithTokenEpochHolder installs a slot a handler can use to report a bumped token epoch +// back to middleware that has already returned from the handler's perspective. +func WithTokenEpochHolder(ctx context.Context) context.Context { + h := &tokenEpochHolder{} + h.value.Store(-1) + return context.WithValue(ctx, TokenEpochHolder, h) +} + +func SetTokenEpoch(ctx context.Context, epoch int) { + if h, ok := ctx.Value(TokenEpochHolder).(*tokenEpochHolder); ok { + h.value.Store(int64(epoch)) + } +} + +func TokenEpochFrom(ctx context.Context) (int, bool) { + h, ok := ctx.Value(TokenEpochHolder).(*tokenEpochHolder) + if !ok { + return 0, false + } + if v := h.value.Load(); v >= 0 { + return int(v), true + } + return 0, false +} diff --git a/model/request/request_suite_test.go b/model/request/request_suite_test.go new file mode 100644 index 000000000..643ca76d7 --- /dev/null +++ b/model/request/request_suite_test.go @@ -0,0 +1,17 @@ +package request + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// tests.Init is not used here: the tests package imports model/request, so importing it +// back would create an import cycle. +func TestRequest(t *testing.T) { + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Request Suite") +} diff --git a/model/request/request_test.go b/model/request/request_test.go new file mode 100644 index 000000000..ef9af8231 --- /dev/null +++ b/model/request/request_test.go @@ -0,0 +1,40 @@ +package request + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Token epoch holder", func() { + It("reports nothing when unset", func() { + ctx := WithTokenEpochHolder(context.TODO()) + _, ok := TokenEpochFrom(ctx) + Expect(ok).To(BeFalse()) + }) + + It("round-trips a value set by the handler", func() { + ctx := WithTokenEpochHolder(context.TODO()) + SetTokenEpoch(ctx, 7) + + epoch, ok := TokenEpochFrom(ctx) + Expect(ok).To(BeTrue()) + Expect(epoch).To(Equal(7)) + }) + + It("survives being wrapped in a derived context", func() { + ctx := WithTokenEpochHolder(context.TODO()) + SetTokenEpoch(context.WithValue(ctx, contextKey("unrelated"), 1), 3) + + epoch, ok := TokenEpochFrom(ctx) + Expect(ok).To(BeTrue()) + Expect(epoch).To(Equal(3)) + }) + + It("is a no-op with no holder installed", func() { + Expect(func() { SetTokenEpoch(context.TODO(), 5) }).ToNot(Panic()) + _, ok := TokenEpochFrom(context.TODO()) + Expect(ok).To(BeFalse()) + }) +}) diff --git a/model/user.go b/model/user.go index b6f792c9a..37bdca33d 100644 --- a/model/user.go +++ b/model/user.go @@ -22,6 +22,8 @@ type User struct { // This is only available on the backend, and it is never sent over the wire Password string `structs:"-" json:"-"` + // Bumped on password change to invalidate every issued token for this user. + TokenEpoch int `structs:"-" json:"-"` // This is used to set or change a password when calling Put. If it is empty, the password is not changed. // It is received from the UI with the name "password" NewPassword string `structs:"password,omitempty" json:"password,omitempty"` //nolint:gosec diff --git a/persistence/user_repository.go b/persistence/user_repository.go index 3c030a640..9de37876b 100644 --- a/persistence/user_repository.go +++ b/persistence/user_repository.go @@ -18,6 +18,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/model/id" + "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/utils" "github.com/navidrome/navidrome/utils/slice" "github.com/pocketbase/dbx" @@ -126,14 +127,30 @@ func (r *userRepository) Put(u *model.User) error { } delete(values, "current_password") - // Save/update the user + // The epoch bump rides the password UPDATE: as two statements they can interleave with a + // concurrent change and leave a session valid that the other change should have revoked. update := Update(r.tableName).Where(Eq{"id": u.ID}).SetMap(values) - count, err := r.executeSQL(update) - if err != nil { - return err + var isNewUser bool + var epoch int + if u.NewPassword != "" { + var res struct{ TokenEpoch int } + err = r.queryOne(update.Set("token_epoch", Expr("token_epoch + 1")). + Suffix("RETURNING token_epoch"), &res) + switch { + case errors.Is(err, model.ErrNotFound): + isNewUser = true + case err != nil: + return err + default: + epoch = res.TokenEpoch + } + } else { + count, err := r.executeSQL(update) + if err != nil { + return err + } + isNewUser = count == 0 } - - isNewUser := count == 0 if isNewUser { values["created_at"] = time.Now() insert := Insert(r.tableName).SetMap(values) @@ -163,6 +180,12 @@ func (r *userRepository) Put(u *model.User) error { } } + // Only the caller's own token can be refreshed in-flight; an admin resetting another + // user must keep their own epoch. + if u.NewPassword != "" && !isNewUser && loggedUser(r.ctx).ID == u.ID { + request.SetTokenEpoch(r.ctx, epoch) + } + return nil } diff --git a/persistence/user_repository_test.go b/persistence/user_repository_test.go index ec417c193..dc519d0a1 100644 --- a/persistence/user_repository_test.go +++ b/persistence/user_repository_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "slices" + "sync" "github.com/Masterminds/squirrel" "github.com/deluan/rest" @@ -13,6 +14,7 @@ import ( "github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/utils/slice" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -683,4 +685,159 @@ var _ = Describe("UserRepository", func() { Expect(query).To(ContainSubstring("user.id = {:p0}")) }) }) + + Describe("token epoch", func() { + var repo model.UserRepository + var usr model.User + + newUser := func() model.User { + uid := id.NewRandom() + // user_name is unique; suffix it so each It gets its own row in the shared suite DB. + return model.User{ID: uid, UserName: "epoch-user-" + uid, Name: "Epoch", NewPassword: "hunter2"} + } + + BeforeEach(func() { + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, model.User{ID: "userid", IsAdmin: true}) + repo = NewUserRepository(ctx, GetDBXBuilder()) + usr = newUser() + Expect(repo.Put(&usr)).To(Succeed()) + }) + + It("starts at zero for a new user", func() { + got, err := repo.Get(usr.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.TokenEpoch).To(Equal(0)) + }) + + It("increments once per password change", func() { + usr.NewPassword = "second" + Expect(repo.Put(&usr)).To(Succeed()) + got, err := repo.Get(usr.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.TokenEpoch).To(Equal(1)) + + usr.NewPassword = "third" + Expect(repo.Put(&usr)).To(Succeed()) + got, err = repo.Get(usr.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.TokenEpoch).To(Equal(2)) + }) + + It("leaves the epoch alone when the password is untouched", func() { + usr.NewPassword = "" + usr.Name = "Renamed" + Expect(repo.Put(&usr)).To(Succeed()) + + got, err := repo.Get(usr.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.TokenEpoch).To(Equal(0)) + Expect(got.Name).To(Equal("Renamed")) + }) + + It("never signals the same epoch to two concurrent password changes", func() { + // Each writer's epoch must be the one its own UPDATE produced. + const callers = 4 + var mu sync.Mutex + var signalled []int + var wg sync.WaitGroup + for range callers { + wg.Go(func() { + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, model.User{ID: usr.ID}) + ctx = request.WithTokenEpochHolder(ctx) + own := NewUserRepository(ctx, GetDBXBuilder()) + + u := usr + u.NewPassword = "concurrent" + if err := own.Put(&u); err != nil { + return // the shared in-memory test DB can raise SQLITE_LOCKED + } + epoch, ok := request.TokenEpochFrom(ctx) + if !ok { + return + } + mu.Lock() + defer mu.Unlock() + signalled = append(signalled, epoch) + }) + } + wg.Wait() + + Expect(signalled).To(HaveLen(len(slice.Unique(signalled))), + "an epoch was signalled to more than one writer: %v", signalled) + }) + }) + + Describe("Put and the token epoch", func() { + newRepo := func(actingUserID string) model.UserRepository { + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, model.User{ID: actingUserID, IsAdmin: true}) + ctx = request.WithTokenEpochHolder(ctx) + return NewUserRepository(ctx, GetDBXBuilder()) + } + + It("does not bump when creating a user", func() { + repo := newRepo("admin") + usr := model.User{ID: id.NewRandom(), UserName: "fresh", NewPassword: "pw1"} + Expect(repo.Put(&usr)).To(Succeed()) + + got, err := repo.Get(usr.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.TokenEpoch).To(Equal(0)) + }) + + It("bumps when the password changes", func() { + repo := newRepo("admin") + usr := model.User{ID: id.NewRandom(), UserName: "changer", NewPassword: "pw1"} + Expect(repo.Put(&usr)).To(Succeed()) + + usr.NewPassword = "pw2" + Expect(repo.Put(&usr)).To(Succeed()) + + got, err := repo.Get(usr.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.TokenEpoch).To(Equal(1)) + }) + + It("does not bump on an edit that leaves the password alone", func() { + repo := newRepo("admin") + usr := model.User{ID: id.NewRandom(), UserName: "renamer", NewPassword: "pw1"} + Expect(repo.Put(&usr)).To(Succeed()) + + usr.NewPassword = "" + usr.Name = "New Display Name" + Expect(repo.Put(&usr)).To(Succeed()) + + got, err := repo.Get(usr.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(got.TokenEpoch).To(Equal(0)) + }) + + It("signals the new epoch when a user changes their own password", func() { + userID := id.NewRandom() + repo := newRepo(userID) + usr := model.User{ID: userID, UserName: "self", NewPassword: "pw1"} + Expect(repo.Put(&usr)).To(Succeed()) + + usr.NewPassword = "pw2" + Expect(repo.Put(&usr)).To(Succeed()) + + epoch, ok := request.TokenEpochFrom(repo.(*userRepository).ctx) + Expect(ok).To(BeTrue()) + Expect(epoch).To(Equal(1)) + }) + + It("does not signal when an admin changes someone else's password", func() { + repo := newRepo("some-admin") + usr := model.User{ID: id.NewRandom(), UserName: "other", NewPassword: "pw1"} + Expect(repo.Put(&usr)).To(Succeed()) + + usr.NewPassword = "pw2" + Expect(repo.Put(&usr)).To(Succeed()) + + _, ok := request.TokenEpochFrom(repo.(*userRepository).ctx) + Expect(ok).To(BeFalse()) + }) + }) }) diff --git a/server/auth.go b/server/auth.go index 6a25f1406..37a318a83 100644 --- a/server/auth.go +++ b/server/auth.go @@ -12,10 +12,12 @@ import ( "net/http" "slices" "strings" + "sync" "time" "github.com/deluan/rest" "github.com/go-chi/jwtauth/v5" + "github.com/lestrrat-go/jwx/v3/jwt" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/auth" @@ -260,7 +262,7 @@ func Authenticator(ds model.DataStore) func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx, err := authenticateRequest(ds, r, UsernameFromConfig, UsernameFromToken, UsernameFromExtAuthHeader) - if err != nil { + if err != nil || !tokenAllowed(ctx) { _ = rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated") return } @@ -270,24 +272,88 @@ func Authenticator(ds model.DataStore) func(next http.Handler) http.Handler { } } -// JWTRefresher updates the expiry date of the received JWT token, and add the new one to the Authorization Header +// tokenAllowed re-checks a JWT that actually identifies the resolved user. Header and +// config auth carry no token, so they short-circuit to true. +func tokenAllowed(ctx context.Context) bool { + token, _, err := jwtauth.FromContext(ctx) + if err != nil || token == nil { + return true + } + usr, ok := request.UserFrom(ctx) + if !ok { + return true + } + claims := auth.ClaimsFromToken(token) + if !strings.EqualFold(claims.Subject, usr.UserName) { + return true + } + if err := auth.CheckClaims(claims, usr, auth.AudienceNative); err != nil { + log.Warn(ctx, "Native API: rejected token", "user", claims.Subject, err) + return false + } + return true +} + +// refreshingWriter defers the refreshed-token header until the handler's first write, so an +// epoch the handler bumped reaches the token the client stores. +type refreshingWriter struct { + http.ResponseWriter + ctx context.Context + token jwt.Token + once sync.Once +} + +func (w *refreshingWriter) setToken() { + w.once.Do(func() { + claims := auth.ClaimsFromToken(w.token) + if epoch, ok := request.TokenEpochFrom(w.ctx); ok { + claims.Epoch = epoch + } + newToken, err := auth.TouchClaims(claims) + if err != nil { + log.Error(w.ctx, "Could not sign new token", err) + return + } + w.Header().Set(consts.UIAuthorizationHeader, newToken) + }) +} + +func (w *refreshingWriter) WriteHeader(code int) { + w.setToken() + w.ResponseWriter.WriteHeader(code) +} + +func (w *refreshingWriter) Write(b []byte) (int, error) { + w.setToken() + return w.ResponseWriter.Write(b) +} + +// Flush keeps the SSE events route working through the wrap. +func (w *refreshingWriter) Flush() { + w.setToken() + if f, ok := w.ResponseWriter.(http.Flusher); ok { + f.Flush() + } +} + +// Unwrap lets capability lookups, such as SSE's write deadline, see past this wrap. +func (w *refreshingWriter) Unwrap() http.ResponseWriter { + return w.ResponseWriter +} + +// JWTRefresher updates the expiry date of the received JWT token, and adds the new one to +// the Authorization Header. func JWTRefresher(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - token, _, err := jwtauth.FromContext(ctx) - if err != nil { + token, _, err := jwtauth.FromContext(r.Context()) + if err != nil || token == nil { next.ServeHTTP(w, r) return } - newTokenString, err := auth.TouchToken(token) - if err != nil { - log.Error(r, "Could not sign new token", err) - _ = rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated") - return - } - - w.Header().Set(consts.UIAuthorizationHeader, newTokenString) - next.ServeHTTP(w, r) + ctx := request.WithTokenEpochHolder(r.Context()) + rw := &refreshingWriter{ResponseWriter: w, ctx: ctx, token: token} + next.ServeHTTP(rw, r.WithContext(ctx)) + rw.setToken() }) } diff --git a/server/auth_test.go b/server/auth_test.go index f6af6f0d6..e021c82a8 100644 --- a/server/auth_test.go +++ b/server/auth_test.go @@ -12,6 +12,7 @@ import ( "time" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/model" @@ -342,4 +343,138 @@ var _ = Describe("Auth", func() { Expect(u.IsAdmin).To(BeFalse()) }) }) + + Describe("Authenticator token gating", func() { + var ds *tests.MockDataStore + var usr *model.User + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.SessionTimeout = time.Hour + ds = &tests.MockDataStore{} + auth.Init(ds) + ur := ds.User(context.TODO()).(*tests.MockedUserRepo) + usr = &model.User{ID: "u1", UserName: "johndoe", NewPassword: "pw", TokenEpoch: 2} + Expect(ur.Put(usr)).To(Succeed()) + }) + + serve := func(token string) *httptest.ResponseRecorder { + r := httptest.NewRequest("GET", "/api/song", nil) + r.Header.Set(consts.UIAuthorizationHeader, "Bearer "+token) + w := httptest.NewRecorder() + handler := JWTVerifier(Authenticator(ds)(http.HandlerFunc( + func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }, + ))) + handler.ServeHTTP(w, r) + return w + } + + It("accepts a current session token", func() { + tokenStr, err := auth.CreateToken(usr) + Expect(err).ToNot(HaveOccurred()) + Expect(serve(tokenStr).Code).To(Equal(http.StatusOK)) + }) + + It("rejects a jellyfin-scoped token", func() { + tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin) + Expect(err).ToNot(HaveOccurred()) + Expect(serve(tokenStr).Code).To(Equal(http.StatusUnauthorized)) + }) + + It("rejects a token with a stale epoch", func() { + tokenStr, err := auth.CreateToken(usr) + Expect(err).ToNot(HaveOccurred()) + usr.TokenEpoch = 3 + Expect(serve(tokenStr).Code).To(Equal(http.StatusUnauthorized)) + }) + + It("ignores a stray token for someone else when config auto-login resolves the user", func() { + conf.Server.DevAutoLoginUsername = usr.UserName + tokenStr, err := auth.CreateToken(&model.User{UserName: "someone-else"}) + Expect(err).ToNot(HaveOccurred()) + Expect(serve(tokenStr).Code).To(Equal(http.StatusOK)) + }) + + It("rejects a stale-epoch token whose subject differs only in case from the resolved user", func() { + tokenStr, err := auth.CreateToken(&model.User{UserName: strings.ToUpper(usr.UserName), TokenEpoch: usr.TokenEpoch}) + Expect(err).ToNot(HaveOccurred()) + usr.TokenEpoch = 5 + Expect(serve(tokenStr).Code).To(Equal(http.StatusUnauthorized)) + }) + }) + + Describe("JWTRefresher", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + // TouchClaims reads this; left at zero every refreshed token is born expired. + conf.Server.SessionTimeout = time.Hour + auth.Init(&tests.MockDataStore{}) + }) + + serveWith := func(handler http.HandlerFunc) *httptest.ResponseRecorder { + usr := model.User{ID: "u1", UserName: "johndoe", TokenEpoch: 1} + tokenStr, err := auth.CreateToken(&usr) + Expect(err).ToNot(HaveOccurred()) + + r := httptest.NewRequest("GET", "/api/song", nil) + r.Header.Set(consts.UIAuthorizationHeader, "Bearer "+tokenStr) + w := httptest.NewRecorder() + JWTVerifier(JWTRefresher(handler)).ServeHTTP(w, r) + return w + } + + It("writes a refreshed token when the handler writes a body", func() { + w := serveWith(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("ok")) + }) + Expect(w.Header().Get(consts.UIAuthorizationHeader)).ToNot(BeEmpty()) + }) + + It("writes a refreshed token when the handler writes no body", func() { + w := serveWith(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }) + Expect(w.Header().Get(consts.UIAuthorizationHeader)).ToNot(BeEmpty()) + }) + + It("picks up an epoch the handler reported", func() { + w := serveWith(func(w http.ResponseWriter, r *http.Request) { + request.SetTokenEpoch(r.Context(), 42) + w.WriteHeader(http.StatusOK) + }) + + claims, err := auth.Validate(w.Header().Get(consts.UIAuthorizationHeader)) + Expect(err).ToNot(HaveOccurred()) + Expect(claims.Epoch).To(Equal(42)) + }) + + It("keeps the original epoch when the handler reports nothing", func() { + w := serveWith(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + claims, err := auth.Validate(w.Header().Get(consts.UIAuthorizationHeader)) + Expect(err).ToNot(HaveOccurred()) + Expect(claims.Epoch).To(Equal(1)) + }) + + It("propagates Flush to the underlying ResponseWriter", func() { + w := serveWith(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + w.(http.Flusher).Flush() + }) + Expect(w.Flushed).To(BeTrue()) + }) + + It("exposes the underlying ResponseWriter via Unwrap, for http.ResponseController lookups", func() { + var unwrapped http.ResponseWriter + w := serveWith(func(w http.ResponseWriter, _ *http.Request) { + u, ok := w.(interface{ Unwrap() http.ResponseWriter }) + Expect(ok).To(BeTrue()) + unwrapped = u.Unwrap() + w.WriteHeader(http.StatusOK) + }) + Expect(unwrapped).To(BeIdenticalTo(w)) + }) + }) }) diff --git a/server/jellyfin/README.md b/server/jellyfin/README.md index dc3219dfa..15b56a499 100644 --- a/server/jellyfin/README.md +++ b/server/jellyfin/README.md @@ -58,6 +58,8 @@ query param — all forms are accepted, matching what different clients do). `/auth/login` (`AuthRequestLimit`/`AuthWindowLength`), since it's an unauthenticated brute-force surface. +Access tokens do not expire, matching real Jellyfin. They are revoked by a password change, which bumps the user's token epoch. + ### Public user list (login picker) `GET /Users/Public` lets a client render a login user-picker (tap a user, then just type the diff --git a/server/jellyfin/auth.go b/server/jellyfin/auth.go index 062ac6458..e7070d341 100644 --- a/server/jellyfin/auth.go +++ b/server/jellyfin/auth.go @@ -36,7 +36,7 @@ func (api *Router) authenticateByName(w http.ResponseWriter, r *http.Request) { log.Error(ctx, "Jellyfin API: could not update last login date", "username", body.Username, err) } - token, err := auth.CreateToken(usr) + token, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin) if err != nil { api.internalError(w, r, err) return diff --git a/server/jellyfin/e2e/auth_test.go b/server/jellyfin/e2e/auth_test.go index 806b0e5e7..156e79ce9 100644 --- a/server/jellyfin/e2e/auth_test.go +++ b/server/jellyfin/e2e/auth_test.go @@ -6,6 +6,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/server/jellyfin/dto" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -61,6 +62,42 @@ var _ = Describe("Authentication", func() { It("rejects a malformed body", func() { Expect(rawReq("POST", "/Users/AuthenticateByName", "not json").Code).To(Equal(http.StatusBadRequest)) }) + + It("mints a non-expiring token scoped to the Jellyfin audience", func() { + w := authenticate("admin", "password") + var res dto.AuthenticationResult + parseInto(w, &res) + + claims, err := auth.Validate(res.AccessToken) + Expect(err).ToNot(HaveOccurred()) + Expect(claims.ExpiresAt.IsZero()).To(BeTrue()) + Expect(claims.Audience).To(Equal([]string{"jellyfin"})) + Expect(claims.Subject).To(Equal("admin")) + }) + + It("revokes an already-issued token when the user's epoch is bumped", func() { + w := authenticate("admin", "password") + var res dto.AuthenticationResult + parseInto(w, &res) + + r := httptest.NewRequest("GET", "/Users/Me", nil) + r.Header.Set("X-Emby-Token", res.AccessToken) + pw := httptest.NewRecorder() + router.ServeHTTP(pw, r) + Expect(pw.Code).To(Equal(http.StatusOK)) + + // A real password change through the repository, which is what revokes in production. + admin, err := ds.User(ctx).Get(testID("admin-1")) + Expect(err).ToNot(HaveOccurred()) + admin.NewPassword = "rotated" + Expect(ds.User(ctx).Put(admin)).To(Succeed()) + + r = httptest.NewRequest("GET", "/Users/Me", nil) + r.Header.Set("X-Emby-Token", res.AccessToken) + pw = httptest.NewRecorder() + router.ServeHTTP(pw, r) + Expect(pw.Code).To(Equal(http.StatusUnauthorized)) + }) }) Describe("GET /Users/Public", func() { diff --git a/server/jellyfin/middlewares.go b/server/jellyfin/middlewares.go index c90f9c088..0ae4f6071 100644 --- a/server/jellyfin/middlewares.go +++ b/server/jellyfin/middlewares.go @@ -167,6 +167,10 @@ func (api *Router) userFromToken(r *http.Request) (model.User, bool) { log.Warn(r.Context(), "Jellyfin API: token subject not found", "user", claims.Subject, err) return model.User{}, false } + if err := auth.CheckClaims(claims, *usr, auth.AudienceJellyfin); err != nil { + log.Warn(r.Context(), "Jellyfin API: rejected token", "user", claims.Subject, err) + return model.User{}, false + } return *usr, true } diff --git a/server/jellyfin/middlewares_test.go b/server/jellyfin/middlewares_test.go index b17b9a4ec..a3b88799b 100644 --- a/server/jellyfin/middlewares_test.go +++ b/server/jellyfin/middlewares_test.go @@ -95,6 +95,52 @@ var _ = Describe("authenticate middleware", func() { api.authenticate(next).ServeHTTP(w, r) Expect(w.Code).To(Equal(http.StatusUnauthorized)) }) + + Context("token scoping and revocation", func() { + var usr *model.User + + BeforeEach(func() { + ur := ds.User(context.Background()).(*tests.MockedUserRepo) + usr = &model.User{ID: testID("u2"), UserName: "bob", NewPassword: "secret", TokenEpoch: 3} + Expect(ur.Put(usr)).To(Succeed()) + }) + + serve := func(token string) *httptest.ResponseRecorder { + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/Items", nil) + r.Header.Set("X-Emby-Token", token) + api.authenticate(next).ServeHTTP(w, r) + return w + } + + It("accepts a jellyfin-scoped token with the current epoch", func() { + tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin) + Expect(err).ToNot(HaveOccurred()) + Expect(serve(tokenStr).Code).To(Equal(http.StatusOK)) + }) + + It("rejects a token whose epoch is stale", func() { + tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin) + Expect(err).ToNot(HaveOccurred()) + usr.TokenEpoch = 4 + Expect(serve(tokenStr).Code).To(Equal(http.StatusUnauthorized)) + }) + + It("rejects a token minted for another API", func() { + tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceNative) + Expect(err).ToNot(HaveOccurred()) + Expect(serve(tokenStr).Code).To(Equal(http.StatusUnauthorized)) + }) + + It("still accepts an unscoped session token", func() { + tokenStr, err := auth.CreateToken(usr) + Expect(err).ToNot(HaveOccurred()) + Expect(serve(tokenStr).Code).To(Equal(http.StatusOK)) + }) + }) }) var _ = Describe("withPlayer middleware", func() { diff --git a/server/nativeapi/user_password_token_refresh_test.go b/server/nativeapi/user_password_token_refresh_test.go new file mode 100644 index 000000000..32f4b13cb --- /dev/null +++ b/server/nativeapi/user_password_token_refresh_test.go @@ -0,0 +1,80 @@ +package nativeapi + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "time" + + "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/auth" + "github.com/navidrome/navidrome/db" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/persistence" + "github.com/navidrome/navidrome/server" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type noopPluginUnloader struct{} + +func (noopPluginUnloader) UnloadDisabledPlugins(context.Context) {} + +// Pins that the token-epoch handoff survives a real request through the real middleware chain. +var _ = Describe("PUT /user/{id}: token refresh on self password change", func() { + var ds model.DataStore + var router http.Handler + + BeforeEach(func() { + // db.Db() is a process-wide singleton that this DeferCleanup closes for the whole binary; keep this the only real-DB spec in this package. + DeferCleanup(configtest.SetupConfig()) + conf.Server.EnableUserEditing = true + conf.Server.EnableSharing = false + conf.Server.SessionTimeout = time.Hour + conf.Server.DbPath = filepath.Join(GinkgoT().TempDir(), "nativeapi-user-refresh.db") + "?_journal_mode=WAL" + DeferCleanup(db.Init(GinkgoT().Context())) + + ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} + auth.Init(ds) + + userService := core.NewUser(ds, noopPluginUnloader{}) + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), userService, nil, nil, nil) + router = server.JWTVerifier(nativeRouter) + }) + + It("carries the bumped epoch in the refreshed token, not the epoch the token was minted with", func() { + usr := model.User{UserName: "selfchanger", Name: "Self Changer", NewPassword: "old-password"} + Expect(ds.User(GinkgoT().Context()).Put(&usr)).To(Succeed()) + + token, err := auth.CreateToken(&usr) + Expect(err).ToNot(HaveOccurred()) + + body, _ := json.Marshal(map[string]any{ + "userName": usr.UserName, + "name": usr.Name, + "currentPassword": "old-password", + "password": "new-password", + }) + req := createAuthenticatedRequest(http.MethodPut, "/user/"+usr.ID, bytes.NewBuffer(body), token) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + Expect(w.Code).To(Equal(http.StatusOK), w.Body.String()) + + refreshed := w.Header().Get(consts.UIAuthorizationHeader) + Expect(refreshed).ToNot(BeEmpty()) + claims, err := auth.Validate(refreshed) + Expect(err).ToNot(HaveOccurred()) + + reloaded, err := ds.User(GinkgoT().Context()).Get(usr.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(reloaded.TokenEpoch).To(Equal(1)) + Expect(claims.Epoch).To(Equal(reloaded.TokenEpoch)) + }) +}) diff --git a/server/subsonic/middlewares.go b/server/subsonic/middlewares.go index 837852d18..6dfa2263f 100644 --- a/server/subsonic/middlewares.go +++ b/server/subsonic/middlewares.go @@ -178,7 +178,9 @@ func validateCredentials(user *model.User, pass, token, salt, jwt string) error switch { case jwt != "": claims, err := auth.Validate(jwt) - valid = err == nil && claims.Subject == user.UserName + valid = err == nil && + claims.Subject == user.UserName && + auth.CheckClaims(claims, *user, auth.AudienceSubsonic) == nil case pass != "": if strings.HasPrefix(pass, "enc:") { if dec, err := hex.DecodeString(pass[4:]); err == nil { diff --git a/server/subsonic/middlewares_test.go b/server/subsonic/middlewares_test.go index 3f8c07a56..cb34b92e7 100644 --- a/server/subsonic/middlewares_test.go +++ b/server/subsonic/middlewares_test.go @@ -470,6 +470,7 @@ var _ = Describe("Middlewares", func() { var validToken string BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) conf.Server.SessionTimeout = time.Minute auth.Init(ds) @@ -499,6 +500,36 @@ var _ = Describe("Middlewares", func() { Expect(err).To(MatchError(model.ErrInvalidAuth)) }) }) + + Context("JWT credentials", func() { + var usr *model.User + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.SessionTimeout = time.Minute + auth.Init(ds) + usr = &model.User{ID: "u1", UserName: "johndoe", TokenEpoch: 1} + }) + + It("accepts an unscoped session token", func() { + tokenStr, err := auth.CreateToken(usr) + Expect(err).ToNot(HaveOccurred()) + Expect(validateCredentials(usr, "", "", "", tokenStr)).To(Succeed()) + }) + + It("rejects a jellyfin-scoped token", func() { + tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin) + Expect(err).ToNot(HaveOccurred()) + Expect(validateCredentials(usr, "", "", "", tokenStr)).To(MatchError(model.ErrInvalidAuth)) + }) + + It("rejects a token with a stale epoch", func() { + tokenStr, err := auth.CreateToken(usr) + Expect(err).ToNot(HaveOccurred()) + usr.TokenEpoch = 2 + Expect(validateCredentials(usr, "", "", "", tokenStr)).To(MatchError(model.ErrInvalidAuth)) + }) + }) }) }) From 3cb9850872f45a2cee0ca540b7c8d141c631f222 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 22 Aug 2026 21:38:54 -0400 Subject: [PATCH 19/78] feat(artwork): extend stale absent age to 30 days and update test formatting Signed-off-by: Deluan --- cmd/artwork_test.go | 3 ++- core/artwork/housekeeping.go | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/cmd/artwork_test.go b/cmd/artwork_test.go index cc63ed86f..f17206aaa 100644 --- a/cmd/artwork_test.go +++ b/cmd/artwork_test.go @@ -3,6 +3,7 @@ package cmd import ( "context" "errors" + "fmt" "io" "strings" "time" @@ -910,7 +911,7 @@ var _ = Describe("formatStatus", func() { }) It("states the recheck window and the drip rate the absent counts are bucketed against", func() { - Expect(formatStatus(rep)).To(ContainSubstring("168h")) + Expect(formatStatus(rep)).To(ContainSubstring(fmt.Sprintf("%gh", artwork.StaleAbsentAge.Hours()))) Expect(formatStatus(rep)).To(ContainSubstring("100 per kind per hour")) }) diff --git a/core/artwork/housekeeping.go b/core/artwork/housekeeping.go index 9456a5584..ce98e2a03 100644 --- a/core/artwork/housekeeping.go +++ b/core/artwork/housekeeping.go @@ -18,7 +18,7 @@ import ( ) // StaleAbsentAge is how long an absent state is trusted before a recheck retries it. -const StaleAbsentAge = 7 * 24 * time.Hour +const StaleAbsentAge = 30 * 24 * time.Hour // StaleAbsentRecheckBatch caps how many absent states each hourly tick re-queues per kind, // oldest first, so external agents see a flat drip instead of a daily burst. From fc9d93d22ac7e175223a48e0a84a6e5fe7c24c42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 22 Aug 2026 22:02:25 -0400 Subject: [PATCH 20/78] fix(plugins): read the loaded plugin from a local, not the shared map (#6014) Plugins load concurrently through an errgroup. loadPluginWithConfig wrote m.plugins under m.mu but read it back unlocked to pass to callPluginInit, so one goroutine's write raced another's read. Caught by -race on master (run 32608293134): all 640 specs passed, the job failed only on the race. Capture the pointer while holding the lock and use the local. Holding m.mu across callPluginInit would be wrong, since that runs arbitrary plugin code. --- plugins/manager_loader.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/plugins/manager_loader.go b/plugins/manager_loader.go index e5e3dbfc0..46da56396 100644 --- a/plugins/manager_loader.go +++ b/plugins/manager_loader.go @@ -434,8 +434,7 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { return fmt.Errorf("manifest validation: %w", err) } - m.mu.Lock() - m.plugins[p.ID] = &plugin{ + loadedPlugin := &plugin{ name: p.ID, path: p.Path, manifest: pkg.Manifest, @@ -449,13 +448,16 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { fsConfig: fsConfig, lyricsSem: make(chan struct{}, maxConcurrentLyricsCalls), } + m.mu.Lock() + m.plugins[p.ID] = loadedPlugin m.mu.Unlock() loaded = true // Init is the plugin's first chance to run arbitrary code: open sockets, create task queues, // schedule work. Only a caller that already intends to reach the network asks for it. + // Use the local: loads run concurrently, so reading the map back here would race the writes. if m.transient == nil || m.transient.runInit { - callPluginInit(ctx, m.plugins[p.ID]) + callPluginInit(ctx, loadedPlugin) } return nil From 3e55886195bb125f5c1fda1a1ded39f319e96662 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 23 Aug 2026 14:31:37 -0400 Subject: [PATCH 21/78] feat: add optional natural sort order for names and titles (#6015) * feat: add optional natural sort order for names and titles Album, artist, song and playlist lists sort with a plain text comparison, so names containing numbers come out as "Foo 1, Foo 10, Foo 2" instead of "Foo 1, Foo 2, Foo 10" (issue #4554). Adds an EnableNaturalSorting option, default off, that switches those sorts to a NATSORT collation registered on every connection and backed by natural.CompareFold. natural.Compare gained an ASCII case-folding variant because it replaces 'collate nocase': sort_* columns hold raw tag values, so without folding they would order uppercase before lowercase. Applying the collation only inside mapSortOrder would have missed the default configuration entirely, since that mapper runs only when PreferSortTags is on. setSortMappings now also rewrites the order_* columns when natural sorting is enabled on its own. Sorts over plain text columns that are not order_* columns (playlist.name, album.name, media_file.title, playlist_tracks title) are wrapped explicitly, and qualified with their table because 'user' is joined and also has a 'name' column. The option defaults to off because the collation cannot use the existing indexes: measured on a synthetic 110k album library, the first page of an album-by-name listing goes from 0.03ms to 14ms. Indexing the expression was rejected outright - an index declared with a custom collation makes the whole database unreadable to any tool that does not register it, including the sqlite3 CLI, which fails even on 'select count(*)' and 'pragma integrity_check'. * refactor: fold the two sort-order mappers into one mapSortOrder and mapNaturalOrder shared the same regex and loop, differing only in the expression they substituted, and setSortMappings picked between them with a two-case switch. mapSortOrder now selects the column shape itself and defers to collatedSort for the collation, so the 'collate' clause is emitted in one place and the caller only has to decide whether any mapping is needed at all. The mapper tests were three near-identical cases that each hard-coded one flag combination; they are now a DescribeTable covering all four combinations of PreferSortTags and EnableNaturalSorting, which the previous set did not. The album sorting specs collapse the same way. Behavior is unchanged. * fix: leave plain sort columns alone when natural sorting is off collatedSort wrapped its column unconditionally, so the tiebreakers added for plain text columns picked up 'collate nocase' even with EnableNaturalSorting off. media_file.title, the playlist_tracks alias of it, and user.user_name are all declared without a collation, so a default install would have silently switched those tiebreaks from binary to case-insensitive ordering. Only playlist.name was already NOCASE and genuinely unaffected. The helper is now naturalSort and returns the column untouched unless the option is on, so the default path keeps the collation each column was declared with. sortCollation had a single remaining caller and folded into mapSortOrder. Tests: the CompareFold table body was a verbatim copy of the Compare one, so both now go through one expectOrder helper, and the album sorting specs inline two single-use closures. * fix(natural): defer the leading-zero tie-break to keep ordering transitive Compare applied the padding difference between numerically equal digit runs only when one side ended at the digit boundary, and ignored it mid-string. That made the relation intransitive: CompareFold("1","1a") < 0 and CompareFold("1a","01a") == 0, yet CompareFold("1","01a") > 0. SQLite requires a collating function to be transitive and leaves ORDER BY undefined otherwise, so registering this as NATSORT was not safe. Reproduced with the real driver on three artist names that occur in practice - "3", "3 doors down" and "03 greedo" - where paging one row at a time returned "03 greedo" twice and dropped "3" entirely. The padding difference is now carried as a tie-break that is applied only when the strings are otherwise equal, which restores transitivity while keeping the documented intent (a01 < a1, a0 < a00). Three existing entries changed: each asserted that two distinct strings compare equal, which was the same defect seen from the other side. Found by the Codex review on #6015. --- conf/configuration.go | 2 + db/db.go | 10 ++- persistence/album_repository.go | 2 +- persistence/album_repository_test.go | 39 +++++++++ persistence/helpers.go | 29 +++++-- persistence/helpers_test.go | 55 ++++++++++--- persistence/mediafile_repository.go | 2 +- persistence/playlist_repository.go | 3 +- persistence/playlist_repository_test.go | 35 ++++++++ persistence/playlist_track_repository.go | 2 +- persistence/sql_base_repository.go | 5 +- utils/natural/natural.go | 51 ++++++++---- utils/natural/natural_test.go | 100 ++++++++++++++++++++--- 13 files changed, 284 insertions(+), 51 deletions(-) diff --git a/conf/configuration.go b/conf/configuration.go index fbbaaf252..df22e4ae2 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -73,6 +73,7 @@ type configOptions struct { Matcher matcherOptions `json:",omitzero"` RecentlyAddedByModTime bool PreferSortTags bool + EnableNaturalSorting bool IgnoredArticles string IndexGroups string FFmpegPath string @@ -973,6 +974,7 @@ func setViperDefaults() { viper.SetDefault("matcher.fuzzythreshold", 85) viper.SetDefault("recentlyaddedbymodtime", false) viper.SetDefault("prefersorttags", false) + viper.SetDefault("enablenaturalsorting", false) viper.SetDefault("ignoredarticles", "The El La Los Las Le Les Os As O A") viper.SetDefault("indexgroups", "A B C D E F G H I J K L M N O P Q R S T U V W X-Z(XYZ) [Unknown]([)") viper.SetDefault("ffmpegpath", "") diff --git a/db/db.go b/db/db.go index 11a05b456..a325dd3f5 100644 --- a/db/db.go +++ b/db/db.go @@ -13,10 +13,15 @@ import ( _ "github.com/navidrome/navidrome/db/migrations" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/utils/hasher" + "github.com/navidrome/navidrome/utils/natural" "github.com/navidrome/navidrome/utils/singleton" "github.com/pressly/goose/v3" ) +// NaturalCollation sorts embedded numbers by value. It is registered on every +// connection, but only referenced when conf.Server.EnableNaturalSorting is on. +const NaturalCollation = "NATSORT" + var ( Dialect = "sqlite3" Driver = Dialect + "_custom" @@ -32,7 +37,10 @@ func Db() *sql.DB { return singleton.GetInstance(func() *sql.DB { sql.Register(Driver, &sqlite3.SQLiteDriver{ ConnectHook: func(conn *sqlite3.SQLiteConn) error { - return conn.RegisterFunc("SEEDEDRAND", hasher.HashFunc(), false) + if err := conn.RegisterFunc("SEEDEDRAND", hasher.HashFunc(), false); err != nil { + return err + } + return conn.RegisterCollation(NaturalCollation, natural.CompareFold) }, }) Path = conf.Server.DbPath diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 5d7aad22e..7ac875a51 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -113,7 +113,7 @@ func NewAlbumRepository(ctx context.Context, db dbx.Builder) model.AlbumReposito "artist": "compilation, order_album_artist_name, order_album_name", "album_artist": "compilation, order_album_artist_name, order_album_name", // TODO Rename this to just year (or date) - "max_year": "coalesce(nullif(original_date,''), cast(max_year as text)), release_date, name", + "max_year": "coalesce(nullif(original_date,''), cast(max_year as text)), release_date, " + naturalSort("album.name"), "random": "random", "recently_added": recentlyAddedSort(), "starred_at": "starred, starred_at", diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index f6768768d..0fb680cff 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -10,6 +10,7 @@ import ( "github.com/Masterminds/squirrel" "github.com/deluan/rest" "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/id" @@ -38,6 +39,44 @@ var _ = Describe("AlbumRepository", func() { albumRepo = NewAlbumRepository(ctx, GetDBXBuilder()).(*albumRepository) }) + Describe("natural sorting", func() { + var ids []string + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + ids = nil + for _, n := range []string{"foo 1", "foo 10", "foo 2", "foo 20", "foo 3"} { + aid := "nat-" + n + ids = append(ids, aid) + Expect(albumRepo.Put(&model.Album{ + ID: aid, LibraryID: 1, Name: n, OrderAlbumName: n, + })).To(Succeed()) + } + DeferCleanup(func() { + _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": ids})) + }) + }) + + DescribeTable("sorts albums by name", + func(naturalSorting, preferSortTags bool, expected []string) { + conf.Server.EnableNaturalSorting = naturalSorting + conf.Server.PreferSortTags = preferSortTags + albumRepo = NewAlbumRepository(ctx, GetDBXBuilder()).(*albumRepository) + albums, err := albumRepo.GetAll(model.QueryOptions{ + Sort: "name", Filters: squirrel.Eq{"album.id": ids}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(slice.Map(albums, func(a model.Album) string { return a.Name })).To(Equal(expected)) + }, + Entry("lexicographically by default", false, false, + []string{"foo 1", "foo 10", "foo 2", "foo 20", "foo 3"}), + Entry("by number value when natural sorting is enabled", true, false, + []string{"foo 1", "foo 2", "foo 3", "foo 10", "foo 20"}), + Entry("by number value with sort tags preferred too", true, true, + []string{"foo 1", "foo 2", "foo 3", "foo 10", "foo 20"}), + ) + }) + Describe("Get", func() { var Get = func(id string) (*model.Album, error) { album, err := albumRepo.Get(id) diff --git a/persistence/helpers.go b/persistence/helpers.go index fd6a9a4cd..1da31cf02 100644 --- a/persistence/helpers.go +++ b/persistence/helpers.go @@ -9,6 +9,8 @@ import ( "github.com/Masterminds/squirrel" "github.com/fatih/structs" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/db" ) type PostMapper interface { @@ -82,11 +84,28 @@ func (e existsCond) ToSql() (string, []any, error) { var sortOrderRegex = regexp.MustCompile(`order_([a-z_]+)`) -// Convert the order_* columns to an expression using sort_* columns. Example: -// sort_album_name -> (coalesce(nullif(sort_album_name,”),order_album_name) collate nocase) +// naturalSort makes a plain text column sort numbers by value, leaving it alone +// otherwise so it keeps its declared collation. Parens guard buildSortOrder's space split. +func naturalSort(col string) string { + if !conf.Server.EnableNaturalSorting { + return col + } + return fmt.Sprintf("(%s collate %s)", col, db.NaturalCollation) +} + +// Convert the order_* columns to a collated sort expression, falling back to the +// sort_* column when those are preferred. Example: +// order_album_name -> (coalesce(nullif(sort_album_name,”),order_album_name) collate nocase) // It finds order column names anywhere in the substring func mapSortOrder(tableName, order string) string { - order = strings.ToLower(order) - repl := fmt.Sprintf("(coalesce(nullif(%[1]s.sort_$1,''),%[1]s.order_$1) collate nocase)", tableName) - return sortOrderRegex.ReplaceAllString(order, repl) + col := tableName + ".order_$1" + if conf.Server.PreferSortTags { + col = fmt.Sprintf("coalesce(nullif(%[1]s.sort_$1,''),%[1]s.order_$1)", tableName) + } + collation := "nocase" + if conf.Server.EnableNaturalSorting { + collation = db.NaturalCollation + } + repl := fmt.Sprintf("(%s collate %s)", col, collation) + return sortOrderRegex.ReplaceAllString(strings.ToLower(order), repl) } diff --git a/persistence/helpers_test.go b/persistence/helpers_test.go index 85893ef55..3019609f3 100644 --- a/persistence/helpers_test.go +++ b/persistence/helpers_test.go @@ -4,6 +4,8 @@ import ( "time" "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -85,22 +87,51 @@ var _ = Describe("Helpers", func() { }) Describe("mapSortOrder", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + It("does not change the sort string if there are no order columns", func() { - sort := "album_name asc" - mapped := mapSortOrder("album", sort) - Expect(mapped).To(Equal(sort)) - }) - It("changes order columns to sort expression", func() { - sort := "ORDER_ALBUM_NAME asc" - mapped := mapSortOrder("album", sort) - Expect(mapped).To(Equal(`(coalesce(nullif(album.sort_album_name,''),album.order_album_name)` + - ` collate nocase) asc`)) + Expect(mapSortOrder("album", "album_name asc")).To(Equal("album_name asc")) }) + + DescribeTable("maps order columns to a collated expression", + func(preferSortTags, naturalSorting bool, expected string) { + conf.Server.PreferSortTags = preferSortTags + conf.Server.EnableNaturalSorting = naturalSorting + Expect(mapSortOrder("album", "ORDER_ALBUM_NAME asc")).To(Equal(expected)) + }, + Entry("qualified column", false, false, + "(album.order_album_name collate nocase) asc"), + Entry("natural collation", false, true, + "(album.order_album_name collate NATSORT) asc"), + Entry("sort tags preferred", true, false, + `(coalesce(nullif(album.sort_album_name,''),album.order_album_name) collate nocase) asc`), + Entry("sort tags preferred, natural collation", true, true, + `(coalesce(nullif(album.sort_album_name,''),album.order_album_name) collate NATSORT) asc`), + ) + It("changes multiple order columns to sort expressions", func() { + conf.Server.PreferSortTags = true sort := "compilation, order_title asc, order_album_artist_name desc, year desc" - mapped := mapSortOrder("album", sort) - Expect(mapped).To(Equal(`compilation, (coalesce(nullif(album.sort_title,''),album.order_title) collate nocase) asc,` + - ` (coalesce(nullif(album.sort_album_artist_name,''),album.order_album_artist_name) collate nocase) desc, year desc`)) + Expect(mapSortOrder("album", sort)).To(Equal( + `compilation, (coalesce(nullif(album.sort_title,''),album.order_title) collate nocase) asc,` + + ` (coalesce(nullif(album.sort_album_artist_name,''),album.order_album_artist_name) collate nocase) desc, year desc`)) + }) + }) + + Describe("naturalSort", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + + It("leaves the column alone by default, keeping its declared collation", func() { + Expect(naturalSort("media_file.title")).To(Equal("media_file.title")) + }) + + It("applies the natural collation when enabled", func() { + conf.Server.EnableNaturalSorting = true + Expect(naturalSort("media_file.title")).To(Equal("(media_file.title collate NATSORT)")) }) }) }) diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index 8146cba2f..320b95ef2 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -86,7 +86,7 @@ func NewMediaFileRepository(ctx context.Context, db dbx.Builder) model.MediaFile "title": "order_title", "artist": "order_artist_name, order_album_name, release_date, disc_number, track_number", "album_artist": "order_album_artist_name, order_album_name, release_date, disc_number, track_number", - "album": "order_album_name, album_id, disc_number, track_number, order_artist_name, title", + "album": "order_album_name, album_id, disc_number, track_number, order_artist_name, " + naturalSort("media_file.title"), "random": "random", "created_at": "media_file.created_at", "recently_added": mediaFileRecentlyAddedSort(), diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index cf54c6d5a..505f23440 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -60,7 +60,8 @@ func NewPlaylistRepository(ctx context.Context, db dbx.Builder) model.PlaylistRe "starred": annotationBoolFilter("starred"), }) r.setSortMappings(map[string]string{ - "owner_name": "owner_name", + "name": naturalSort("playlist.name"), + "owner_name": naturalSort("owner_name"), }) return r } diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go index 9697e6fff..f60b4e7ca 100644 --- a/persistence/playlist_repository_test.go +++ b/persistence/playlist_repository_test.go @@ -5,6 +5,8 @@ import ( "github.com/Masterminds/squirrel" "github.com/deluan/rest" + "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/criteria" @@ -24,6 +26,39 @@ var _ = Describe("PlaylistRepository", func() { repo = NewPlaylistRepository(ctx, GetDBXBuilder()) }) + Describe("natural sorting", func() { + var ids []string + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.EnableNaturalSorting = true + ctx := log.NewContext(GinkgoT().Context()) + ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) + repo = NewPlaylistRepository(ctx, GetDBXBuilder()) + + ids = nil + for _, n := range []string{"mix 1", "mix 10", "mix 2"} { + pls := model.Playlist{Name: n, OwnerID: "userid"} + Expect(repo.Put(&pls)).To(Succeed()) + ids = append(ids, pls.ID) + } + DeferCleanup(func() { + for _, id := range ids { + _ = repo.Delete(id) + } + }) + }) + + It("sorts playlist names by number value", func() { + all, err := repo.GetAll(model.QueryOptions{ + Sort: "name", Filters: squirrel.Eq{"playlist.id": ids}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(slice.Map(all, func(p model.Playlist) string { return p.Name })).To( + Equal([]string{"mix 1", "mix 2", "mix 10"})) + }) + }) + Describe("Count", func() { It("returns the number of playlists in the DB", func() { Expect(repo.CountAll()).To(Equal(int64(2))) diff --git a/persistence/playlist_track_repository.go b/persistence/playlist_track_repository.go index a5e1975fd..cf1b8f3fa 100644 --- a/persistence/playlist_track_repository.go +++ b/persistence/playlist_track_repository.go @@ -56,7 +56,7 @@ func (r *playlistRepository) Tracks(playlistId string, refreshSmartPlaylist bool "id": "playlist_tracks.id", "artist": "order_artist_name", "album_artist": "order_album_artist_name", - "album": "order_album_name, album_id, disc_number, track_number, order_artist_name, title", + "album": "order_album_name, album_id, disc_number, track_number, order_artist_name, " + naturalSort("f.title"), "title": "order_title", "random": "random()", // To make sure these fields will be whitelisted diff --git a/persistence/sql_base_repository.go b/persistence/sql_base_repository.go index d4cf9b456..f49e1bc4f 100644 --- a/persistence/sql_base_repository.go +++ b/persistence/sql_base_repository.go @@ -113,10 +113,9 @@ func (r *sqlRepository) setSortMappings(mappings map[string]string, tableName .. if len(tableName) > 0 { tn = tableName[0] } - if conf.Server.PreferSortTags { + if conf.Server.PreferSortTags || conf.Server.EnableNaturalSorting { for k, v := range mappings { - v = mapSortOrder(tn, v) - mappings[k] = v + mappings[k] = mapSortOrder(tn, v) } } r.sortMappings = mappings diff --git a/utils/natural/natural.go b/utils/natural/natural.go index fa0800e1d..d8ddcc405 100644 --- a/utils/natural/natural.go +++ b/utils/natural/natural.go @@ -10,15 +10,32 @@ import "strings" // or a positive value if a > b using natural sort ordering. // // When two numeric segments are numerically equal (e.g. "01" vs "1"), -// comparison continues with the remaining suffixes. If one or both -// strings end at the digit boundary, the raw strings are compared -// lexically, which makes leading zeros significant as a tie-breaker -// (e.g. "a01" < "a1", "a0" < "a00"). +// comparison continues with the remaining suffixes, and the padding +// difference is kept as a final tie-breaker that only decides strings +// that are otherwise equal (e.g. "a01" < "a1", "a0" < "a00"). Deferring +// it that way is what keeps the ordering transitive, which SQLite +// requires of a collating function. func Compare(a, b string) int { + return compare(a, b, false) +} + +// CompareFold is Compare with ASCII case folding, matching SQLite's NOCASE +// collation: only A-Z fold, bytes >= 0x80 are compared as-is. +func CompareFold(a, b string) int { + return compare(a, b, true) +} + +func compare(a, b string, fold bool) int { ia, ib := 0, 0 + // Set when two runs are numerically equal but differently padded. Applying it + // immediately would break transitivity, so it only decides otherwise-equal strings. + padTie := 0 for ia < len(a) && ib < len(b) { ca, cb := a[ia], b[ib] da, db := isDigit(ca), isDigit(cb) + if fold { + ca, cb = lower(ca), lower(cb) + } switch { case da && db: @@ -35,17 +52,11 @@ func Compare(a, b string) int { if c := compareNumbers(a[ia:endA], b[ib:endB]); c != 0 { return c } - - // Numerically equal. If both sides have trailing data, continue - // comparing after the digit runs. Otherwise fall through to - // lexical comparison of the full remaining strings (which makes - // leading-zero differences significant as a tie-breaker). - if endA < len(a) && endB < len(b) { - ia = endA - ib = endB - continue + if t := strings.Compare(a[ia:endA], b[ib:endB]); t != 0 { + padTie = t } - return strings.Compare(a[ia:], b[ib:]) + ia = endA + ib = endB case da != db: return int(ca) - int(cb) default: @@ -56,7 +67,10 @@ func Compare(a, b string) int { ib++ } } - return (len(a) - ia) - (len(b) - ib) + if c := (len(a) - ia) - (len(b) - ib); c != 0 { + return c + } + return padTie } // compareNumbers compares two digit strings numerically. @@ -96,3 +110,10 @@ func stripZeros(s string) string { func isDigit(c byte) bool { return c >= '0' && c <= '9' } + +func lower(c byte) byte { + if c >= 'A' && c <= 'Z' { + return c + 'a' - 'A' + } + return c +} diff --git a/utils/natural/natural_test.go b/utils/natural/natural_test.go index 825a944c0..534885d40 100644 --- a/utils/natural/natural_test.go +++ b/utils/natural/natural_test.go @@ -13,17 +13,23 @@ func TestNatural(t *testing.T) { RunSpecs(t, "Natural Suite") } +// expectOrder asserts the sign of cmp(a, b) matches expected. +func expectOrder(cmp func(string, string) int, a, b string, expected int) { + result := cmp(a, b) + switch { + case expected < 0: + ExpectWithOffset(1, result).To(BeNumerically("<", 0), "expected %q < %q", a, b) + case expected > 0: + ExpectWithOffset(1, result).To(BeNumerically(">", 0), "expected %q > %q", a, b) + default: + ExpectWithOffset(1, result).To(Equal(0), "expected %q == %q", a, b) + } +} + var _ = Describe("Compare", func() { DescribeTable("returns correct ordering", func(a, b string, expected int) { - result := natural.Compare(a, b) - if expected < 0 { - Expect(result).To(BeNumerically("<", 0), "expected %q < %q", a, b) - } else if expected > 0 { - Expect(result).To(BeNumerically(">", 0), "expected %q > %q", a, b) - } else { - Expect(result).To(Equal(0), "expected %q == %q", a, b) - } + expectOrder(natural.Compare, a, b, expected) }, // Basic string ordering Entry("a < b", "a", "b", -1), @@ -67,7 +73,9 @@ var _ = Describe("Compare", func() { Entry("a00b00 < a0b1", "a00b00", "a0b1", -1), Entry("a00b00 > a0b0", "a00b00", "a0b0", 1), Entry("a00b01 > a0b00", "a00b01", "a0b00", 1), - Entry("a00b00 == a0b00", "a00b00", "a0b00", 0), + // Distinct strings must not compare equal: the padding difference in the first + // run decides once everything else matches. + Entry("a00b00 > a0b00", "a00b00", "a0b00", 1), // Leading zeros at end of string — lexical tie-break Entry("file01 < file1", "file01", "file1", -1), @@ -109,8 +117,78 @@ var _ = Describe("Compare", func() { Entry("large: equal", "a100000000000000000000", "a100000000000000000000", 0), Entry("large: leading zeros with trailing data", - "a00000000000000000000001x", "a1x", 0), + "a00000000000000000000001x", "a1x", -1), Entry("large: leading zeros with trailing data (2)", - "a099999999999999999999x", "a99999999999999999999x", 0), + "a099999999999999999999x", "a99999999999999999999x", -1), ) }) + +var _ = Describe("CompareFold", func() { + DescribeTable("orders case-insensitively", + func(a, b string, expected int) { + expectOrder(natural.CompareFold, a, b, expected) + }, + Entry("numbers compare numerically", "foo 2", "foo 10", -1), + Entry("numbers compare numerically, reversed", "foo 10", "foo 2", 1), + Entry("case is ignored", "apple 2", "Banana 10", -1), + Entry("case is ignored, reversed", "Banana 10", "apple 2", 1), + Entry("same word, different case, is equal", "ABC", "abc", 0), + Entry("case ignored while comparing numbers", "Vol 2", "vol 10", -1), + Entry("uppercase digits boundary", "Track9", "track10", -1), + Entry("empty vs empty", "", "", 0), + Entry("empty sorts first", "", "a", -1), + Entry("non-ASCII is left untouched", "café 2", "café 10", -1), + ) + + // SQLite requires a collating function to be transitive; if it is not, the behavior of + // ORDER BY is undefined and paginated queries can drop or duplicate rows. + It("is transitive, as a SQLite collation requires", func() { + var corpus []string + var build func(prefix string, depth int) + build = func(prefix string, depth int) { + if prefix != "" { + corpus = append(corpus, prefix) + } + if depth == 0 { + return + } + for _, c := range []string{"0", "1", "a"} { + build(prefix+c, depth-1) + } + } + build("", 3) + + sign := func(n int) int { + switch { + case n < 0: + return -1 + case n > 0: + return 1 + } + return 0 + } + for _, a := range corpus { + for _, b := range corpus { + ab := sign(natural.CompareFold(a, b)) + for _, c := range corpus { + bc := sign(natural.CompareFold(b, c)) + ac := sign(natural.CompareFold(a, c)) + if ab == 0 && bc == 0 { + Expect(ac).To(Equal(0), "%q==%q and %q==%q but %q vs %q is %d", a, b, b, c, a, c, ac) + } + if ab < 0 && bc < 0 { + Expect(ac).To(BeNumerically("<", 0), "%q<%q<%q but %q vs %q is %d", a, b, c, a, c, ac) + } + } + } + } + }) + + It("matches Compare when both sides are already lowercase", func() { + pairs := [][2]string{{"foo 2", "foo 10"}, {"a01", "a1"}, {"a", "aa"}, {"vol 3", "vol 3"}} + for _, p := range pairs { + Expect(natural.CompareFold(p[0], p[1])).To(Equal(natural.Compare(p[0], p[1])), + "CompareFold(%q,%q) should match Compare", p[0], p[1]) + } + }) +}) From 82b9a44a1f0ee94682f759086579df3a9ec4330e Mon Sep 17 00:00:00 2001 From: Deluan Date: Sun, 23 Aug 2026 15:24:44 -0400 Subject: [PATCH 22/78] fix(log): redact sensitive auth headers from request logs The trace-level request log dumps all headers as a JSON blob, but the redaction hook only had query-param patterns, so Authorization, X-Emby-Token, X-MediaBrowser-Token and X-Nd-Authorization leaked their tokens in plaintext. Add one pattern that blanks those header value arrays at the log sink. --- log/log.go | 4 ++++ log/log_test.go | 18 +++++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/log/log.go b/log/log.go index f7977c0fa..10cfb17b5 100644 --- a/log/log.go +++ b/log/log.go @@ -50,6 +50,10 @@ var redacted = &Hook{ // at a JWT's first '.' and leak its payload and signature. Case-insensitive with an // optional underscore: the API accepts api_key, apikey and ApiKey alike. "(?i)([^\\w]api_?key=)[^&\\s]+", + + // Sensitive request headers, logged as a JSON blob at trace level and never matched by the + // query-param patterns above. Blank the whole value array; values may hold escaped quotes. + `(?i)("(?:Authorization|X-Emby-Token|X-MediaBrowser-Token|X-Nd-Authorization)":\[")[^\]]*("\])`, }, } diff --git a/log/log_test.go b/log/log_test.go index f0e2e0eae..82207c672 100644 --- a/log/log_test.go +++ b/log/log_test.go @@ -2,7 +2,9 @@ package log import ( "context" + "encoding/json" "errors" + "net/http" "net/http/httptest" "testing" "time" @@ -92,7 +94,7 @@ var _ = Describe("Logger", func() { SetLogSourceLine(true) Error("A crash happened") // NOTE: This assertion breaks if the line number above changes - Expect(hook.LastEntry().Data[" source"]).To(ContainSubstring("/log/log_test.go:93")) + Expect(hook.LastEntry().Data[" source"]).To(ContainSubstring("/log/log_test.go:95")) Expect(hook.LastEntry().Message).To(Equal("A crash happened")) }) @@ -275,5 +277,19 @@ var _ = Describe("Logger", func() { Entry("ApiKey", "ApiKey"), Entry("APIKEY", "APIKEY"), ) + + It("redacts sensitive request headers in a logged header blob", func() { + h := http.Header{ + "Authorization": {`MediaBrowser Client="Finamp", Token="jwt-secret"`}, + "X-Emby-Token": {"emby-secret"}, + "X-Mediabrowser-Token": {"mb-secret"}, + "X-Nd-Authorization": {"Bearer nd-secret"}, + "User-Agent": {"Finamp/1.0"}, + } + blob, _ := json.Marshal(h) + got := Redact(string(blob)) + Expect(got).ToNot(ContainSubstring("secret")) + Expect(got).To(ContainSubstring(`"User-Agent":["Finamp/1.0"]`)) + }) }) }) From 3da2b590e780722df6c2a35e10fd38dfaf83098a Mon Sep 17 00:00:00 2001 From: Rob Emery Date: Mon, 24 Aug 2026 16:22:32 +0100 Subject: [PATCH 23/78] fix: add Navidrome UserAgent in all outgoing requests (#6020) * There has been a report about navidrome hitting listenbrainz hard and the listenbrainz guys wanting to be able to distinguish navidrome * feat: apply Navidrome User-Agent to all outgoing HTTP requests Add utils/httpclient, a shared http.Client factory whose transport sets the User-Agent header (Navidrome/{version} - https://github.com/navidrome) on any request that does not already have one, and use it at every place the server builds an HTTP client: Last.fm, ListenBrainz and Deezer agents and auth routers, insights collector, backgrounds handler, and the plugin host HTTP service. Plugin-set User-Agent values are preserved. The per-request header lines from the previous commit are superseded by the transport. --------- Co-authored-by: Deluan --- adapters/deezer/deezer.go | 6 +- adapters/lastfm/agent.go | 5 +- adapters/lastfm/auth_router.go | 5 +- adapters/listenbrainz/agent.go | 6 +- adapters/listenbrainz/auth_router.go | 5 +- consts/consts.go | 2 +- core/metrics/insights.go | 5 +- plugins/host_httpclient.go | 3 +- server/backgrounds/handler.go | 5 +- utils/httpclient/httpclient.go | 35 +++++++++++ utils/httpclient/httpclient_suite_test.go | 17 +++++ utils/httpclient/httpclient_test.go | 76 +++++++++++++++++++++++ 12 files changed, 146 insertions(+), 24 deletions(-) create mode 100644 utils/httpclient/httpclient.go create mode 100644 utils/httpclient/httpclient_suite_test.go create mode 100644 utils/httpclient/httpclient_test.go diff --git a/adapters/deezer/deezer.go b/adapters/deezer/deezer.go index 742b8b1a5..1fa10e25c 100644 --- a/adapters/deezer/deezer.go +++ b/adapters/deezer/deezer.go @@ -5,7 +5,6 @@ import ( "context" "errors" "fmt" - "net/http" "slices" "strings" @@ -15,6 +14,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/cache" + "github.com/navidrome/navidrome/utils/httpclient" "github.com/navidrome/navidrome/utils/slice" ) @@ -36,9 +36,7 @@ func deezerConstructor(dataStore model.DataStore) agents.Interface { dataStore: dataStore, languages: conf.Server.Deezer.Languages, } - httpClient := &http.Client{ - Timeout: consts.DefaultHttpClientTimeOut, - } + httpClient := httpclient.New(consts.DefaultHttpClientTimeOut) cachedHttpClient := cache.NewHTTPClient(httpClient, consts.DefaultHttpClientTimeOut) agent.client = newClient(cachedHttpClient) return agent diff --git a/adapters/lastfm/agent.go b/adapters/lastfm/agent.go index f967595e3..863868b5a 100644 --- a/adapters/lastfm/agent.go +++ b/adapters/lastfm/agent.go @@ -18,6 +18,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/cache" + "github.com/navidrome/navidrome/utils/httpclient" "golang.org/x/net/html" ) @@ -59,9 +60,7 @@ func lastFMConstructor(ds model.DataStore) *lastfmAgent { secret: conf.Server.LastFM.Secret, sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty}, } - hc := &http.Client{ - Timeout: consts.DefaultHttpClientTimeOut, - } + hc := httpclient.New(consts.DefaultHttpClientTimeOut) chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut) l.httpClient = chc l.client = newClient(l.apiKey, l.secret, chc) diff --git a/adapters/lastfm/auth_router.go b/adapters/lastfm/auth_router.go index 499863e28..411bf069a 100644 --- a/adapters/lastfm/auth_router.go +++ b/adapters/lastfm/auth_router.go @@ -18,6 +18,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server" + "github.com/navidrome/navidrome/utils/httpclient" "github.com/navidrome/navidrome/utils/req" ) @@ -41,9 +42,7 @@ func NewRouter(ds model.DataStore) *Router { sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty}, } r.Handler = r.routes() - hc := &http.Client{ - Timeout: consts.DefaultHttpClientTimeOut, - } + hc := httpclient.New(consts.DefaultHttpClientTimeOut) r.client = newClient(r.apiKey, r.secret, hc) return r } diff --git a/adapters/listenbrainz/agent.go b/adapters/listenbrainz/agent.go index 76beed921..a59a5393f 100644 --- a/adapters/listenbrainz/agent.go +++ b/adapters/listenbrainz/agent.go @@ -3,7 +3,6 @@ package listenbrainz import ( "context" "errors" - "net/http" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" @@ -12,6 +11,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/cache" + "github.com/navidrome/navidrome/utils/httpclient" "github.com/navidrome/navidrome/utils/slice" ) @@ -33,9 +33,7 @@ func listenBrainzConstructor(ds model.DataStore) *listenBrainzAgent { sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty}, baseURL: conf.Server.ListenBrainz.BaseURL, } - hc := &http.Client{ - Timeout: consts.DefaultHttpClientTimeOut, - } + hc := httpclient.New(consts.DefaultHttpClientTimeOut) chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut) l.client = newClient(l.baseURL, chc) return l diff --git a/adapters/listenbrainz/auth_router.go b/adapters/listenbrainz/auth_router.go index 7cb9eb16a..1ff1a1495 100644 --- a/adapters/listenbrainz/auth_router.go +++ b/adapters/listenbrainz/auth_router.go @@ -16,6 +16,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server" + "github.com/navidrome/navidrome/utils/httpclient" ) type sessionKeysRepo interface { @@ -37,9 +38,7 @@ func NewRouter(ds model.DataStore) *Router { sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty}, } r.Handler = r.routes() - hc := &http.Client{ - Timeout: consts.DefaultHttpClientTimeOut, - } + hc := httpclient.New(consts.DefaultHttpClientTimeOut) r.client = newClient(conf.Server.ListenBrainz.BaseURL, hc) return r } diff --git a/consts/consts.go b/consts/consts.go index aed8ecf66..2934cd968 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -201,7 +201,7 @@ var ( } ) -var HTTPUserAgent = "Navidrome" + "/" + Version +var HTTPUserAgent = "Navidrome/" + Version + " - https://github.com/navidrome" var ( VariousArtists = "Various Artists" diff --git a/core/metrics/insights.go b/core/metrics/insights.go index 66d0b89bd..706df6559 100644 --- a/core/metrics/insights.go +++ b/core/metrics/insights.go @@ -26,6 +26,7 @@ import ( "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/plugins" "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/utils/httpclient" "github.com/navidrome/navidrome/utils/singleton" ) @@ -95,9 +96,7 @@ func (c *insightsCollector) sendInsights(ctx context.Context) { log.Trace(ctx, "No users found, skipping Insights data collection") return } - hc := &http.Client{ - Timeout: consts.DefaultHttpClientTimeOut, - } + hc := httpclient.New(consts.DefaultHttpClientTimeOut) data := c.collect(ctx) if data == nil { return diff --git a/plugins/host_httpclient.go b/plugins/host_httpclient.go index f1d64deb7..4c8f85acd 100644 --- a/plugins/host_httpclient.go +++ b/plugins/host_httpclient.go @@ -14,6 +14,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/plugins/host" + "github.com/navidrome/navidrome/utils/httpclient" ) const ( @@ -46,7 +47,7 @@ func newHTTPService(pluginName string, permission *HTTPPermission) *httpServiceI requiredHosts: requiredHosts, } svc.client = &http.Client{ - Transport: http.DefaultTransport, + Transport: httpclient.NewTransport(nil), // Timeout is set per-request via context deadline, not here. // CheckRedirect validates hosts and enforces redirect limits. CheckRedirect: func(req *http.Request, via []*http.Request) error { diff --git a/server/backgrounds/handler.go b/server/backgrounds/handler.go index b00a51696..f6e159b4b 100644 --- a/server/backgrounds/handler.go +++ b/server/backgrounds/handler.go @@ -13,6 +13,7 @@ import ( "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/utils/cache" + "github.com/navidrome/navidrome/utils/httpclient" "github.com/navidrome/navidrome/utils/random" "gopkg.in/yaml.v3" ) @@ -35,7 +36,7 @@ type Handler struct { func NewHandler() *Handler { h := &Handler{} - h.httpClient = cache.NewHTTPClient(&http.Client{Timeout: 5 * time.Second}, imageListTTL) + h.httpClient = cache.NewHTTPClient(httpclient.New(5*time.Second), imageListTTL) h.cache = cache.NewFileCache(imageCacheDir, imageCacheSize, imageCacheDir, imageCacheMaxItems, h.serveImage) go func() { _, _ = h.getImageList(log.NewContext(context.Background())) @@ -78,7 +79,7 @@ func (h *Handler) serveImage(ctx context.Context, item cache.Item) (io.Reader, e if image == "" { return nil, errors.New("empty image name") } - c := http.Client{Timeout: imageRequestTimeout} + c := httpclient.New(imageRequestTimeout) req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageURL(image), nil) resp, err := c.Do(req) //nolint:bodyclose,gosec // No need to close resp.Body, it will be closed via the CachedStream wrapper if errors.Is(err, context.DeadlineExceeded) { diff --git a/utils/httpclient/httpclient.go b/utils/httpclient/httpclient.go new file mode 100644 index 000000000..7fb48f36d --- /dev/null +++ b/utils/httpclient/httpclient.go @@ -0,0 +1,35 @@ +// Package httpclient provides a shared http.Client factory that identifies +// Navidrome via the User-Agent header on all outgoing requests. +package httpclient + +import ( + "net/http" + "time" + + "github.com/navidrome/navidrome/consts" +) + +type uaTransport struct { + base http.RoundTripper +} + +func (t *uaTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if _, ok := req.Header["User-Agent"]; !ok { + req = req.Clone(req.Context()) + req.Header.Set("User-Agent", consts.HTTPUserAgent) + } + return t.base.RoundTrip(req) +} + +// NewTransport wraps base (or http.DefaultTransport if nil) to set the +// Navidrome User-Agent on requests that don't have one. +func NewTransport(base http.RoundTripper) http.RoundTripper { + if base == nil { + base = http.DefaultTransport + } + return &uaTransport{base: base} +} + +func New(timeout time.Duration) *http.Client { + return &http.Client{Timeout: timeout, Transport: NewTransport(nil)} +} diff --git a/utils/httpclient/httpclient_suite_test.go b/utils/httpclient/httpclient_suite_test.go new file mode 100644 index 000000000..e18a9d0ad --- /dev/null +++ b/utils/httpclient/httpclient_suite_test.go @@ -0,0 +1,17 @@ +package httpclient_test + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestHTTPClient(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "HTTPClient Suite") +} diff --git a/utils/httpclient/httpclient_test.go b/utils/httpclient/httpclient_test.go new file mode 100644 index 000000000..c86b51165 --- /dev/null +++ b/utils/httpclient/httpclient_test.go @@ -0,0 +1,76 @@ +package httpclient_test + +import ( + "net/http" + "net/http/httptest" + "time" + + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/utils/httpclient" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("httpclient", func() { + var server *httptest.Server + var receivedUA string + + BeforeEach(func() { + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedUA = r.Header.Get("User-Agent") + })) + DeferCleanup(server.Close) + }) + + Describe("New", func() { + It("sets the Navidrome User-Agent when the request has none", func() { + c := httpclient.New(time.Second) + resp, err := c.Get(server.URL) + Expect(err).ToNot(HaveOccurred()) + resp.Body.Close() + Expect(receivedUA).To(Equal(consts.HTTPUserAgent)) + }) + + It("keeps a User-Agent already set by the caller", func() { + c := httpclient.New(time.Second) + req, err := http.NewRequest(http.MethodGet, server.URL, nil) + Expect(err).ToNot(HaveOccurred()) + req.Header.Set("User-Agent", "CustomAgent/1.0") + resp, err := c.Do(req) + Expect(err).ToNot(HaveOccurred()) + resp.Body.Close() + Expect(receivedUA).To(Equal("CustomAgent/1.0")) + }) + + It("applies the given timeout", func() { + c := httpclient.New(5 * time.Second) + Expect(c.Timeout).To(Equal(5 * time.Second)) + }) + }) + + Describe("NewTransport", func() { + It("uses the default transport when base is nil", func() { + c := &http.Client{Transport: httpclient.NewTransport(nil)} + resp, err := c.Get(server.URL) + Expect(err).ToNot(HaveOccurred()) + resp.Body.Close() + Expect(receivedUA).To(Equal(consts.HTTPUserAgent)) + }) + + It("does not modify the original request", func() { + c := &http.Client{Transport: httpclient.NewTransport(nil)} + req, err := http.NewRequest(http.MethodGet, server.URL, nil) + Expect(err).ToNot(HaveOccurred()) + resp, err := c.Do(req) + Expect(err).ToNot(HaveOccurred()) + resp.Body.Close() + Expect(req.Header).ToNot(HaveKey("User-Agent")) + }) + }) + + Describe("HTTPUserAgent", func() { + It("identifies Navidrome with version and project URL", func() { + Expect(consts.HTTPUserAgent).To(Equal("Navidrome/" + consts.Version + " - https://github.com/navidrome")) + }) + }) +}) From cb0a6cedd6c445d8040df3ac01fe69d06ca27b75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Tue, 25 Aug 2026 10:58:58 -0400 Subject: [PATCH 24/78] fix(scanner): keep album tag order from the files instead of alphabetical (#5872) Album-level tags were ordered by frequency and then alphabetically by value. Album.Genre is just the first genre in that list, so any album whose genres tie on frequency, which is the normal case, displayed the alphabetically first genre rather than the first one in the file. A file tagged "Native American New Age; Indigenous American Traditional Music; Ambient" showed up as "Ambient". Break frequency ties on order of appearance instead. This affects all album-level tags, so mood tagged "Happy; Chill" now keeps that order too. MediaFiles.ToAlbum already sorts the files by path before flattening their tags, so the aggregated order stays deterministic across scans. Only album genre was affected; media_file tags already preserved file order. --- model/mediafile_test.go | 6 +++--- model/tag.go | 8 ++++++-- model/tag_test.go | 4 ++-- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/model/mediafile_test.go b/model/mediafile_test.go index 097e3ca54..9ca3489bb 100644 --- a/model/mediafile_test.go +++ b/model/mediafile_test.go @@ -218,11 +218,11 @@ var _ = Describe("MediaFiles", func() { {Tags: Tags{"genre": []string{"Alternative", "Rock"}}}, } }) - It("sets the correct Genre, sorted by frequency, then alphabetically", func() { + It("sets the correct Genre, sorted by frequency, then by order of appearance", func() { album := mfs.ToAlbum() Expect(album.Tags).To(HaveLen(2)) - Expect(album.Tags).To(HaveKeyWithValue(TagGenre, []string{"Rock", "Alternative", "Punk"})) - Expect(album.Tags).To(HaveKeyWithValue(TagMood, []string{"Chill", "Happy"})) + Expect(album.Tags).To(HaveKeyWithValue(TagGenre, []string{"Rock", "Punk", "Alternative"})) + Expect(album.Tags).To(HaveKeyWithValue(TagMood, []string{"Happy", "Chill"})) }) }) When("we have tags with mismatching case", func() { diff --git a/model/tag.go b/model/tag.go index bb4fce181..234cfb359 100644 --- a/model/tag.go +++ b/model/tag.go @@ -24,13 +24,17 @@ type TagList []Tag func (l TagList) GroupByFrequency() Tags { grouped := map[string]map[string]int{} values := map[string]string{} - for _, t := range l { + firstSeen := map[string]int{} + for i, t := range l { if m, ok := grouped[string(t.TagName)]; !ok { grouped[string(t.TagName)] = map[string]int{t.ID: 1} } else { m[t.ID]++ } values[t.ID] = t.TagValue + if _, ok := firstSeen[t.ID]; !ok { + firstSeen[t.ID] = i + } } tags := Tags{} @@ -42,7 +46,7 @@ func (l TagList) GroupByFrequency() Tags { slices.SortFunc(idList, func(a, b string) int { return cmp.Or( cmp.Compare(counts[b], counts[a]), - cmp.Compare(values[a], values[b]), + cmp.Compare(firstSeen[a], firstSeen[b]), ) }) tags[TagName(name)] = slice.Map(idList, func(id string) string { return values[id] }) diff --git a/model/tag_test.go b/model/tag_test.go index c01aa0b4c..4dc99019b 100644 --- a/model/tag_test.go +++ b/model/tag_test.go @@ -93,7 +93,7 @@ var _ = Describe("Tag", func() { Expect(groupedTags).To(HaveKeyWithValue(TagName("artist"), []string{"The Beatles", "The Rolling Stones"})) }) - It("should sort tags by name when frequency is the same", func() { + It("should keep the order the values appeared in when frequency is the same", func() { tagList := TagList{ NewTag("genre", "Jazz"), NewTag("genre", "Rock"), @@ -103,7 +103,7 @@ var _ = Describe("Tag", func() { groupedTags := tagList.GroupByFrequency() - Expect(groupedTags).To(HaveKeyWithValue(TagName("genre"), []string{"Alternative", "Jazz", "Pop", "Rock"})) + Expect(groupedTags).To(HaveKeyWithValue(TagName("genre"), []string{"Jazz", "Rock", "Alternative", "Pop"})) }) It("should normalize casing", func() { tagList := TagList{ From 97da9993d76126cadc45f54159ed6e5ce0932230 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Tue, 25 Aug 2026 18:48:43 -0400 Subject: [PATCH 25/78] fix(stream): abort the response when a transcoded stream is truncated (#6035) * fix(stream): abort the response when a transcoded stream is truncated When a transcode failed after some audio had already been sent, Serve logged the error and returned nil, so Go finished the chunked body normally and the client received an apparently complete, silently short file. Symfonium users hit this on large offline syncs, and the worst path, ffmpeg dying mid-write behind the transcoding cache, produced no error and nothing in the log above Debug: the cache writer was closed plainly, so readers drained the truncated entry to a clean EOF. The root cause of that silence is an fscache limitation: Close is the only way to end a cache write, and Close always means "complete". This adopts the deluan/fscache fork, which adds CloseWithError: on failure copyAndClose now cancels the entry with the cause, so every attached reader fails mid-read with the real error instead of EOF, a late Get for the entry is refused, and the entry never reports a final size. The error travels inside the entry each reader holds, which makes per-generation delivery automatic and needs no bookkeeping on our side. With the failure arriving in-band, one change in Serve covers every mode: an io.Copy error after bytes are on the wire panics with http.ErrAbortHandler. Go aborts the response without the terminating chunk (RST_STREAM on HTTP/2), chi's Recoverer re-panics that value, and the deferred stream.Close() still runs, so the transcode limiter slot is released as before. Two behaviors improve as side effects. A transcoder that dies before its first byte now yields a Subsonic error response instead of a 200 with an empty body, since the failure reaches Serve as an error while the status is still unsent; genuinely empty output (clean EOF, exit 0) keeps the 200. And a failed entry's invalidation no longer defers its unlink past a replacement entry re-creating the same file, because canceling already closed its readers. * fix(cache): warn when the cache writer cannot report failures to readers The CloseWithError capability comes from the fscache fork via a go.mod replace directive, and a type assertion picks it up. If that directive is ever lost, the assertion fails silently, readers of a dead writer go back to draining a truncated entry to a clean EOF, and nothing says so. Two layers against that: a warning on the failure path when the writer lacks the capability, and a test that asserts the writer fscache returns carries it, so losing the fork fails CI instead of a listener's download. * build: point the fscache replace at the fork's master deluan/fscache#1 is merged; pin the merge commit instead of the review branch. Pinned by sha because the module proxy still resolves the fork's master ref to its pre-merge commit. * build: reference the upstream fscache PR in the replace comment The replace itself must keep pointing at the fork: the commit only exists in djherbis/fscache under refs/pull/22/head, which the Go module fetcher cannot resolve (verified: unknown revision for both short and full sha). The same commit is advertised on the fork's master, so that is the fetchable source. --- core/stream/media_streamer.go | 6 ++- core/stream/media_streamer_test.go | 51 ++++++++++++++++++++++++ go.mod | 3 ++ go.sum | 4 +- utils/cache/file_caches.go | 11 ++++++ utils/cache/file_caches_test.go | 62 ++++++++++++++++++++++++++---- 6 files changed, 126 insertions(+), 11 deletions(-) diff --git a/core/stream/media_streamer.go b/core/stream/media_streamer.go index b09d9bab8..aaa3126b4 100644 --- a/core/stream/media_streamer.go +++ b/core/stream/media_streamer.go @@ -152,8 +152,9 @@ func (s *Stream) EstimatedContentLength() int { // Serve writes the stream to the HTTP response. For seekable streams it uses http.ServeContent // (supporting range requests). For non-seekable streams it writes directly and logs any errors. -// Returns the number of bytes written and an error only when io.Copy fails with 0 bytes written +// Returns the number of bytes written and an error only when it fails with 0 bytes written // (meaning the HTTP 200 status has not been flushed yet and the caller can still send an error response). +// Once bytes are on the wire it panics with http.ErrAbortHandler instead, aborting the response. // Empty output (0 bytes, no error) is logged but not treated as an error. func (s *Stream) Serve(ctx context.Context, w http.ResponseWriter, r *http.Request) (int64, error) { if s.Seekable() { @@ -183,7 +184,8 @@ func (s *Stream) Serve(ctx context.Context, w http.ResponseWriter, r *http.Reque w.Header().Del("Content-Length") return 0, fmt.Errorf("sending transcoded file: %w", err) } - return c, nil + // The 200 is already sent, so dropping the connection is the only way to say "truncated". + panic(http.ErrAbortHandler) } if c == 0 { log.Error(ctx, "Transcoding returned empty output, ffmpeg may have failed. "+ diff --git a/core/stream/media_streamer_test.go b/core/stream/media_streamer_test.go index fb1c59a60..e06599208 100644 --- a/core/stream/media_streamer_test.go +++ b/core/stream/media_streamer_test.go @@ -1,12 +1,18 @@ package stream_test import ( + "bytes" "context" "errors" "io" + "net/http" + "net/http/httptest" "os" + "testing/iotest" "time" + "github.com/go-chi/chi/v5/middleware" + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core/stream" @@ -140,4 +146,49 @@ var _ = Describe("MediaStreamer", func() { Expect(s.Seekable()).To(BeTrue()) }) }) + + Context("Serve", func() { + var mf *model.MediaFile + BeforeEach(func() { + var err error + mf, err = ds.MediaFile(ctx).Get("123") + Expect(err).ToNot(HaveOccurred()) + }) + + It("keeps empty output a non-error, so callers still reply 200 with an empty body", func() { + s := stream.NewStream(mf, "mp3", 128, io.NopCloser(bytes.NewReader(nil))) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/", nil) + + n, err := s.Serve(ctx, w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(BeZero()) + Expect(w.Code).To(Equal(http.StatusOK)) + }) + + It("aborts the response when the source fails after sending data", func() { + src := io.NopCloser(io.MultiReader( + bytes.NewReader(bytes.Repeat([]byte("a"), 64*1024)), + iotest.ErrReader(errors.New("transcoder died")), + )) + server := httptest.NewServer(serveHandler(stream.NewStream(mf, "mp3", 128, src))) + DeferCleanup(server.Close) + + resp, err := http.Get(server.URL) + Expect(err).ToNot(HaveOccurred()) + defer resp.Body.Close() + + // A client-side read failure is the only observable proof the response was aborted. + _, err = io.ReadAll(resp.Body) + Expect(err).To(HaveOccurred()) + }) + }) }) + +// Serve runs behind the real server's Recoverer, which must let ErrAbortHandler through. +func serveHandler(s *stream.Stream) http.Handler { + return middleware.Recoverer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = s.Serve(r.Context(), w, r) + })) +} diff --git a/go.mod b/go.mod index 65a01a9b5..c0809f7b5 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,9 @@ go 1.26 // Fork to implement raw tags support replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3 +// Fork to implement CloseWithError, proposed upstream in https://github.com/djherbis/fscache/pull/22 +replace github.com/djherbis/fscache => github.com/deluan/fscache v0.9.1-0.20260825221051-a07d597526e2 + require ( github.com/Masterminds/squirrel v1.5.4 github.com/andybalholm/cascadia v1.3.4 diff --git a/go.sum b/go.sum index c11f4bb20..8be5a910c 100644 --- a/go.sum +++ b/go.sum @@ -29,6 +29,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/deluan/fscache v0.9.1-0.20260825221051-a07d597526e2 h1:s254V2hsrrCJXYtAn9WPG/5p4QHenfL9E+j6Tiq5MW4= +github.com/deluan/fscache v0.9.1-0.20260825221051-a07d597526e2/go.mod h1:eNFa48vJrse+8ysT4IJnnUeXwLZNcR0JQumU/W/QoUI= github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3 h1:j7eSXqgtjhlNfwnMEzRdXnJGZTEw4I7J9TeQAll83bU= github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3/go.mod h1:QGxQ4Z1IWyY9w56xNEFjYAaWE8uSxA/gneQ7RPcFJrY= github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf h1:tb246l2Zmpt/GpF9EcHCKTtwzrd0HGfEmoODFA/qnk4= @@ -39,8 +41,6 @@ github.com/dexterlb/mpvipc v0.0.0-20260722094525-0cf47d745b36 h1:KtPfdSST6e0vJbM github.com/dexterlb/mpvipc v0.0.0-20260722094525-0cf47d745b36/go.mod h1:RkQWLNITKkXHLP7LXxZSgEq+uFWU25M5qW7qfEhL9Wc= github.com/djherbis/atime v1.1.0 h1:rgwVbP/5by8BvvjBNrbh64Qz33idKT3pSnMSJsxhi0g= github.com/djherbis/atime v1.1.0/go.mod h1:28OF6Y8s3NQWwacXc5eZTsEsiMzp7LF8MbXE+XJPdBE= -github.com/djherbis/fscache v0.10.2-0.20231127215153-442a07e326c4 h1:wdZllsLrDJtYfHiAKogB4PNHSDeO+v+5S3eqSWHGDlc= -github.com/djherbis/fscache v0.10.2-0.20231127215153-442a07e326c4/go.mod h1:dHWjlanKIxaHVH1xJOTb4kzP800XdcXlgJ6JYlR2DPU= github.com/djherbis/stream v1.4.0 h1:aVD46WZUiq5kJk55yxJAyw6Kuera6kmC3i2vEQyW/AE= github.com/djherbis/stream v1.4.0/go.mod h1:cqjC1ZRq3FFwkGmUtHwcldbnW8f0Q4YuVsGW1eAFtOk= github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= diff --git a/utils/cache/file_caches.go b/utils/cache/file_caches.go index dff9e4e7a..48cd135cb 100644 --- a/utils/cache/file_caches.go +++ b/utils/cache/file_caches.go @@ -255,6 +255,17 @@ func (fc *fileCache) copyAndClose(ctx context.Context, key string, w io.WriteClo } if err == nil { fc.markComplete(ctx, key) + } else if cw, ok := w.(interface{ CloseWithError(error) error }); ok { + // Cancel instead of close, so readers fail with the cause rather than + // draining a truncated entry to a clean EOF. + if cErr := cw.CloseWithError(err); cErr != nil { + // Join, not Append: err is now shared with readers and must not be mutated. + return errors.Join(err, fmt.Errorf("closing cache writer: %w", cErr)) + } + return err + } else { + log.Warn(ctx, "Cache writer cannot report failures; readers will see a truncated entry as a clean EOF", + "cache", fc.name, "key", key, err) } 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 974200656..3189de6b2 100644 --- a/utils/cache/file_caches_test.go +++ b/utils/cache/file_caches_test.go @@ -259,6 +259,54 @@ var _ = Describe("File Caches", func() { }).Should(BeTrue()) }) + It("gets a writer that can report failures to readers", func() { + // Guards the fork adoption: if the fscache replace directive is ever lost, + // this fails in CI instead of silently reviving the truncation bug. + fc := callNewFileCache("test", "10MB", "test", 0, nil) + _, w, err := fc.cache.Get("capability") + Expect(err).To(BeNil()) + DeferCleanup(func() { _ = w.Close() }) + + _, ok := w.(interface{ CloseWithError(error) error }) + Expect(ok).To(BeTrue(), "fscache writer lost CloseWithError; check the go.mod replace directive") + }) + + It("fails the reader with the cause instead of a clean EOF", func() { + fc := callNewFileCache("test", "10MB", "test", 0, func(ctx context.Context, arg Item) (io.Reader, error) { + return &partialThenErrReader{data: []byte("PARTIAL"), err: errors.New("transcoder died")}, nil + }) + s, err := fc.Get(context.Background(), &testArg{"inband"}) + Expect(err).To(BeNil()) + DeferCleanup(func() { _ = s.Close() }) + + _, err = io.ReadAll(s) + Expect(err).To(MatchError(ContainSubstring("transcoder died"))) + }) + + It("fails a reader that joined mid-write with the same cause", func() { + pr, pw := io.Pipe() + fc := callNewFileCache("test", "10MB", "test", 0, func(ctx context.Context, arg Item) (io.Reader, error) { + return pr, nil + }) + s1, err := fc.Get(context.Background(), &testArg{"joined"}) + Expect(err).To(BeNil()) + DeferCleanup(func() { _ = s1.Close() }) + + // The blocking pipe write gives a happens-before: the entry is in flight. + _, err = pw.Write([]byte("PARTIAL")) + Expect(err).To(BeNil()) + + s2, err := fc.Get(context.Background(), &testArg{"joined"}) + Expect(err).To(BeNil()) + DeferCleanup(func() { _ = s2.Close() }) + Expect(s2.Cached).To(BeTrue()) + + Expect(pw.CloseWithError(errors.New("transcoder died"))).To(Succeed()) + + _, err = io.ReadAll(s2) + Expect(err).To(MatchError(ContainSubstring("transcoder died"))) + }) + It("does not write a completion marker when the write fails after partial bytes", func() { // Mimics a transcode that produces real output and then dies: // the bytes land on disk, but the entry must NOT be marked complete. @@ -304,9 +352,9 @@ var _ = Describe("File Caches", func() { 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. + It("removes a failed entry promptly, without eating its replacement", func() { + // Cancel closes the failed entry's readers, so its removal no longer defers + // past the point where a new entry re-creates the same file. var n atomic.Int32 fc := callNewFileCache("test", "10MB", "test", 0, func(ctx context.Context, arg Item) (io.Reader, error) { if n.Add(1) == 1 { @@ -319,7 +367,6 @@ var _ = Describe("File Caches", func() { 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"}) @@ -330,15 +377,16 @@ var _ = Describe("File Caches", func() { Expect(s1.Close()).To(Succeed()) dataPath := fcSpreadFS(fc).KeyMapper(key) - Eventually(func() bool { + Consistently(func() error { _, e := os.Stat(dataPath) - return os.IsNotExist(e) - }).Should(BeTrue(), "expected the deferred removal to take the re-created file") + return e + }).Should(Succeed(), "the replacement entry's file must survive the failed entry's cleanup") s3, err := fc.Get(context.Background(), &testArg{"deferred"}) Expect(err).ToNot(HaveOccurred()) Expect(io.ReadAll(s3)).To(Equal([]byte("GOOD"))) _ = s3.Close() + Expect(n.Load()).To(Equal(int32(2)), "the third Get must be served from cache") }) It("re-fetches when an adopted entry's data file vanished", func() { From f08b5297ee01e1c1195344f32204a8ffcd107750 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Tue, 25 Aug 2026 23:59:40 -0400 Subject: [PATCH 26/78] feat(ui): add Refresh Metadata to the album and artist context menus (#6036) * refactor(artwork): move artworkItemName into core/artwork as ItemName * feat(external): add RefreshInfo to force an external info refresh RefreshInfo re-fetches and re-saves external info for one artist or album, bypassing the TTL check that UpdateArtistInfo/UpdateAlbumInfo use. It is synchronous; callers that must not block detach it themselves. Also makes MockArtistRepo/MockAlbumRepo.UpdateExternalInfo persist to Data (previously a no-op) and adds the new method to the e2e noopProvider, both required so the interface addition compiles and is observable in tests. * feat(external): broadcast RefreshResource after external info is saved populateArtistInfo and populateAlbumInfo now emit the same RefreshResource event the artwork worker uses, so the UI learns about both foreground and background metadata refreshes. * feat(nativeapi): replace artwork refresh endpoint with metadata refresh * feat(ui): add refreshMetadata to the data provider * feat(ui): add a Refresh Metadata item to the album and artist context menus * fix(ui): re-fetch artist info when the record is refreshed * test: fix mislabeled spec, add kind-gate negative case, guard nil mock maps - Rename the RefreshInfo spec that claimed to cover the save-failure/broadcast path: SetError(true) fails Get too, so it only proves RefreshInfo bails out early at getArtist. - Add a spec proving playlist refreshes skip the external-info step, since that asymmetry (al/ar only) was documented but unasserted. - Add lazy nil-map init to MockAlbumRepo/MockArtistRepo.UpdateExternalInfo so a composite-literal-constructed mock doesn't panic on first save. * test: relocate discArtworkName specs from cmd to core/artwork artworkItemName moved into core/artwork as ItemName in an earlier commit, but its disc-name specs stayed behind in cmd/artwork_test.go, reaching across packages. Move them to core/artwork/item_name_test.go where the code now lives. * fix(ui): shape refreshMetadata like a react-admin response react-admin validates custom dataProvider methods and rejects any response without a `data` key, so the raw httpClient promise made every click surface an error toast instead of the success message. The unit test mocked useDataProvider, which skips that validation. Also folds "which kinds have external info" into external.HasInfo so the handler stops restating it, drops the nil-broker guard that only existed for tests, and delegates the mocks' UpdateExternalInfo to Put. * refactor(external): unexport infoKinds Only HasInfo is used outside the package, so the slice itself does not need to be exported. * refactor(artwork): fold ItemName into housekeeping.go next to Refresh ItemName exists to guard Refresh from ids that would orphan a queue row, and both callers invoke them back to back. A separate file hid that pairing; it was only split out to keep the move out of cmd/ legible in review. * fix(nativeapi): return 500 when the refresh lookup fails for a non-ErrNotFound reason A transient repository error told the admin the id did not exist, and the error was dropped without a log line, so nothing pointed at the real cause. Also drops the inherited claim that clearing artwork state shows a placeholder. Reads fall back to local resolution, so that only holds when there is no local art. * fix(ui): move Refresh Metadata above Get Info in the context menu Menu order follows key insertion order in the options object, so the new spec pins the position rather than leaving it to be shuffled by the next addition. --- cmd/artwork.go | 61 +----- cmd/artwork_test.go | 27 --- cmd/wire_gen.go | 9 +- core/artwork/housekeeping.go | 57 ++++++ core/artwork/housekeeping_test.go | 53 ++++++ core/external/provider.go | 47 ++++- core/external/provider_refreshinfo_test.go | 156 +++++++++++++++ core/external/provider_similarsongs_test.go | 2 +- core/external/provider_topsongs_test.go | 2 +- .../external/provider_updatealbuminfo_test.go | 2 +- .../provider_updateartistinfo_test.go | 2 +- resources/i18n/pt-br.json | 4 +- server/nativeapi/artwork.go | 34 ---- server/nativeapi/artwork_test.go | 95 ---------- server/nativeapi/config_test.go | 2 +- server/nativeapi/library_test.go | 2 +- server/nativeapi/metadata.go | 56 ++++++ server/nativeapi/metadata_test.go | 177 ++++++++++++++++++ server/nativeapi/native_api.go | 8 +- server/nativeapi/native_api_song_test.go | 2 +- server/nativeapi/playlists_test.go | 2 +- server/nativeapi/plugin_test.go | 2 +- .../user_password_token_refresh_test.go | 2 +- server/subsonic/e2e/e2e_suite_test.go | 4 + tests/mock_album_repo.go | 8 +- tests/mock_artist_repo.go | 8 +- ui/src/artist/ArtistShow.jsx | 6 +- ui/src/artist/ArtistShow.test.jsx | 63 +++++++ ui/src/common/ContextMenus.jsx | 18 +- ui/src/common/ContextMenus.test.jsx | 57 +++++- ui/src/dataProvider/wrapperDataProvider.js | 8 + .../dataProvider/wrapperDataProvider.test.js | 33 ++++ ui/src/i18n/en.json | 4 +- 33 files changed, 766 insertions(+), 247 deletions(-) create mode 100644 core/external/provider_refreshinfo_test.go delete mode 100644 server/nativeapi/artwork.go delete mode 100644 server/nativeapi/artwork_test.go create mode 100644 server/nativeapi/metadata.go create mode 100644 server/nativeapi/metadata_test.go create mode 100644 ui/src/artist/ArtistShow.test.jsx diff --git a/cmd/artwork.go b/cmd/artwork.go index 8b9e28f0e..5cd4fc146 100644 --- a/cmd/artwork.go +++ b/cmd/artwork.go @@ -648,7 +648,7 @@ func refreshItems(ctx context.Context, ds model.DataStore, targets []model.Artwo for _, t := range targets { kind, id := t.Kind, t.ID // artwork.Refresh would happily queue an id that does not exist, orphaning a queue row. - if _, err := artworkItemName(ctx, ds, kind, id); err != nil { + if _, err := artwork.ItemName(ctx, ds, kind, id); err != nil { log.Error(ctx, "Item not found", "kind", kind, "id", id, err) failed++ continue @@ -963,7 +963,7 @@ func runExplain(ctx context.Context, args []string) { } kind, id := targets[0].Kind, targets[0].ID - name, err := artworkItemName(ctx, ds, kind, id) + name, err := artwork.ItemName(ctx, ds, kind, id) if err != nil { log.Fatal(ctx, "Item not found", "kind", kind, "id", id, err) } @@ -1005,60 +1005,3 @@ func runExplain(ctx context.Context, args []string) { log.Fatal(ctx, "Failed to resolve artwork", "kind", kind, "id", id, rep.resolveErr) } } - -// artworkItemName looks the entity up under its own kind, so a mismatched kind/id pair is -// reported as not found instead of silently explaining another entity's artwork. -func artworkItemName(ctx context.Context, ds model.DataStore, kind model.Kind, id string) (string, error) { - switch kind { - case model.KindArtistArtwork: - ar, err := ds.Artist(ctx).Get(id) - if err != nil { - return "", err - } - return ar.Name, nil - case model.KindAlbumArtwork: - al, err := ds.Album(ctx).Get(id) - if err != nil { - return "", err - } - return al.Name, nil - case model.KindPlaylistArtwork: - pls, err := ds.Playlist(ctx).Get(id) - if err != nil { - return "", err - } - return pls.Name, nil - case model.KindRadioArtwork: - rd, err := ds.Radio(ctx).Get(id) - if err != nil { - return "", err - } - return rd.Name, nil - case model.KindMediaFileArtwork: - mf, err := ds.MediaFile(ctx).Get(id) - if err != nil { - return "", err - } - return mf.Title, nil - case model.KindDiscArtwork: - return discArtworkName(ctx, ds, id) - } - return "", fmt.Errorf("unsupported kind %q", kind.Prefix()) -} - -func discArtworkName(ctx context.Context, ds model.DataStore, id string) (string, error) { - albumID, discNumber, err := model.ParseDiscArtworkID(id) - if err != nil { - return "", err - } - al, err := ds.Album(ctx).Get(albumID) - if err != nil { - return "", err - } - name := fmt.Sprintf("%s (disc %d)", al.Name, discNumber) - // The subtitle is itself a DiscArtPriority candidate, so name it where the chain can be read against it. - if subtitle := strings.TrimSpace(al.Discs[discNumber]); subtitle != "" { - name += ": " + subtitle - } - return name, nil -} diff --git a/cmd/artwork_test.go b/cmd/artwork_test.go index f17206aaa..a7220d2d5 100644 --- a/cmd/artwork_test.go +++ b/cmd/artwork_test.go @@ -424,33 +424,6 @@ var _ = Describe("explainConfig", func() { ) }) -var _ = Describe("discArtworkName", func() { - var ds *tests.MockDataStore - - BeforeEach(func() { - albumRepo := tests.CreateMockAlbumRepo() - albumRepo.SetData(model.Albums{{ID: "al-1", Name: "Sandinista!", Discs: model.Discs{2: "Side Three"}}}) - ds = &tests.MockDataStore{MockedAlbum: albumRepo} - }) - - It("names the album, the disc and its subtitle", func() { - name, err := artworkItemName(context.Background(), ds, model.KindDiscArtwork, "al-1:2") - Expect(err).ToNot(HaveOccurred()) - Expect(name).To(Equal("Sandinista! (disc 2): Side Three")) - }) - - It("omits the subtitle when the disc has none", func() { - name, err := artworkItemName(context.Background(), ds, model.KindDiscArtwork, "al-1:1") - Expect(err).ToNot(HaveOccurred()) - Expect(name).To(Equal("Sandinista! (disc 1)")) - }) - - It("rejects an id that is not :", func() { - _, err := artworkItemName(context.Background(), ds, model.KindDiscArtwork, "al-1") - Expect(err).To(HaveOccurred()) - }) -}) - var _ = Describe("artwork refresh command", func() { It("requires at least one argument", func() { Expect(artworkRefreshCmd.Args(artworkRefreshCmd, []string{})).To(HaveOccurred()) diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 49a99f8ca..de4c55a1e 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -76,7 +76,10 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router { 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, uploader) + agentsAgents := agents.GetAgents(dataStore, manager) + matcherMatcher := matcher.New(dataStore) + provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker) + router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, uploader, provider) return router } @@ -97,7 +100,7 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { manager := plugins.GetManager(dataStore, broker, metricsMetrics) agentsAgents := agents.GetAgents(dataStore, manager) matcherMatcher := matcher.New(dataStore) - provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher) + provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker) uploader := artwork.NewUploader(dataStore) playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader) modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics) @@ -129,7 +132,7 @@ func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router { playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader) agentsAgents := agents.GetAgents(dataStore, manager) matcherMatcher := matcher.New(dataStore) - provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher) + provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker) 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) diff --git a/core/artwork/housekeeping.go b/core/artwork/housekeeping.go index ce98e2a03..a3330d7cf 100644 --- a/core/artwork/housekeeping.go +++ b/core/artwork/housekeeping.go @@ -166,6 +166,63 @@ func enqueueMissingAll(ctx context.Context, ds model.DataStore) error { return nil } +// ItemName resolves a kind+id to the entity's display name, and errors when the item +// does not exist. Callers use it to reject ids that would otherwise orphan a queue row. +func ItemName(ctx context.Context, ds model.DataStore, kind model.Kind, id string) (string, error) { + switch kind { + case model.KindArtistArtwork: + ar, err := ds.Artist(ctx).Get(id) + if err != nil { + return "", err + } + return ar.Name, nil + case model.KindAlbumArtwork: + al, err := ds.Album(ctx).Get(id) + if err != nil { + return "", err + } + return al.Name, nil + case model.KindPlaylistArtwork: + pls, err := ds.Playlist(ctx).Get(id) + if err != nil { + return "", err + } + return pls.Name, nil + case model.KindRadioArtwork: + rd, err := ds.Radio(ctx).Get(id) + if err != nil { + return "", err + } + return rd.Name, nil + case model.KindMediaFileArtwork: + mf, err := ds.MediaFile(ctx).Get(id) + if err != nil { + return "", err + } + return mf.Title, nil + case model.KindDiscArtwork: + return discArtworkName(ctx, ds, id) + } + return "", fmt.Errorf("unsupported kind %q", kind.Prefix()) +} + +func discArtworkName(ctx context.Context, ds model.DataStore, id string) (string, error) { + albumID, discNumber, err := model.ParseDiscArtworkID(id) + if err != nil { + return "", err + } + al, err := ds.Album(ctx).Get(albumID) + if err != nil { + return "", err + } + name := fmt.Sprintf("%s (disc %d)", al.Name, discNumber) + // The subtitle is itself a DiscArtPriority candidate, so name it where the chain can be read against it. + if subtitle := strings.TrimSpace(al.Discs[discNumber]); subtitle != "" { + name += ": " + subtitle + } + return name, 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 { diff --git a/core/artwork/housekeeping_test.go b/core/artwork/housekeeping_test.go index 7aecd2760..c9809203a 100644 --- a/core/artwork/housekeeping_test.go +++ b/core/artwork/housekeeping_test.go @@ -332,3 +332,56 @@ var _ = Describe("Housekeeping", func() { }) }) }) + +var _ = Describe("ItemName", func() { + var ds *tests.MockDataStore + var ctx context.Context + + BeforeEach(func() { + ctx = context.Background() + albumRepo := tests.CreateMockAlbumRepo() + albumRepo.SetData(model.Albums{ + {ID: "al-1", Name: "Kid A"}, + {ID: "al-2", Name: "Sandinista!", Discs: model.Discs{2: "Side Three"}}, + }) + ds = &tests.MockDataStore{MockedAlbum: albumRepo} + Expect(ds.Artist(ctx).(*tests.MockArtistRepo).Put(&model.Artist{ID: "ar-1", Name: "Radiohead"})).To(Succeed()) + }) + + It("returns the album name", func() { + Expect(ItemName(ctx, ds, model.KindAlbumArtwork, "al-1")).To(Equal("Kid A")) + }) + + It("returns the artist name", func() { + Expect(ItemName(ctx, ds, model.KindArtistArtwork, "ar-1")).To(Equal("Radiohead")) + }) + + It("errors for an unknown album", func() { + _, err := ItemName(ctx, ds, model.KindAlbumArtwork, "nope") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("errors for an unsupported kind", func() { + // model.Kind is a struct with unexported fields, so the zero value is the only + // unsupported Kind constructible from outside package model. + _, err := ItemName(ctx, ds, model.Kind{}, "al-1") + Expect(err).To(HaveOccurred()) + }) + + Context("disc artwork", func() { + It("names the album, the disc and its subtitle", func() { + Expect(ItemName(ctx, ds, model.KindDiscArtwork, "al-2:2")). + To(Equal("Sandinista! (disc 2): Side Three")) + }) + + It("omits the subtitle when the disc has none", func() { + Expect(ItemName(ctx, ds, model.KindDiscArtwork, "al-2:1")). + To(Equal("Sandinista! (disc 1)")) + }) + + It("rejects an id that is not :", func() { + _, err := ItemName(ctx, ds, model.KindDiscArtwork, "al-2") + Expect(err).To(HaveOccurred()) + }) + }) +}) diff --git a/core/external/provider.go b/core/external/provider.go index 782c7c3aa..5c46dc644 100644 --- a/core/external/provider.go +++ b/core/external/provider.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "slices" "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/server/events" "github.com/navidrome/navidrome/utils" . "github.com/navidrome/navidrome/utils/gg" "github.com/navidrome/navidrome/utils/slice" @@ -33,12 +35,14 @@ 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) + RefreshInfo(ctx context.Context, kind model.Kind, id string) error } type provider struct { ds model.DataStore ag Agents matcher *matcher.Matcher + broker events.Broker artistQueue refreshQueue[auxArtist] albumQueue refreshQueue[auxAlbum] } @@ -83,13 +87,17 @@ type Agents interface { agents.SimilarSongsByArtistRetriever } -func NewProvider(ds model.DataStore, agents Agents, m *matcher.Matcher) Provider { - e := &provider{ds: ds, ag: agents, matcher: m} +func NewProvider(ds model.DataStore, agents Agents, m *matcher.Matcher, broker events.Broker) Provider { + e := &provider{ds: ds, ag: agents, matcher: m, broker: broker} e.artistQueue = newRefreshQueue(context.TODO(), e.populateArtistInfo) e.albumQueue = newRefreshQueue(context.TODO(), e.populateAlbumInfo) return e } +func (e *provider) broadcastRefresh(ctx context.Context, resource, id string) { + e.broker.SendBroadcastMessage(ctx, (&events.RefreshResource{}).With(resource, id)) +} + func (e *provider) getAlbum(ctx context.Context, id string) (auxAlbum, error) { var entity any entity, err := model.GetEntityByID(ctx, e.ds, id) @@ -179,6 +187,7 @@ func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAl "elapsed", time.Since(start), err) } else { log.Trace(ctx, "AlbumInfo collected", "album", album, "elapsed", time.Since(start)) + e.broadcastRefresh(ctx, "album", album.ID) } return album, nil @@ -272,10 +281,44 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au "elapsed", time.Since(start), err) } else { log.Trace(ctx, "ArtistInfo collected", "artist", artist, "elapsed", time.Since(start)) + e.broadcastRefresh(ctx, "artist", artist.ID) } return artist, nil } +// infoKinds are the kinds RefreshInfo can act on. Callers check this instead of restating +// the set, so the switch below stays the only place that has to know how each kind loads. +var infoKinds = []model.Kind{model.KindArtistArtwork, model.KindAlbumArtwork} + +// HasInfo reports whether a kind has external info to refresh. +func HasInfo(kind model.Kind) bool { return slices.Contains(infoKinds, kind) } + +// RefreshInfo re-fetches external info for one item, ignoring the TTL. It is synchronous: +// callers that must not block are responsible for detaching it. +func (e *provider) RefreshInfo(ctx context.Context, kind model.Kind, id string) error { + ctx, cancel := context.WithTimeout(ctx, refreshTimeout) + defer cancel() + + switch kind { + case model.KindArtistArtwork: + artist, err := e.getArtist(ctx, id) + if err != nil { + return err + } + _, err = e.populateArtistInfo(ctx, artist) + return err + case model.KindAlbumArtwork: + album, err := e.getAlbum(ctx, id) + if err != nil { + return err + } + _, err = e.populateAlbumInfo(ctx, album) + return err + default: + return model.ErrNotFound + } +} + 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_refreshinfo_test.go b/core/external/provider_refreshinfo_test.go new file mode 100644 index 000000000..e7910a734 --- /dev/null +++ b/core/external/provider_refreshinfo_test.go @@ -0,0 +1,156 @@ +package external_test + +import ( + "context" + "slices" + "sync" + "time" + + "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/server/events" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/stretchr/testify/mock" +) + +type fakeBroker struct { + events.Broker + mu sync.Mutex + events []events.Event +} + +func (f *fakeBroker) SendBroadcastMessage(_ context.Context, e events.Event) { + f.mu.Lock() + defer f.mu.Unlock() + f.events = append(f.events, e) +} + +func (f *fakeBroker) sent() []events.Event { + f.mu.Lock() + defer f.mu.Unlock() + return slices.Clone(f.events) +} + +var _ = Describe("Provider - RefreshInfo", func() { + var ( + ctx context.Context + p external.Provider + ds *tests.MockDataStore + ag *mockAgents + broker *fakeBroker + mockArtistRepo *tests.MockArtistRepo + mockAlbumRepo *tests.MockAlbumRepo + ) + + expectArtistAgents := func() { + ag.On("GetArtistMBID", mock.Anything, mock.Anything, mock.Anything).Return("mbid-1", nil) + ag.On("GetArtistImages", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return([]agents.ExternalImage{}, nil) + ag.On("GetArtistBiography", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return("Fresh Bio", nil) + ag.On("GetArtistURL", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return("http://artist.url", nil) + ag.On("GetSimilarArtists", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return([]agents.Artist{}, nil) + } + + expectAlbumAgents := func() { + ag.On("GetAlbumInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(&agents.AlbumInfo{URL: "http://album.url", Description: "Fresh Notes"}, nil) + ag.On("GetAlbumImages", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return([]agents.ExternalImage{}, nil) + } + + BeforeEach(func() { + ctx = GinkgoT().Context() + ds = new(tests.MockDataStore) + ag = new(mockAgents) + broker = &fakeBroker{} + p = external.NewProvider(ds, ag, matcher.New(ds), broker) + mockArtistRepo = ds.Artist(ctx).(*tests.MockArtistRepo) + mockAlbumRepo = ds.Album(ctx).(*tests.MockAlbumRepo) + }) + + It("repopulates an artist even when its info is fresh", func() { + fresh := time.Now() + mockArtistRepo.SetData(model.Artists{{ + ID: "ar-1", Name: "Test Artist", Biography: "stale", ExternalInfoUpdatedAt: &fresh, + }}) + expectArtistAgents() + + Expect(p.RefreshInfo(ctx, model.KindArtistArtwork, "ar-1")).To(Succeed()) + + saved, err := mockArtistRepo.Get("ar-1") + Expect(err).ToNot(HaveOccurred()) + Expect(saved.Biography).To(Equal("Fresh Bio")) + }) + + It("repopulates an album even when its info is fresh", func() { + fresh := time.Now() + mockAlbumRepo.SetData(model.Albums{{ + ID: "al-1", Name: "Test Album", AlbumArtist: "Test Artist", + Description: "stale", ExternalInfoUpdatedAt: &fresh, + }}) + expectAlbumAgents() + + Expect(p.RefreshInfo(ctx, model.KindAlbumArtwork, "al-1")).To(Succeed()) + + saved, err := mockAlbumRepo.Get("al-1") + Expect(err).ToNot(HaveOccurred()) + Expect(saved.Description).To(Equal("Fresh Notes")) + }) + + It("returns ErrNotFound for an unknown id", func() { + Expect(p.RefreshInfo(ctx, model.KindArtistArtwork, "nope")).To(MatchError(model.ErrNotFound)) + }) + + It("returns ErrNotFound for a kind with no external info", func() { + Expect(p.RefreshInfo(ctx, model.KindPlaylistArtwork, "pl-1")).To(MatchError(model.ErrNotFound)) + }) + + It("broadcasts a RefreshResource naming the artist", func() { + mockArtistRepo.SetData(model.Artists{{ID: "ar-1", Name: "Test Artist"}}) + expectArtistAgents() + + Expect(p.RefreshInfo(ctx, model.KindArtistArtwork, "ar-1")).To(Succeed()) + + sent := broker.sent() + Expect(sent).To(HaveLen(1)) + rr, ok := sent[0].(*events.RefreshResource) + Expect(ok).To(BeTrue()) + Expect(rr.Data(rr)).To(ContainSubstring("ar-1")) + Expect(rr.Data(rr)).To(ContainSubstring("artist")) + }) + + It("broadcasts a RefreshResource naming the album", func() { + mockAlbumRepo.SetData(model.Albums{{ID: "al-1", Name: "Test Album", AlbumArtist: "Test Artist"}}) + expectAlbumAgents() + + Expect(p.RefreshInfo(ctx, model.KindAlbumArtwork, "al-1")).To(Succeed()) + + sent := broker.sent() + Expect(sent).To(HaveLen(1)) + Expect(sent[0].Data(sent[0])).To(ContainSubstring("album")) + Expect(sent[0].Data(sent[0])).To(ContainSubstring("al-1")) + }) + + It("does not broadcast when the artist cannot be loaded", func() { + mockArtistRepo.SetData(model.Artists{{ID: "ar-1", Name: "Test Artist"}}) + expectArtistAgents() + mockArtistRepo.SetError(true) + + _ = p.RefreshInfo(ctx, model.KindArtistArtwork, "ar-1") + + Expect(broker.sent()).To(BeEmpty()) + }) + + It("reports which kinds have external info", func() { + Expect(external.HasInfo(model.KindArtistArtwork)).To(BeTrue()) + Expect(external.HasInfo(model.KindAlbumArtwork)).To(BeTrue()) + Expect(external.HasInfo(model.KindPlaylistArtwork)).To(BeFalse()) + }) +}) diff --git a/core/external/provider_similarsongs_test.go b/core/external/provider_similarsongs_test.go index ac54495f4..3944edb68 100644 --- a/core/external/provider_similarsongs_test.go +++ b/core/external/provider_similarsongs_test.go @@ -61,7 +61,7 @@ var _ = Describe("Provider - SimilarSongs", func() { similarAgent: mockSimilarAgent, } - provider = NewProvider(ds, agentsCombined, matcher.New(ds)) + provider = NewProvider(ds, agentsCombined, matcher.New(ds), &fakeBroker{}) }) // Resolves track-1 through the GetEntityByID probe order and on to its artist. Left permissive: diff --git a/core/external/provider_topsongs_test.go b/core/external/provider_topsongs_test.go index 795a7ff74..16e8ad840 100644 --- a/core/external/provider_topsongs_test.go +++ b/core/external/provider_topsongs_test.go @@ -45,7 +45,7 @@ var _ = Describe("Provider - TopSongs", func() { ag = new(mockAgents) - p = NewProvider(ds, ag, matcher.New(ds)) + p = NewProvider(ds, ag, matcher.New(ds), &fakeBroker{}) }) It("returns top songs for a known artist", func() { diff --git a/core/external/provider_updatealbuminfo_test.go b/core/external/provider_updatealbuminfo_test.go index 21824c93f..a54ef922c 100644 --- a/core/external/provider_updatealbuminfo_test.go +++ b/core/external/provider_updatealbuminfo_test.go @@ -34,7 +34,7 @@ var _ = Describe("Provider - UpdateAlbumInfo", func() { ctx = GinkgoT().Context() ds = new(tests.MockDataStore) ag = new(mockAgents) - p = external.NewProvider(ds, ag, matcher.New(ds)) + p = external.NewProvider(ds, ag, matcher.New(ds), &fakeBroker{}) mockAlbumRepo = ds.Album(ctx).(*tests.MockAlbumRepo) conf.Server.DevAlbumInfoTimeToLive = 1 * time.Hour }) diff --git a/core/external/provider_updateartistinfo_test.go b/core/external/provider_updateartistinfo_test.go index d783128fb..853d56bbc 100644 --- a/core/external/provider_updateartistinfo_test.go +++ b/core/external/provider_updateartistinfo_test.go @@ -37,7 +37,7 @@ var _ = Describe("Provider - UpdateArtistInfo", func() { ctx = GinkgoT().Context() ds = new(tests.MockDataStore) ag = new(mockAgents) - p = external.NewProvider(ds, ag, matcher.New(ds)) + p = external.NewProvider(ds, ag, matcher.New(ds), &fakeBroker{}) mockArtistRepo = ds.Artist(ctx).(*tests.MockArtistRepo) }) diff --git a/resources/i18n/pt-br.json b/resources/i18n/pt-br.json index 9a6338ae1..a4ad6bc8c 100644 --- a/resources/i18n/pt-br.json +++ b/resources/i18n/pt-br.json @@ -93,6 +93,7 @@ "addToPlaylist": "Adicionar à playlist", "download": "Baixar", "info": "Detalhes", + "refresh": "Atualizar Metadados", "share": "Compartilhar" }, "lists": { @@ -602,7 +603,8 @@ "coverUploaded": "Capa atualizada", "coverRemoved": "Capa removida", "coverUploadError": "Erro ao enviar capa", - "coverRemoveError": "Erro ao remover capa" + "coverRemoveError": "Erro ao remover capa", + "metadataRefreshStarted": "Atualizando metadados em segundo plano" }, "menu": { "library": "Biblioteca", diff --git a/server/nativeapi/artwork.go b/server/nativeapi/artwork.go deleted file mode 100644 index cfd943b1f..000000000 --- a/server/nativeapi/artwork.go +++ /dev/null @@ -1,34 +0,0 @@ -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" -) - -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(artwork.RefreshableKinds, 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 deleted file mode 100644 index b26a94e6a..000000000 --- a/server/nativeapi/artwork_test.go +++ /dev/null @@ -1,95 +0,0 @@ -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/config_test.go b/server/nativeapi/config_test.go index 107b01e01..d1007f457 100644 --- a/server/nativeapi/config_test.go +++ b/server/nativeapi/config_test.go @@ -29,7 +29,7 @@ var _ = Describe("Config API", func() { conf.Server.DevUIShowConfig = true // Enable config endpoint for tests ds = &tests.MockDataStore{} auth.Init(ds) - nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil) + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil) router = server.JWTVerifier(nativeRouter) // Create test users diff --git a/server/nativeapi/library_test.go b/server/nativeapi/library_test.go index 9b7061845..13b33c238 100644 --- a/server/nativeapi/library_test.go +++ b/server/nativeapi/library_test.go @@ -31,7 +31,7 @@ var _ = Describe("Library API", func() { conf.Server.EnableSharing = false ds = &tests.MockDataStore{} auth.Init(ds) - nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil) + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil) router = server.JWTVerifier(nativeRouter) // Create test users diff --git a/server/nativeapi/metadata.go b/server/nativeapi/metadata.go new file mode 100644 index 000000000..913ab0471 --- /dev/null +++ b/server/nativeapi/metadata.go @@ -0,0 +1,56 @@ +package nativeapi + +import ( + "context" + "errors" + "net/http" + "slices" + + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" +) + +func (api *Router) addMetadataRoute(r chi.Router) { + r.Post("/metadata/{kind}/{id}/refresh", api.refreshMetadata()) +} + +// refreshMetadata clears the artwork state deliberately, so a wrong pick cannot be served from +// cache again; reads fall back to local resolution while the worker re-runs the chain at Bump. +func (api *Router) refreshMetadata() 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(artwork.RefreshableKinds, kind) { + http.Error(w, "invalid artwork kind", http.StatusBadRequest) + return + } + if _, err := artwork.ItemName(ctx, api.ds, kind, id); err != nil { + if errors.Is(err, model.ErrNotFound) { + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + return + } + log.Error(ctx, "Error looking up item to refresh", "kind", kind, "id", id, err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + 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 + } + if external.HasInfo(kind) { + // Detached: the request context is cancelled the moment this handler returns 204. + bg := context.WithoutCancel(ctx) + go func() { + if err := api.provider.RefreshInfo(bg, kind, id); err != nil { + log.Error(bg, "Error refreshing external info", "kind", kind, "id", id, err) + } + }() + } + w.WriteHeader(http.StatusNoContent) + } +} diff --git a/server/nativeapi/metadata_test.go b/server/nativeapi/metadata_test.go new file mode 100644 index 000000000..ebc9aeb28 --- /dev/null +++ b/server/nativeapi/metadata_test.go @@ -0,0 +1,177 @@ +package nativeapi + +import ( + "context" + "net/http" + "net/http/httptest" + "slices" + "sync" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type fakeProvider struct { + external.Provider + mu sync.Mutex + called []string +} + +func (f *fakeProvider) RefreshInfo(_ context.Context, kind model.Kind, id string) error { + f.mu.Lock() + defer f.mu.Unlock() + f.called = append(f.called, kind.Prefix()+"/"+id) + return nil +} + +func (f *fakeProvider) calls() []string { + f.mu.Lock() + defer f.mu.Unlock() + return slices.Clone(f.called) +} + +var _ = Describe("Metadata API", func() { + var ds *tests.MockDataStore + var artRepo *tests.MockArtworkRepo + var queueRepo *tests.MockArtworkQueueRepo + var albumRepo *tests.MockAlbumRepo + var provider *fakeProvider + var router http.Handler + var adminToken, userToken string + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.EnableSharing = false + artRepo = tests.CreateMockArtworkRepo() + queueRepo = tests.CreateMockArtworkQueueRepo() + albumRepo = tests.CreateMockAlbumRepo() + artistRepo := tests.CreateMockArtistRepo() + playlistRepo := tests.CreateMockPlaylistRepo() + Expect(albumRepo.Put(&model.Album{ID: "al-1", Name: "Kid A"})).To(Succeed()) + Expect(artistRepo.Put(&model.Artist{ID: "ar-1", Name: "Radiohead"})).To(Succeed()) + Expect(playlistRepo.Put(&model.Playlist{ID: "pl-1", Name: "My Playlist"})).To(Succeed()) + ds = &tests.MockDataStore{ + MockedArtwork: artRepo, + MockedArtworkQueue: queueRepo, + MockedAlbum: albumRepo, + MockedArtist: artistRepo, + MockedPlaylist: playlistRepo, + } + auth.Init(ds) + provider = &fakeProvider{} + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, provider) + 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/metadata/{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", "/metadata/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", "/metadata/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", "/metadata/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", "/metadata/al/al-1/refresh", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + }) + + It("triggers an external info refresh for albums", func() { + req := createAuthenticatedRequest("POST", "/metadata/al/al-1/refresh", nil, adminToken) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Eventually(provider.calls).Should(ContainElement("al/al-1")) + }) + + It("triggers an external info refresh for artists", func() { + req := createAuthenticatedRequest("POST", "/metadata/ar/ar-1/refresh", nil, adminToken) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Eventually(provider.calls).Should(ContainElement("ar/ar-1")) + }) + + It("skips the external info refresh for kinds without external info", func() { + req := createAuthenticatedRequest("POST", "/metadata/pl/pl-1/refresh", nil, adminToken) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusNoContent)) + Consistently(provider.calls).ShouldNot(ContainElement("pl/pl-1")) + }) + + It("returns 404 for an unknown id", func() { + req := createAuthenticatedRequest("POST", "/metadata/al/nope/refresh", nil, adminToken) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 500 when the lookup fails for a reason other than not-found", func() { + albumRepo.SetError(true) + + req := createAuthenticatedRequest("POST", "/metadata/al/al-1/refresh", nil, adminToken) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + }) + }) +}) diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index f97bd26b2..57a712a20 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -14,6 +14,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/core/metrics" playlistsvc "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/log" @@ -46,10 +47,11 @@ type Router struct { maintenance core.Maintenance pluginManager PluginManager imgUpload artwork.Uploader + provider external.Provider } -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} +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, provider external.Provider) *Router { + r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, users: userService, maintenance: maintenance, pluginManager: pluginManager, imgUpload: imgUpload, provider: provider} r.Handler = r.routes() return r } @@ -92,7 +94,7 @@ func (api *Router) routes() http.Handler { api.addConfigRoute(r) api.addUserLibraryRoute(r) api.addPluginRoute(r) - api.addArtworkRoute(r) + api.addMetadataRoute(r) api.RX(r, "/library", api.libs.NewRepository, true) }) }) diff --git a/server/nativeapi/native_api_song_test.go b/server/nativeapi/native_api_song_test.go index b1ed09d65..203fcd4cf 100644 --- a/server/nativeapi/native_api_song_test.go +++ b/server/nativeapi/native_api_song_test.go @@ -95,7 +95,7 @@ var _ = Describe("Song Endpoints", func() { mfRepo.SetData(testSongs) // Create the native API router and wrap it with the JWTVerifier middleware - nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil) + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil) router = server.JWTVerifier(nativeRouter) w = httptest.NewRecorder() }) diff --git a/server/nativeapi/playlists_test.go b/server/nativeapi/playlists_test.go index 74ef58cab..9abcc477f 100644 --- a/server/nativeapi/playlists_test.go +++ b/server/nativeapi/playlists_test.go @@ -99,7 +99,7 @@ var _ = Describe("Playlist Tracks Endpoint", func() { err := userRepo.Put(&testUser) Expect(err).ToNot(HaveOccurred()) - nativeRouter := New(ds, nil, plsSvc, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil) + nativeRouter := New(ds, nil, plsSvc, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil) router = server.JWTVerifier(nativeRouter) w = httptest.NewRecorder() }) diff --git a/server/nativeapi/plugin_test.go b/server/nativeapi/plugin_test.go index aa91a7951..1683885e7 100644 --- a/server/nativeapi/plugin_test.go +++ b/server/nativeapi/plugin_test.go @@ -34,7 +34,7 @@ var _ = Describe("Plugin API", func() { ds = &tests.MockDataStore{} mockManager = &tests.MockPluginManager{} auth.Init(ds) - nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, mockManager, nil) + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, mockManager, nil, nil) router = server.JWTVerifier(nativeRouter) // Create test users diff --git a/server/nativeapi/user_password_token_refresh_test.go b/server/nativeapi/user_password_token_refresh_test.go index 32f4b13cb..2a363980f 100644 --- a/server/nativeapi/user_password_token_refresh_test.go +++ b/server/nativeapi/user_password_token_refresh_test.go @@ -45,7 +45,7 @@ var _ = Describe("PUT /user/{id}: token refresh on self password change", func() auth.Init(ds) userService := core.NewUser(ds, noopPluginUnloader{}) - nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), userService, nil, nil, nil) + nativeRouter := New(ds, nil, nil, nil, tests.NewMockLibraryService(), userService, nil, nil, nil, nil) router = server.JWTVerifier(nativeRouter) }) diff --git a/server/subsonic/e2e/e2e_suite_test.go b/server/subsonic/e2e/e2e_suite_test.go index 8998731a4..58e877b0d 100644 --- a/server/subsonic/e2e/e2e_suite_test.go +++ b/server/subsonic/e2e/e2e_suite_test.go @@ -357,6 +357,10 @@ func (n noopProvider) TopSongs(context.Context, string, string, int) (model.Medi return nil, nil } +func (n noopProvider) RefreshInfo(context.Context, model.Kind, string) error { + return nil +} + // Compile-time interface checks var ( _ artwork.Artwork = noopArtwork{} diff --git a/tests/mock_album_repo.go b/tests/mock_album_repo.go index ff6f9cff6..c63f7c425 100644 --- a/tests/mock_album_repo.go +++ b/tests/mock_album_repo.go @@ -65,6 +65,9 @@ func (m *MockAlbumRepo) Put(al *model.Album) error { if al.ID == "" { al.ID = id.NewRandom() } + if m.Data == nil { + m.Data = make(map[string]*model.Album) + } m.Data[al.ID] = al return nil } @@ -142,10 +145,7 @@ func (m *MockAlbumRepo) GetTouchedAlbums(libID int) (model.AlbumCursor, error) { } func (m *MockAlbumRepo) UpdateExternalInfo(album *model.Album) error { - if m.Err { - return errors.New("unexpected error") - } - return nil + return m.Put(album) } func (m *MockAlbumRepo) Search(q string, options ...model.QueryOptions) (model.Albums, error) { diff --git a/tests/mock_artist_repo.go b/tests/mock_artist_repo.go index 9691a6584..b71942208 100644 --- a/tests/mock_artist_repo.go +++ b/tests/mock_artist_repo.go @@ -58,6 +58,9 @@ func (m *MockArtistRepo) Put(ar *model.Artist, columsToUpdate ...string) error { if ar.ID == "" { ar.ID = id.NewRandom() } + if m.Data == nil { + m.Data = make(map[string]*model.Artist) + } m.Data[ar.ID] = ar return nil } @@ -137,10 +140,7 @@ func (m *MockArtistRepo) GetCursor(options ...model.QueryOptions) (model.ArtistC } func (m *MockArtistRepo) UpdateExternalInfo(artist *model.Artist) error { - if m.Err { - return errors.New("mock repo error") - } - return nil + return m.Put(artist) } func (m *MockArtistRepo) RefreshStats(allArtists bool) (int64, error) { diff --git a/ui/src/artist/ArtistShow.jsx b/ui/src/artist/ArtistShow.jsx index eef2989d5..515c1ab08 100644 --- a/ui/src/artist/ArtistShow.jsx +++ b/ui/src/artist/ArtistShow.jsx @@ -53,7 +53,7 @@ const useStyles = makeStyles( }, ) -const ArtistDetails = (props) => { +export const ArtistDetails = (props) => { const record = useRecordContext(props) const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('sm'), { noSsr: true, @@ -75,7 +75,9 @@ const ArtistDetails = (props) => { // eslint-disable-next-line no-console console.error('error on artist page', e) }) - }, [record.id]) + // Keyed on the record, not its id: a refreshed record must re-fetch, or the stale + // artistInfo state keeps winning the `||` above. + }, [record]) const Component = isDesktop ? DesktopArtistDetails : MobileArtistDetails return ( diff --git a/ui/src/artist/ArtistShow.test.jsx b/ui/src/artist/ArtistShow.test.jsx new file mode 100644 index 000000000..f51cde5ed --- /dev/null +++ b/ui/src/artist/ArtistShow.test.jsx @@ -0,0 +1,63 @@ +import React from 'react' +import { render, waitFor } from '@testing-library/react' +import { RecordContextProvider } from 'react-admin' +import { ThemeProvider, createTheme } from '@material-ui/core/styles' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { ArtistDetails } from './ArtistShow' +import subsonic from '../subsonic' + +vi.mock('../subsonic', () => ({ + default: { getArtistInfo: vi.fn(), getCoverArtUrl: vi.fn() }, +})) + +// Not under test here: isolate ArtistDetails from the leaf presentational views. +vi.mock('./DesktopArtistDetails', () => ({ default: () => null })) +vi.mock('./MobileArtistDetails', () => ({ default: () => null })) + +const mockGetArtistInfo = subsonic.getArtistInfo + +describe('ArtistDetails', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetArtistInfo.mockResolvedValue({ + json: { + 'subsonic-response': { + status: 'ok', + artistInfo: { biography: 'fetched' }, + }, + }, + }) + }) + + const theme = createTheme() + + const wrap = (record) => ( + + + + + + ) + + const renderDetails = (record) => render(wrap(record)) + + it('re-fetches the artist info when the record object changes', async () => { + const record = { id: 'ar1', name: 'Artist', biography: 'old' } + const { rerender } = renderDetails(record) + await waitFor(() => expect(mockGetArtistInfo).toHaveBeenCalledTimes(1)) + + rerender(wrap({ ...record, biography: 'new' })) + + await waitFor(() => expect(mockGetArtistInfo).toHaveBeenCalledTimes(2)) + }) + + it('does not re-fetch when the same record object is passed again', async () => { + const record = { id: 'ar1', name: 'Artist', biography: 'old' } + const { rerender } = renderDetails(record) + await waitFor(() => expect(mockGetArtistInfo).toHaveBeenCalledTimes(1)) + + rerender(wrap(record)) + + expect(mockGetArtistInfo).toHaveBeenCalledTimes(1) + }) +}) diff --git a/ui/src/common/ContextMenus.jsx b/ui/src/common/ContextMenus.jsx index 7ad8c735c..606506459 100644 --- a/ui/src/common/ContextMenus.jsx +++ b/ui/src/common/ContextMenus.jsx @@ -7,7 +7,12 @@ import MenuItem from '@material-ui/core/MenuItem' import MoreVertIcon from '@material-ui/icons/MoreVert' import { MdQuestionMark } from 'react-icons/md' import { makeStyles } from '@material-ui/core/styles' -import { useDataProvider, useNotify, useTranslate } from 'react-admin' +import { + useDataProvider, + useNotify, + usePermissions, + useTranslate, +} from 'react-admin' import clsx from 'clsx' import { playNext, @@ -69,6 +74,7 @@ const ContextMenu = ({ const dispatch = useDispatch() const translate = useTranslate() const notify = useNotify() + const { permissions } = usePermissions() const [anchorEl, setAnchorEl] = useState(null) const isArtist = resource === 'artist' @@ -129,6 +135,16 @@ const ContextMenu = ({ ) }, }, + refresh: { + enabled: permissions === 'admin', + needData: false, + label: translate('resources.album.actions.refresh'), + action: (record) => + dataProvider + .refreshMetadata(resource, record.id) + .then(() => notify('message.metadataRefreshStarted')) + .catch(() => notify('ra.page.error', 'warning')), + }, ...(!hideInfo && { info: { enabled: true, diff --git a/ui/src/common/ContextMenus.test.jsx b/ui/src/common/ContextMenus.test.jsx index 72a98b64b..59a7de966 100644 --- a/ui/src/common/ContextMenus.test.jsx +++ b/ui/src/common/ContextMenus.test.jsx @@ -17,12 +17,21 @@ const { mockConfig } = vi.hoisted(() => ({ })) vi.mock('../config', () => ({ default: mockConfig })) +const { mockPermissions, mockRefreshMetadata } = vi.hoisted(() => ({ + mockPermissions: { value: 'admin' }, + mockRefreshMetadata: vi.fn(), +})) + vi.mock('react-admin', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useNotify: () => vi.fn(), - useDataProvider: () => ({ getList: vi.fn() }), + usePermissions: () => ({ permissions: mockPermissions.value }), + useDataProvider: () => ({ + getList: vi.fn(), + refreshMetadata: mockRefreshMetadata, + }), useTranslate: () => (x) => x, } }) @@ -43,6 +52,7 @@ describe('ContextMenus', () => { vi.clearAllMocks() mockConfig.enableSharing = true mockConfig.enableDownloads = true + mockPermissions.value = 'admin' }) describe('ArtistContextMenu', () => { @@ -75,4 +85,49 @@ describe('ContextMenus', () => { expect(screen.getByText('ra.action.download (1 MB)')).toBeInTheDocument() }) }) + + describe('refresh metadata', () => { + it('shows the item for admins on the album menu', () => { + renderMenu(AlbumContextMenu, { id: 'al1', name: 'Album', songCount: 1 }) + expect( + screen.getByText('resources.album.actions.refresh'), + ).toBeInTheDocument() + }) + + // Menu order comes from key insertion order in the options object, so it is easy to + // change by accident when adding an entry. + it('places the item directly above Get Info', () => { + renderMenu(AlbumContextMenu, { id: 'al1', name: 'Album', songCount: 1 }) + const labels = screen + .getAllByRole('menuitem') + .map((item) => item.textContent) + const refreshAt = labels.indexOf('resources.album.actions.refresh') + const infoAt = labels.indexOf('resources.album.actions.info') + + expect(refreshAt).toBeGreaterThanOrEqual(0) + expect(infoAt).toEqual(refreshAt + 1) + }) + + it('shows the item for admins on the artist menu', () => { + renderMenu(ArtistContextMenu, { id: 'ar1', name: 'Artist', stats: {} }) + expect( + screen.getByText('resources.album.actions.refresh'), + ).toBeInTheDocument() + }) + + it('hides the item for regular users', () => { + mockPermissions.value = 'regular' + renderMenu(AlbumContextMenu, { id: 'al1', name: 'Album', songCount: 1 }) + expect( + screen.queryByText('resources.album.actions.refresh'), + ).not.toBeInTheDocument() + }) + + it('calls refreshMetadata with the resource and id', () => { + mockRefreshMetadata.mockResolvedValue({}) + renderMenu(AlbumContextMenu, { id: 'al1', name: 'Album', songCount: 1 }) + fireEvent.click(screen.getByText('resources.album.actions.refresh')) + expect(mockRefreshMetadata).toHaveBeenCalledWith('album', 'al1') + }) + }) }) diff --git a/ui/src/dataProvider/wrapperDataProvider.js b/ui/src/dataProvider/wrapperDataProvider.js index f5004308b..e79beb787 100644 --- a/ui/src/dataProvider/wrapperDataProvider.js +++ b/ui/src/dataProvider/wrapperDataProvider.js @@ -4,6 +4,8 @@ import { REST_URL } from '../consts' const dataProvider = jsonServerProvider(REST_URL, httpClient) +const REFRESH_KIND = { album: 'al', artist: 'ar' } + const isAdmin = () => { const role = localStorage.getItem('role') return role === 'admin' @@ -221,6 +223,12 @@ const wrapperDataProvider = { data: json, })) }, + // The endpoint answers 204 with no body, but react-admin rejects any response without a + // `data` key, so the id stands in for one. + refreshMetadata: (resource, id) => + httpClient(`${REST_URL}/metadata/${REFRESH_KIND[resource]}/${id}/refresh`, { + method: 'POST', + }).then(() => ({ data: { id } })), } export default wrapperDataProvider diff --git a/ui/src/dataProvider/wrapperDataProvider.test.js b/ui/src/dataProvider/wrapperDataProvider.test.js index fbc82f969..4225a5a54 100644 --- a/ui/src/dataProvider/wrapperDataProvider.test.js +++ b/ui/src/dataProvider/wrapperDataProvider.test.js @@ -87,4 +87,37 @@ describe('wrapperDataProvider', () => { ) }) }) + + describe('refreshMetadata', () => { + it('posts to the album metadata refresh endpoint', () => { + mockHttpClient.mockResolvedValue({ json: {} }) + wrapperDataProvider.refreshMetadata('album', 'al-1') + expect(mockHttpClient).toHaveBeenCalledWith( + expect.stringContaining('/metadata/al/al-1/refresh'), + { method: 'POST' }, + ) + }) + + it('posts to the artist metadata refresh endpoint', () => { + mockHttpClient.mockResolvedValue({ json: {} }) + wrapperDataProvider.refreshMetadata('artist', 'ar-1') + expect(mockHttpClient).toHaveBeenCalledWith( + expect.stringContaining('/metadata/ar/ar-1/refresh'), + { method: 'POST' }, + ) + }) + + // react-admin rejects a custom method whose response has no `data` key, and the + // endpoint answers 204 with no body. + it('resolves to a react-admin shaped response', async () => { + mockHttpClient.mockResolvedValue({ + status: 204, + body: '', + json: undefined, + }) + await expect( + wrapperDataProvider.refreshMetadata('album', 'al-1'), + ).resolves.toEqual({ data: { id: 'al-1' } }) + }) + }) }) diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index 12968afbd..8823a6749 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -93,7 +93,8 @@ "shuffle": "Shuffle", "addToPlaylist": "Add to Playlist", "download": "Download", - "info": "Get Info" + "info": "Get Info", + "refresh": "Refresh Metadata" }, "lists": { "all": "All", @@ -569,6 +570,7 @@ "coverRemoved": "Cover art removed", "coverUploadError": "Error uploading cover art", "coverRemoveError": "Error removing cover art", + "metadataRefreshStarted": "Refreshing metadata in the background", "note": "NOTE", "transcodingDisabled": "Changing the transcoding configuration through the web interface is disabled for security reasons. If you would like to change (edit or add) transcoding options, restart the server with the %{config} configuration option.", "transcodingEnabled": "Navidrome is currently running with %{config}, making it possible to run system commands from the transcoding settings using the web interface. We recommend to disable it for security reasons and only enable it when configuring Transcoding options.", From a9962ebe5de7d1b077eb57852a096897eb3ae8e1 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 26 Aug 2026 10:53:07 -0400 Subject: [PATCH 27/78] refactor(artwork): use the shared httpclient for image downloads Same behavior: httpclient.New sets the Navidrome User-Agent via its transport, so the manual header is no longer needed. --- core/artwork/sources.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/core/artwork/sources.go b/core/artwork/sources.go index 78b7dd68d..069b7bf5a 100644 --- a/core/artwork/sources.go +++ b/core/artwork/sources.go @@ -16,10 +16,10 @@ import ( "strings" "time" - "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/httpclient" "go.senan.xyz/taglib" ) @@ -163,9 +163,8 @@ type readCloser struct { } func fromURL(ctx context.Context, imageUrl *url.URL) (io.ReadCloser, string, error) { - hc := http.Client{Timeout: 5 * time.Second} + hc := httpclient.New(5 * time.Second) req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageUrl.String(), nil) - req.Header.Set("User-Agent", consts.HTTPUserAgent) resp, err := hc.Do(req) //nolint:gosec if err != nil { return nil, "", err From 23e4c8f580365e642d1e0f24c30484057423f4a1 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 26 Aug 2026 10:57:01 -0400 Subject: [PATCH 28/78] refactor(plugins): build the host HTTP client with httpclient.New CheckRedirect is set on the returned client, so the plugin service no longer hand-builds an http.Client just to attach the shared transport. --- plugins/host_httpclient.go | 33 +++++++++++++++------------------ 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/plugins/host_httpclient.go b/plugins/host_httpclient.go index 4c8f85acd..d52898bdd 100644 --- a/plugins/host_httpclient.go +++ b/plugins/host_httpclient.go @@ -46,24 +46,21 @@ func newHTTPService(pluginName string, permission *HTTPPermission) *httpServiceI pluginName: pluginName, requiredHosts: requiredHosts, } - svc.client = &http.Client{ - Transport: httpclient.NewTransport(nil), - // Timeout is set per-request via context deadline, not here. - // CheckRedirect validates hosts and enforces redirect limits. - CheckRedirect: func(req *http.Request, via []*http.Request) error { - if req.Context().Value(noFollowRedirectsKey) != nil { - return http.ErrUseLastResponse - } - if len(via) >= httpClientMaxRedirects { - log.Warn(req.Context(), "HTTP redirect limit exceeded", "plugin", svc.pluginName, "url", req.URL.String(), "redirectCount", len(via)) - return http.ErrUseLastResponse - } - if err := svc.validateHost(req.Context(), req.URL.Host); err != nil { - log.Warn(req.Context(), "HTTP redirect blocked", "plugin", svc.pluginName, "url", req.URL.String(), "err", err) - return err - } - return nil - }, + // No client timeout: it is set per-request via context deadline. + svc.client = httpclient.New(0) + svc.client.CheckRedirect = func(req *http.Request, via []*http.Request) error { + if req.Context().Value(noFollowRedirectsKey) != nil { + return http.ErrUseLastResponse + } + if len(via) >= httpClientMaxRedirects { + log.Warn(req.Context(), "HTTP redirect limit exceeded", "plugin", svc.pluginName, "url", req.URL.String(), "redirectCount", len(via)) + return http.ErrUseLastResponse + } + if err := svc.validateHost(req.Context(), req.URL.Host); err != nil { + log.Warn(req.Context(), "HTTP redirect blocked", "plugin", svc.pluginName, "url", req.URL.String(), "err", err) + return err + } + return nil } return svc } From b0e1943d8ba8ba8f7abd92d0666e332c096a9b3c Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 26 Aug 2026 18:03:59 -0400 Subject: [PATCH 29/78] fix(ui): always show the Last.fm link on the artist details page The button only rendered when an agent supplied a real last.fm URL, either embedded in the biography or as artistInfo.lastFmUrl. Neither source is reliable anymore: cleanContent strips the "Read more on Last.fm" anchor out of the biography, and the Last.fm agent does not register at all unless LastFM.ApiKey and LastFM.Secret are set, in which case GetArtistURL falls through to ListenBrainz, which returns the artist's official homepage. The isLastFmURL guard then correctly rejects it and the button disappears. Build the URL from the artist name when no canonical one is available, the same way AlbumExternalLinks already does for albums. A real last.fm URL is still preferred when one is present, and the button stays hidden when Last.fm is disabled or the artist has no name. --- ui/src/artist/ArtistExternalLink.jsx | 16 +++--- ui/src/artist/ArtistExternalLink.test.jsx | 60 +++++++++++++++++++++++ 2 files changed, 68 insertions(+), 8 deletions(-) create mode 100644 ui/src/artist/ArtistExternalLink.test.jsx diff --git a/ui/src/artist/ArtistExternalLink.jsx b/ui/src/artist/ArtistExternalLink.jsx index a83972f17..6595d1fbc 100644 --- a/ui/src/artist/ArtistExternalLink.jsx +++ b/ui/src/artist/ArtistExternalLink.jsx @@ -38,15 +38,15 @@ const ArtistExternalLinks = ({ artistInfo, record }) => { } if (config.lastFMEnabled) { - if (lastFMlink && isLastFmURL(lastFMlink[2])) { + // No agent may be enabled to supply a canonical URL, so fall back to a name-based one. + const lastFMUrl = + (lastFMlink && isLastFmURL(lastFMlink[2]) && lastFMlink[2]) || + (isLastFmURL(artistInfo?.lastFmUrl) && artistInfo.lastFmUrl) || + (record.name && + `https://last.fm/music/${encodeURIComponent(record.name)}`) + if (lastFMUrl) { addLink( - lastFMlink[2], - 'message.openIn.lastfm', - , - ) - } else if (isLastFmURL(artistInfo?.lastFmUrl)) { - addLink( - artistInfo?.lastFmUrl, + lastFMUrl, 'message.openIn.lastfm', , ) diff --git a/ui/src/artist/ArtistExternalLink.test.jsx b/ui/src/artist/ArtistExternalLink.test.jsx new file mode 100644 index 000000000..4214c21ea --- /dev/null +++ b/ui/src/artist/ArtistExternalLink.test.jsx @@ -0,0 +1,60 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { TestContext } from 'ra-test' +import ArtistExternalLinks from './ArtistExternalLink' + +const { mockConfig } = vi.hoisted(() => ({ + mockConfig: { lastFMEnabled: true }, +})) +vi.mock('../config', () => ({ default: mockConfig })) + +describe('ArtistExternalLinks', () => { + beforeEach(() => { + mockConfig.lastFMEnabled = true + }) + + const renderLinks = (artistInfo, record = { id: 'ar-1', name: 'Björk' }) => + render( + + + , + ) + + const lastFmHref = () => + screen.getByLabelText('message.openIn.lastfm').closest('a').href + + it('uses the URL returned by the server', () => { + renderLinks({ lastFmUrl: 'https://www.last.fm/music/Bjork' }) + expect(lastFmHref()).toBe('https://www.last.fm/music/Bjork') + }) + + it('uses the URL found in the biography', () => { + renderLinks({ + biography: 'Read more on ', + lastFmUrl: 'https://bjork.com', + }) + expect(lastFmHref()).toBe('https://www.last.fm/music/Bjork') + }) + + it('builds the URL from the artist name when the server has none', () => { + renderLinks({ lastFmUrl: 'https://bjork.com' }) + expect(lastFmHref()).toBe('https://last.fm/music/Bj%C3%B6rk') + }) + + it('builds the URL when there is no artist info at all', () => { + renderLinks(undefined) + expect(lastFmHref()).toBe('https://last.fm/music/Bj%C3%B6rk') + }) + + it('shows no Last.fm link when Last.fm is disabled', () => { + mockConfig.lastFMEnabled = false + renderLinks({ lastFmUrl: 'https://www.last.fm/music/Bjork' }) + expect(screen.queryByLabelText('message.openIn.lastfm')).toBeNull() + }) + + it('shows no Last.fm link when the artist has no name', () => { + renderLinks({}, { id: 'ar-1', name: '' }) + expect(screen.queryByLabelText('message.openIn.lastfm')).toBeNull() + }) +}) From b5f530e90cc57c798e3b8277f96dbcda557a4df3 Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 27 Aug 2026 20:19:16 -0400 Subject: [PATCH 30/78] chore(deps): update Go dependencies to latest versions Signed-off-by: Deluan --- go.mod | 12 ++++++------ go.sum | 24 ++++++++++++------------ 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/go.mod b/go.mod index c0809f7b5..624cf9a01 100644 --- a/go.mod +++ b/go.mod @@ -23,7 +23,7 @@ require ( github.com/extism/go-sdk v1.7.1 github.com/fatih/structs v1.1.0 github.com/gen2brain/webp v0.6.4 - github.com/go-chi/chi/v5 v5.3.1 + github.com/go-chi/chi/v5 v5.3.2 github.com/go-chi/cors v1.2.2 github.com/go-chi/httprate v0.16.0 github.com/go-chi/jwtauth/v5 v5.4.0 @@ -43,7 +43,7 @@ require ( github.com/microcosm-cc/bluemonday v1.0.27 github.com/mileusna/useragent v1.3.5 github.com/onsi/ginkgo/v2 v2.32.1 - github.com/onsi/gomega v1.42.1 + github.com/onsi/gomega v1.43.0 github.com/pelletier/go-toml/v2 v2.4.3 github.com/pmezard/go-difflib v1.0.0 github.com/pocketbase/dbx v1.12.0 @@ -53,10 +53,10 @@ require ( github.com/robfig/cron/v3 v3.0.1 github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 - github.com/sirupsen/logrus v1.10.0 + github.com/sirupsen/logrus v1.10.2 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 - github.com/stretchr/testify v1.12.0 + github.com/stretchr/testify v1.12.1 github.com/tetratelabs/wazero v1.12.0 github.com/unrolled/secure v1.17.0 github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 @@ -92,7 +92,7 @@ require ( github.com/goccy/go-json v0.10.6 // indirect github.com/goccy/go-yaml v1.19.2 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 // indirect + github.com/google/pprof v0.0.0-20260825171938-4d453200e7d9 // indirect github.com/google/subcommands v1.2.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect @@ -104,7 +104,7 @@ require ( github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect github.com/lestrrat-go/blackmagic v1.0.4 // indirect - github.com/lestrrat-go/dsig v1.3.0 // indirect + github.com/lestrrat-go/dsig v1.4.0 // indirect github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect github.com/lestrrat-go/httprc/v3 v3.0.6 // indirect diff --git a/go.sum b/go.sum index 8be5a910c..0fcd0a4c7 100644 --- a/go.sum +++ b/go.sum @@ -69,8 +69,8 @@ github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZ github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= -github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8= -github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY= +github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-chi/httprate v0.16.0 h1:8V5DH9j6pSK6UQoBsTpvMyFxycqaKEIToyPKzHJjUa8= @@ -101,8 +101,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc h1:hd+uUVsB1vdxohPneMrhGH2YfQuH5hRIK9u4/XCeUtw= github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc= -github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= -github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= +github.com/google/pprof v0.0.0-20260825171938-4d453200e7d9 h1:dl4UZiszMU+NKHirOiCKTC+hRuNAQ0moHPxSg6WcU1o= +github.com/google/pprof v0.0.0-20260825171938-4d453200e7d9/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -151,8 +151,8 @@ github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhR github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= -github.com/lestrrat-go/dsig v1.3.0 h1:phjMOCXvYzhuIgn7Voe2rex8z166vGfxRxmqM25P9/Q= -github.com/lestrrat-go/dsig v1.3.0/go.mod h1:RD2eOaidyPvpc7IJQoO3Qq52RWdy8ZcJs8lrOnoa1Kc= +github.com/lestrrat-go/dsig v1.4.0 h1:g7LUjK8cT74A5DzBXJI5HzsJuLhoYN0Wzj4nuOMIrH8= +github.com/lestrrat-go/dsig v1.4.0/go.mod h1:I8Nddg/vN2cUl/h8N7SRRApLnNNeyZPIqLYpvpOtGGo= github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY= github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU= github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= @@ -187,8 +187,8 @@ github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750= github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g= github.com/onsi/ginkgo/v2 v2.32.1 h1:6tlvcDm/3sE8lGJbZ4+d4mO3RLy24/tQWOFzVSQNIfw= github.com/onsi/ginkgo/v2 v2.32.1/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= -github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I= -github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg= +github.com/onsi/gomega v1.43.0 h1:VlG/1FxqNxhSO+lq/OHBNaaqwiBK/mO8JbVkX9Y+FeU= +github.com/onsi/gomega v1.43.0/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg= github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= @@ -232,8 +232,8 @@ github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr github.com/sethvargo/go-retry v0.4.0 h1:9qy1OoIAxBL+gBYnkTnTnWle5wlfsXQlwRzIbbpdqPw= github.com/sethvargo/go-retry v0.4.0/go.mod h1:tvsjdKG6xfiCx4LSiUZ06kcv38xvdVQwv8R6/VnnVWg= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= -github.com/sirupsen/logrus v1.10.0 h1:T8MxJJXVZkfcC5zSRMRAg2F8+lxjmUCGGWPzFxO+Msc= -github.com/sirupsen/logrus v1.10.0/go.mod h1:FXZFonkDAnFozmO+5hGAFvB0Yg9/j2SIhA/QuIkP180= +github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo= +github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= @@ -266,8 +266,8 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/stretchr/testify v1.12.0 h1:K6Mr6jO9JICuend/5xzTM03ydSV3vdNRYAdPSukj8uI= -github.com/stretchr/testify v1.12.0/go.mod h1:bOYBZb5qJ00vPzWfIqBUZPaxK8jWiXc6d3ErP4Ca9Gw= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 h1:ZF+QBjOI+tILZjBaFj3HgFonKXUcwgJ4djLb6i42S3Q= From 4b60b21316f9e8467479c1f389e2a12a112bcbf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 29 Aug 2026 16:36:08 -0400 Subject: [PATCH 31/78] fix(scanner): read file birth time via statx on Linux (#6046) * fix(scanner): read file birth time via statx on Linux On Linux the file birth time is only reachable through statx(2). We were reading it with times.Get(), which looks only at the plain stat() result, where the field does not exist: djherbis/times declares HasBirthTime=false for Linux, so the check was always false and every file fell back to time.Now(). This has been the case since #2553 introduced the feature, which means that PR was a no-op on Linux from day one. macOS and Windows were never affected, as there the birth time does come back from plain stat. BirthTime() now tries times.Get() first, which costs no syscall and is already correct on macOS, Windows and BSD, and only falls back to times.Stat() on the path when that comes back empty. Ordering matters: on Windows times.Stat() opens the file asking for FILE_WRITE_ATTRIBUTES, which fails on a read-only share before falling back. Not every filesystem stores a birth time. Measured with a probe over real mounts: ext4, SMB/CIFS and mergerfs report one, while NFS and rclone/FUSE never do. Asking those on every file is pure overhead, so a miss is remembered per device on the localFS and skipped from then on. The memo is keyed by device rather than by library, so a library spanning two mounts does not lose birth times on the mount that does support them. Cost of the extra call is ~2us per file against ~52us just to open a file for tag reading, so 0.23s across a 97k-file library, and only for files whose tags are actually read. Existing rows keep their current birth_time: the repository drops that column on update, so only newly added files get the real value. * fix(scanner): return the device id opaquely to satisfy unconvert st.Dev is uint64 on Linux and int32 on darwin, so a uint64() cast is redundant on one and required on the other. Returning it as an opaque value drops the cast entirely, which also removes the gosec suppression that came with it. The value is only ever used as a sync.Map key. --- core/storage/local/deviceid_unix.go | 18 ++++++++++++ core/storage/local/deviceid_windows.go | 8 ++++++ core/storage/local/local.go | 40 +++++++++++++++++++++++++- core/storage/local/local_test.go | 33 +++++++++++++++++++++ 4 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 core/storage/local/deviceid_unix.go create mode 100644 core/storage/local/deviceid_windows.go diff --git a/core/storage/local/deviceid_unix.go b/core/storage/local/deviceid_unix.go new file mode 100644 index 000000000..42e7dac70 --- /dev/null +++ b/core/storage/local/deviceid_unix.go @@ -0,0 +1,18 @@ +//go:build !windows + +package local + +import ( + "io/fs" + "syscall" +) + +// deviceID identifies the filesystem a file lives on, used to key birth time support per mount. +// It is returned opaquely because its width varies by platform, and it is only used as a map key. +func deviceID(fi fs.FileInfo) (any, bool) { + st, ok := fi.Sys().(*syscall.Stat_t) + if !ok { + return nil, false + } + return st.Dev, true +} diff --git a/core/storage/local/deviceid_windows.go b/core/storage/local/deviceid_windows.go new file mode 100644 index 000000000..f1b44be4d --- /dev/null +++ b/core/storage/local/deviceid_windows.go @@ -0,0 +1,8 @@ +//go:build windows + +package local + +import "io/fs" + +// deviceID has no Windows equivalent, and none is needed: birth time comes straight from FileInfo. +func deviceID(fs.FileInfo) (any, bool) { return nil, false } diff --git a/core/storage/local/local.go b/core/storage/local/local.go index 32aff0955..686838565 100644 --- a/core/storage/local/local.go +++ b/core/storage/local/local.go @@ -6,6 +6,7 @@ import ( "net/url" "os" "path/filepath" + "sync" "sync/atomic" "time" @@ -61,6 +62,8 @@ type localFS struct { fs.FS extractor Extractor root string + // devices whose statx never reports a birth time (NFS, rclone/FUSE), so we ask each only once + noBirthTime sync.Map } // ResolveSymlink implements storage.SymlinkResolverFS. It resolves the whole chain at the @@ -84,7 +87,11 @@ func (lfs *localFS) ReadTags(path ...string) (map[string]metadata.Info, error) { if err != nil { return nil, err } - v.FileInfo = localFileInfo{info} + v.FileInfo = localFileInfo{ + FileInfo: info, + path: filepath.Join(lfs.root, filepath.FromSlash(path)), + noBirthTime: &lfs.noBirthTime, + } res[path] = v } } @@ -95,15 +102,46 @@ func (lfs *localFS) ReadTags(path ...string) (map[string]metadata.Info, error) { // with metadata.FileInfo type localFileInfo struct { fs.FileInfo + path string + noBirthTime *sync.Map } func (lfi localFileInfo) BirthTime() time.Time { if ts := times.Get(lfi.FileInfo); ts.HasBirthTime() { return ts.BirthTime() } + if bt, ok := lfi.statxBirthTime(); ok { + return bt + } return time.Now() } +// statxBirthTime reads the birth time from the path, which on Linux is the only way to get it. +// Filesystems that never report one are remembered per device, so a scan asks each only once. +func (lfi localFileInfo) statxBirthTime() (time.Time, bool) { + if lfi.path == "" { + return time.Time{}, false + } + dev, hasDev := deviceID(lfi.FileInfo) + memo := lfi.noBirthTime + if hasDev && memo != nil { + if _, skip := memo.Load(dev); skip { + return time.Time{}, false + } + } + ts, err := times.Stat(lfi.path) + if err != nil { + return time.Time{}, false + } + if ts.HasBirthTime() { + return ts.BirthTime(), true + } + if hasDev && memo != nil { + memo.Store(dev, struct{}{}) + } + return time.Time{}, false +} + func init() { storage.Register(storage.LocalSchemaID, newLocalStorage) } diff --git a/core/storage/local/local_test.go b/core/storage/local/local_test.go index 90bdd4b5b..3ed6d6fd9 100644 --- a/core/storage/local/local_test.go +++ b/core/storage/local/local_test.go @@ -6,8 +6,10 @@ import ( "os" "path/filepath" "runtime" + "sync" "time" + "github.com/djherbis/times" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" @@ -440,6 +442,37 @@ var _ = Describe("LocalStorage", func() { // Should be around the current time (within last few minutes) Expect(birthTime).To(BeTemporally("~", time.Now(), 5*time.Minute)) }) + + It("reads the birth time from the path, not the time of the call", func() { + // On Linux, birth time is only available via statx(2) on the path. + lfi := localFileInfo{FileInfo: fileInfo, path: testFile} + time.Sleep(300 * time.Millisecond) + Expect(lfi.BirthTime()).To(BeTemporally("<", time.Now().Add(-200*time.Millisecond))) + }) + + It("does not remember filesystems that do report a birth time", func() { + memo := &sync.Map{} + lfi := localFileInfo{FileInfo: fileInfo, path: testFile, noBirthTime: memo} + lfi.BirthTime() + + count := 0 + memo.Range(func(_, _ any) bool { count++; return true }) + Expect(count).To(BeZero()) + }) + + It("skips statx on filesystems already known to have none", func() { + if times.Get(fileInfo).HasBirthTime() { + Skip("this platform reports birth time from FileInfo, so statx is never called") + } + dev, ok := deviceID(fileInfo) + Expect(ok).To(BeTrue()) + + memo := &sync.Map{} + memo.Store(dev, struct{}{}) + lfi := localFileInfo{FileInfo: fileInfo, path: testFile, noBirthTime: memo} + time.Sleep(300 * time.Millisecond) + Expect(lfi.BirthTime()).To(BeTemporally("~", time.Now(), 100*time.Millisecond)) + }) }) It("should delegate all other FileInfo methods", func() { From d7ca00d0189210f5f00c110d7a35b74a98b5a71a Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 29 Aug 2026 17:07:17 -0400 Subject: [PATCH 32/78] chore(deps): update fscache fork to the CancelWithErr simplification stream v1.5.0 added CancelWithErr, which delivers a cancellation cause to blocked reads, future reads, and NextReader. The fscache fork now delegates CloseWithError to it, dropping its own cause recording and reader wrappers. Behavior is unchanged on the Navidrome side. --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 624cf9a01..93e84b052 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ go 1.26 replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3 // Fork to implement CloseWithError, proposed upstream in https://github.com/djherbis/fscache/pull/22 -replace github.com/djherbis/fscache => github.com/deluan/fscache v0.9.1-0.20260825221051-a07d597526e2 +replace github.com/djherbis/fscache => github.com/deluan/fscache v0.9.1-0.20260829205053-654a9d517048 require ( github.com/Masterminds/squirrel v1.5.4 @@ -17,7 +17,7 @@ require ( github.com/dexterlb/mpvipc v0.0.0-20260722094525-0cf47d745b36 github.com/djherbis/atime v1.1.0 github.com/djherbis/fscache v0.10.2-0.20231127215153-442a07e326c4 - github.com/djherbis/stream v1.4.0 + github.com/djherbis/stream v1.5.0 github.com/djherbis/times v1.6.0 github.com/dustin/go-humanize v1.0.1 github.com/extism/go-sdk v1.7.1 diff --git a/go.sum b/go.sum index 0fcd0a4c7..c75b72dd0 100644 --- a/go.sum +++ b/go.sum @@ -29,8 +29,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= -github.com/deluan/fscache v0.9.1-0.20260825221051-a07d597526e2 h1:s254V2hsrrCJXYtAn9WPG/5p4QHenfL9E+j6Tiq5MW4= -github.com/deluan/fscache v0.9.1-0.20260825221051-a07d597526e2/go.mod h1:eNFa48vJrse+8ysT4IJnnUeXwLZNcR0JQumU/W/QoUI= +github.com/deluan/fscache v0.9.1-0.20260829205053-654a9d517048 h1:u3oDvM7pOIouwDGeIoMu9SYgSzVMtiSkOc+xywtKiJk= +github.com/deluan/fscache v0.9.1-0.20260829205053-654a9d517048/go.mod h1:Bbk9SqpJcg/saiPfG6byM1G4G/LQndknrsLVOQ+VJqY= github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3 h1:j7eSXqgtjhlNfwnMEzRdXnJGZTEw4I7J9TeQAll83bU= github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3/go.mod h1:QGxQ4Z1IWyY9w56xNEFjYAaWE8uSxA/gneQ7RPcFJrY= github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf h1:tb246l2Zmpt/GpF9EcHCKTtwzrd0HGfEmoODFA/qnk4= @@ -41,8 +41,8 @@ github.com/dexterlb/mpvipc v0.0.0-20260722094525-0cf47d745b36 h1:KtPfdSST6e0vJbM github.com/dexterlb/mpvipc v0.0.0-20260722094525-0cf47d745b36/go.mod h1:RkQWLNITKkXHLP7LXxZSgEq+uFWU25M5qW7qfEhL9Wc= github.com/djherbis/atime v1.1.0 h1:rgwVbP/5by8BvvjBNrbh64Qz33idKT3pSnMSJsxhi0g= github.com/djherbis/atime v1.1.0/go.mod h1:28OF6Y8s3NQWwacXc5eZTsEsiMzp7LF8MbXE+XJPdBE= -github.com/djherbis/stream v1.4.0 h1:aVD46WZUiq5kJk55yxJAyw6Kuera6kmC3i2vEQyW/AE= -github.com/djherbis/stream v1.4.0/go.mod h1:cqjC1ZRq3FFwkGmUtHwcldbnW8f0Q4YuVsGW1eAFtOk= +github.com/djherbis/stream v1.5.0 h1:+ewqpS/ndTmEiJRH142JyOpmmjnkTxdbJtqoAG5sg0Y= +github.com/djherbis/stream v1.5.0/go.mod h1:cqjC1ZRq3FFwkGmUtHwcldbnW8f0Q4YuVsGW1eAFtOk= github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= From 59448e928351f5afd08ee3ce86fa46b081128c0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 29 Aug 2026 17:28:29 -0400 Subject: [PATCH 33/78] fix(scrobbler): back off when a provider asks us to, instead of retrying per play (#6028) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(agents): retry-later error type with optional server delay Add agents.ErrRetryLater and agents.RetryLaterError, which carries the delay requested by an external service (e.g. ListenBrainz's X-RateLimit-Reset-In). scrobbler.ErrRetryLater becomes an alias of the new sentinel, so existing errors.Is checks and the plugin error-string protocol keep working unchanged. Groundwork for honoring server-requested retry delays across scrobbling, metadata agents and artwork. Song.Equals tests moved to song_test.go to enable external test package. * fix(scrobbler): honor backoff window and server-requested retry delay ListenBrainz 429s were decoded into a typed error that classified as unrecoverable, silently discarding the scrobble (a JSON-bodied 429 was measured live). The client now maps any 429 to agents.RetryLaterError, carrying X-RateLimit-Reset-In when present (capped at 1h). Last.fm error 29 (rate limit) is now retryable like 11/16. The buffer's drain loop no longer lets wake signals bypass an active backoff window - new plays enqueue but drain only when the window closes - and the wait honors the server delay via max(backoff, retryIn). * feat(agents): skip cooling-down agents in aggregate calls When an agent reports retry-later, remember a per-agent cooldown deadline (the server-requested delay, or 1 minute when unspecified) and skip that agent in all aggregate metadata calls until it passes. A round that found no data but skipped or saw a throttled agent returns ErrRetryLater instead of ErrNotFound, so callers cannot mistake rate limiting for a definitive 'no data' answer. * feat(artwork): honor server-requested retry delay when rescheduling When an external image lookup fails with a retry-later error carrying a delay (e.g. a 429 with X-RateLimit-Reset-In), the chain trace carries the largest such hint back to the worker, which reschedules the item at max(exponential backoff, server delay) instead of backoff alone. * feat(plugins): retry-later with optional delay for scrobbler and agent plugins Scrobbler plugins can now return scrobbler(retry_later:N) to request a retry in N seconds (capped at 1h); the bare token keeps its old meaning. Metadata-agent plugins, which had no error vocabulary at all, gain the parallel agent(retry_later[:N]) token, mapped to agents.RetryLaterError so the aggregate's cooldown and the artwork worker honor plugin throttling the same way as built-in agents. * fix: address whole-branch review findings for retry-later handling Narrow the aggregate's throttled rule to the spec sentence: core.Agents returns ErrRetryLater only when no agent answered at all (all skipped-cooling or retry-later). An agent that does not implement the called method now returns an internal errUnsupported instead of ErrNotFound, so it counts as "did not run" — without that, the always-appended local agent would answer for biography, URL and images and make ErrRetryLater unreachable. Wire the consequence in core/external: a throttled round no longer stamps ExternalInfoUpdatedAt (artist and album), so the empty result is not cached for the TTL, and TopSongs maps ErrRetryLater to the same empty-200 the not-found path already produced instead of a new client-facing error. Move the Last.fm code-29 mapping into the client's central error construction so every metadata path produces RetryLaterError, and map ListenBrainz's body-level code 429 (sent with a non-429 HTTP status) the same way. Clamp server- and plugin-requested delays in seconds before scaling to a Duration, in all three parse sites: a header of 18446744074 wrapped past 2^64 and came out as a 0.29s delay. Also: extract the artwork worker's reschedule computation into retryDelay() and cover both it and the trace RetryIn wiring with tests; collapse the double regex call in mapScrobblerError; drop capabilities.ScrobblerErrorRetryLaterIn (ndpgen never emits funcs, so plugin authors could not reach it); regenerate the PDKs so MetadataAgentError reaches the Go and Rust SDKs; de-flake the cooldown tests (long RetryIn for the skip case, separate expiry spec); and cover the max() retry-delay aggregation across users in the scrobble buffer. * refactor: dedupe retry-later parsing and simplify error collection - Add agents.NewRetryLater and agents.RetryLaterFromSeconds, with a single 1h cap, replacing the parse+clamp+multiply logic and the maxRetryInSeconds constant duplicated across listenbrainz, plugins and the agent adapter. - Move HTTP header parsing to httpclient.RetryAfter, so the transport layer owns it and stays domain-agnostic; drop retryInFromHeaders from the ListenBrainz client. Covered by a new Ginkgo table in that package. - Collapse the two near-identical plugin retry_later regexes into one parseRetryLater(prefix, msg) shared by the agent and scrobbler adapters. - Fold the duplicated noteRetryIn snippet from fetchArtistImage and fetchAlbumImage into recordAgent, which already branched on the same isTransientExternal condition. - Replace the atomic.Bool + note() closure in populateArtistInfo with errgroup's own error collection; the group carries no context, so a returned error does not cancel its siblings. - Reuse recoveringScrobbler for the per-user delay test instead of a third double, and switch fakeScrobbler's mutex-guarded error to the atomic.Pointer idiom already used in the same package. * refactor(listenbrainz): keep rate-limit header parsing in the adapter The X-RateLimit-Reset-In header is ListenBrainz's own convention, not a shared one: Last.fm sends no rate-limit headers at all and reports its limit as a body code, and no other integration in tree sends Retry-After. A parser in utils/httpclient implied a uniformity across services that does not exist, so it moves back next to the only client that can know which header its service sends. * refactor(agents): collapse the retry-later sentinel and error into one type ErrRetryLater is now the zero-delay RetryLaterError rather than a separate errors.New value, so errors.Is and errors.AsType both match the sentinel and every delay-carrying variant. That removes the trap where a bare sentinel silently skipped the AsType path, and lets every consumer read the delay off the error directly: the RetryIn accessor and the two constructors are gone, with the policy cap applied where untrusted input is parsed. * refactor(agents): split the cooldown store from the per-dispatch tally The cooldown map and mutex become a cooldowns value with active/park, holding no knowledge of errors; agentAttempts records one dispatch's outcomes and owns the classification that noteAgentError used to hide behind a bool. The three dispatch loops now touch a single object: skip folds the cooldown check and the throttled flag into one call, so the store never appears in the loops. * refactor(agents): share one dispatch loop between the agent call helpers callAgentMethod and callAgentSliceMethod ran identical loops, differing only in how they test a result for emptiness: a slice cannot be compared against its zero value, so the two could not share a constraint. Both now delegate to callAgent, which takes that test as a parameter. Keeping the loop in one place matters more than the lines saved: it holds the cooldown skip, the attempt recording and the empty-dispatch verdict, and a fix applied to one copy but not the other would be silent. * test: cover the two retry-later paths a mutation could break silently Both gaps were proven, not guessed: making the artwork worker pass 0 instead of the collected hint left all 386 specs green, and replacing the default agent cooldown with 0 left the agents suite green. The worker test drives a throttled image agent through drain and asserts the persisted retry_at, and the cooldown test parks an agent that asked to be retried without naming a delay, which is what Last.fm does on every rate limit. * refactor(artwork): carry the external failure as an error, not a flag plus a trace field The retry delay was riding on ChainTrace, a diagnostic that gets persisted, while the very same signal — an external source faulted — already travelled by value as resolution.extError. That was two mechanisms for one idea, and it put control-flow state inside a serializable trace. resolution.extError and chainState.extErr become the error itself, so a caller checks err != nil for the fault and errors.AsType for the delay the provider asked for. The agent loops return that error last, per convention, and longerRetry keeps whichever failure wants the longer wait. ChainTrace goes back to holding only steps and no longer imports core/agents. * fix(artwork): check the resolve error before reading its resolution Reading res.extError before the err check was safe only because every error path in resolve returns a bare resolution{}; a future path returning a partly-filled one would have been read silently. The failure path now returns no delay explicitly. * test(artwork): assert the delay acquire reports, not just its downstream effect acquire's retry delay was only covered through the worker's persisted retry_at, one layer away from where the value is computed. Both outcomes are now pinned at the processor: a plain failure asks for nothing, a throttled provider's delay is passed through. * refactor: share the retry-seconds parse and drop the backoff deadline arithmetic The clamp-before-scaling invariant lived in two parsers and was independently re-tested in three files with the same magic number; a fix applied to one copy would have left the others wrapping a huge value down to a fraction of a second. It moves to agents.ParseRetryIn. The buffer tracked an absolute retryDeadline only to re-arm a timer that was already armed for the same instant; a backingOff flag says the same thing without the arithmetic. The plugin token regex now carries its capability in the pattern instead of capturing and comparing, so another capability's token in the same message cannot mask it. resolution.extError becomes extErr, matching its chainState counterpart. * fix(agents): keep the longer cooldown when parks overlap Calls to one agent overlap, so a short cooldown could land after a long one started and cut it short. park now keeps whichever deadline is later, matching the rule longerRetry already applies on the artwork side. No in-tree provider can currently produce two different delays for the same agent, so this is hardening rather than a fix for observed behaviour. * fix(agents): parse the retry delay at a fixed width strconv.Atoi parses into the native int, so on the 32-bit targets we ship (linux/386, windows/386, three ARM variants) a delay above MaxInt32 seconds overflowed and became unspecified instead of being capped. No provider sends a 68-year delay, so this is not user-visible, but the overflow tests asserted the cap and would have failed on those architectures, where tests never run. * fix(plugins): anchor the retry_later regex to a word boundary Prevents a superstring like useragent(retry_later) from matching the agent capability token. --- adapters/lastfm/agent.go | 3 +- adapters/lastfm/agent_test.go | 19 +++ adapters/lastfm/client.go | 11 +- adapters/listenbrainz/agent_test.go | 13 ++ adapters/listenbrainz/client.go | 17 +++ adapters/listenbrainz/client_test.go | 73 +++++++++ core/agents/agents.go | 144 +++++++++++++----- core/agents/agents_test.go | 126 ++++++++++++++- core/agents/interfaces.go | 51 ++++++- core/agents/interfaces_test.go | 47 ++++-- core/agents/song_test.go | 27 ++++ core/artwork/agent_images.go | 36 +++-- core/artwork/agent_images_test.go | 65 +++++--- core/artwork/processor.go | 32 ++-- core/artwork/processor_test.go | 57 ++++--- core/artwork/resolve.go | 49 +++--- core/artwork/resolve_test.go | 46 +++--- core/artwork/worker.go | 9 +- core/artwork/worker_soak_test.go | 2 +- core/artwork/worker_test.go | 33 ++++ core/external/provider.go | 63 +++++--- core/external/provider_similarsongs.go | 2 +- core/external/provider_topsongs_test.go | 15 ++ .../external/provider_updatealbuminfo_test.go | 22 +++ .../provider_updateartistinfo_test.go | 19 +++ core/scrobbler/buffered_scrobbler.go | 47 +++--- core/scrobbler/buffered_scrobbler_test.go | 122 ++++++++++++++- core/scrobbler/interfaces.go | 4 +- core/scrobbler/play_tracker_test.go | 37 +++-- plugins/capabilities.go | 29 +++- plugins/capabilities/metadata_agent.go | 12 ++ plugins/metadata_agent.go | 3 + plugins/metadata_agent_test.go | 26 ++++ plugins/pdk/go/metadata/metadata.go | 12 ++ plugins/pdk/go/metadata/metadata_stub.go | 12 ++ .../rust/nd-pdk-capabilities/src/metadata.rs | 5 + plugins/scrobbler_adapter.go | 5 +- plugins/scrobbler_adapter_test.go | 22 +++ 38 files changed, 1085 insertions(+), 232 deletions(-) create mode 100644 core/agents/song_test.go diff --git a/adapters/lastfm/agent.go b/adapters/lastfm/agent.go index 863868b5a..7f005db1a 100644 --- a/adapters/lastfm/agent.go +++ b/adapters/lastfm/agent.go @@ -405,7 +405,8 @@ func (l *lastfmAgent) Scrobble(ctx context.Context, userId string, s scrobbler.S log.Warn(ctx, "Last.fm client.scrobble returned error", "track", s.Title, err) return errors.Join(err, scrobbler.ErrRetryLater) } - if lfErr.Code == 11 || lfErr.Code == 16 { + // 11: service offline; 16: temporarily unavailable. Rate limiting is mapped by the client. + if lfErr.Code == 11 || lfErr.Code == 16 || errors.Is(err, scrobbler.ErrRetryLater) { return errors.Join(err, scrobbler.ErrRetryLater) } return errors.Join(err, scrobbler.ErrUnrecoverable) diff --git a/adapters/lastfm/agent_test.go b/adapters/lastfm/agent_test.go index 94024b9ab..ce81e0916 100644 --- a/adapters/lastfm/agent_test.go +++ b/adapters/lastfm/agent_test.go @@ -100,6 +100,15 @@ var _ = Describe("lastfmAgent", func() { Expect(httpClient.RequestCount).To(Equal(1)) Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2")) }) + + It("returns ErrRetryLater on error 29 (rate limit exceeded)", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`{"error":29,"message":"Rate limit exceeded"}`)), + StatusCode: 200, + } + _, err := agent.GetArtistBiography(ctx, "123", "U2", "") + Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) + }) }) Describe("Language Fallback", func() { @@ -497,6 +506,16 @@ var _ = Describe("lastfmAgent", func() { Expect(err).To(MatchError(scrobbler.ErrRetryLater)) }) + It("returns ErrRetryLater on error 29 (rate limit exceeded)", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`{"error":29,"message":"Rate limit exceeded"}`)), + StatusCode: 200, + } + + err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()}) + Expect(errors.Is(err, scrobbler.ErrRetryLater)).To(BeTrue()) + }) + It("returns ErrRetryLater on http errors", func() { httpClient.Res = http.Response{ Body: io.NopCloser(bytes.NewBufferString(`internal server error`)), diff --git a/adapters/lastfm/client.go b/adapters/lastfm/client.go index 726df1360..e468aa638 100644 --- a/adapters/lastfm/client.go +++ b/adapters/lastfm/client.go @@ -5,6 +5,7 @@ import ( "crypto/md5" "encoding/hex" "encoding/json" + "errors" "fmt" "net/http" "net/url" @@ -14,11 +15,15 @@ import ( "strings" "time" + "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/log" ) const ( apiBaseUrl = "https://ws.audioscrobbler.com/2.0/" + // errCodeRateLimit is Last.fm's "rate limit exceeded"; it arrives in the body, with HTTP 200 + // and no rate-limit headers, so the body code is the only signal. + errCodeRateLimit = 29 ) type lastFMError struct { @@ -225,7 +230,11 @@ func (c *client) makeRequest(ctx context.Context, method string, params url.Valu return nil, jsonErr } if response.Error != 0 { - return &response, &lastFMError{Code: response.Error, Message: response.Message} + var err error = &lastFMError{Code: response.Error, Message: response.Message} + if response.Error == errCodeRateLimit { + err = errors.Join(err, &agents.RetryLaterError{}) + } + return &response, err } return &response, nil diff --git a/adapters/listenbrainz/agent_test.go b/adapters/listenbrainz/agent_test.go index 2c4668296..a201b7c3a 100644 --- a/adapters/listenbrainz/agent_test.go +++ b/adapters/listenbrainz/agent_test.go @@ -164,6 +164,19 @@ var _ = Describe("listenBrainzAgent", func() { err := agent.Scrobble(ctx, "user-1", sc) Expect(err).To(MatchError(scrobbler.ErrUnrecoverable)) }) + + It("keeps a 429 scrobble for retry and carries the delay", func() { + httpClient.Res = http.Response{ + StatusCode: 429, + Header: http.Header{"X-Ratelimit-Reset-In": []string{"7"}}, + Body: io.NopCloser(bytes.NewBufferString(`{"code":429,"error":"rate limited"}`)), + } + err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()}) + Expect(errors.Is(err, scrobbler.ErrRetryLater)).To(BeTrue()) + retry, ok := errors.AsType[*agents.RetryLaterError](err) + Expect(ok).To(BeTrue()) + Expect(retry.RetryIn).To(Equal(7 * time.Second)) + }) }) Describe("GetArtistUrl", func() { diff --git a/adapters/listenbrainz/client.go b/adapters/listenbrainz/client.go index 708f02f28..aae4fb51d 100644 --- a/adapters/listenbrainz/client.go +++ b/adapters/listenbrainz/client.go @@ -13,6 +13,7 @@ import ( "slices" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/log" ) @@ -21,6 +22,12 @@ const ( labsBase = "https://labs.api.listenbrainz.org/" ) +// retryLaterErr reads the wait ListenBrainz asked for. It sends X-RateLimit-Reset-In +// (delta-seconds) on every response, including the 429, and never Retry-After. +func retryLaterErr(h http.Header) *agents.RetryLaterError { + return &agents.RetryLaterError{RetryIn: agents.ParseRetryIn(h.Get("X-RateLimit-Reset-In"))} +} + var ( ErrorNotFound = errors.New("listenbrainz: not found") ) @@ -174,6 +181,9 @@ func (c *client) makeAuthenticatedRequest(ctx context.Context, method string, en } defer resp.Body.Close() + if resp.StatusCode == http.StatusTooManyRequests { + return nil, retryLaterErr(resp.Header) + } decoder := json.NewDecoder(resp.Body) var response listenBrainzResponse @@ -185,6 +195,10 @@ func (c *client) makeAuthenticatedRequest(ctx context.Context, method string, en return nil, jsonErr } if response.Code != 0 && response.Code != 200 { + // LB also reports rate limiting as a body code, not only as an HTTP status. + if response.Code == http.StatusTooManyRequests { + return &response, retryLaterErr(resp.Header) + } return &response, &listenBrainzError{Code: response.Code, Message: response.Error} } @@ -211,6 +225,9 @@ func (c *client) makeGenericRequest(ctx context.Context, method string, endpoint // On a 200 code, there is no code. Decode using using error message if it exists if resp.StatusCode != 200 { defer resp.Body.Close() + if resp.StatusCode == http.StatusTooManyRequests { + return nil, retryLaterErr(resp.Header) + } decoder := json.NewDecoder(resp.Body) var lbzError lbzHttpError diff --git a/adapters/listenbrainz/client_test.go b/adapters/listenbrainz/client_test.go index 319cf01ab..ec0b0ac11 100644 --- a/adapters/listenbrainz/client_test.go +++ b/adapters/listenbrainz/client_test.go @@ -4,13 +4,17 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "net/http" "os" + "strings" + "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -461,4 +465,73 @@ var _ = Describe("client", func() { })) }) }) + + Describe("rate limiting", func() { + It("returns RetryLaterError with the header delay on 429", func() { + httpClient.Res = http.Response{ + StatusCode: 429, + Header: http.Header{"X-Ratelimit-Reset-In": []string{"3"}}, + Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"You have exceeded your rate limit."}`)), + } + _, err := client.validateToken(context.Background(), "token") + Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) + retry, ok := errors.AsType[*agents.RetryLaterError](err) + Expect(ok).To(BeTrue()) + Expect(retry.RetryIn).To(Equal(3 * time.Second)) + }) + + It("returns RetryLaterError with zero delay when no header is present", func() { + httpClient.Res = http.Response{ + StatusCode: 429, + Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"rate limited"}`)), + } + _, err := client.validateToken(context.Background(), "token") + Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) + retry, _ := errors.AsType[*agents.RetryLaterError](err) + Expect(retry.RetryIn).To(BeZero()) + }) + + DescribeTable("caps absurd header values at one hour", + func(header string) { + httpClient.Res = http.Response{ + StatusCode: 429, + Header: http.Header{"X-Ratelimit-Reset-In": []string{header}}, + Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"rate limited"}`)), + } + _, err := client.validateToken(context.Background(), "token") + retry, _ := errors.AsType[*agents.RetryLaterError](err) + Expect(retry.RetryIn).To(Equal(time.Hour)) + }, + Entry("a large value", "999999"), + Entry("a huge value", "99999999999"), + // Scaling this to nanoseconds before capping wraps past 2^64, landing on ~0.29s. + Entry("a value that overflows int64 nanoseconds", "18446744074"), + ) + + It("maps a body-level 429 sent with a non-429 status", func() { + httpClient.Res = http.Response{ + StatusCode: 200, + Header: http.Header{"X-Ratelimit-Reset-In": []string{"7"}}, + Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"You have exceeded your rate limit."}`)), + } + _, err := client.validateToken(context.Background(), "token") + Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) + retry, ok := errors.AsType[*agents.RetryLaterError](err) + Expect(ok).To(BeTrue()) + Expect(retry.RetryIn).To(Equal(7 * time.Second)) + }) + + It("returns RetryLaterError on a 429 from makeGenericRequest", func() { + httpClient.Res = http.Response{ + StatusCode: 429, + Header: http.Header{"X-Ratelimit-Reset-In": []string{"5"}}, + Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"rate limited"}`)), + } + _, err := client.getArtistUrl(context.Background(), "1") + Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) + retry, ok := errors.AsType[*agents.RetryLaterError](err) + Expect(ok).To(BeTrue()) + Expect(retry.RetryIn).To(Equal(5 * time.Second)) + }) + }) }) diff --git a/core/agents/agents.go b/core/agents/agents.go index 348f7d4e7..ac623951c 100644 --- a/core/agents/agents.go +++ b/core/agents/agents.go @@ -1,9 +1,12 @@ package agents import ( + "cmp" "context" + "errors" "slices" "strings" + "sync" "time" "github.com/navidrome/navidrome/conf" @@ -22,11 +25,43 @@ type PluginLoader interface { LoadMediaAgent(name string) (Interface, bool) } +// agentCooldown is the default cooldown duration for an agent that returns a RetryLaterError without a specific +// RetryIn duration. +const agentCooldown = time.Minute + +// errUnsupported marks an agent that does not implement the requested method: it never ran, +// so it neither answered nor throttled. +var errUnsupported = errors.New("agent does not support this method") + // Agents is a meta-agent that aggregates multiple built-in and plugin agents. It tries each enabled agent in order // until one returns valid data. type Agents struct { ds model.DataStore pluginLoader PluginLoader + cooldowns cooldowns +} + +// cooldowns remembers, across dispatches, which agents asked to be left alone and until when. +type cooldowns struct { + mu sync.RWMutex + until map[string]time.Time +} + +func (c *cooldowns) active(name string) bool { + c.mu.RLock() + defer c.mu.RUnlock() + return time.Now().Before(c.until[name]) +} + +// park keeps whichever deadline is later, so a call still in flight when a longer cooldown +// starts cannot cut it short when it finally answers. +func (c *cooldowns) park(name string, d time.Duration) { + until := time.Now().Add(d) + c.mu.Lock() + defer c.mu.Unlock() + if until.After(c.until[name]) { + c.until[name] = until + } } // GetAgents returns the singleton instance of Agents @@ -41,6 +76,7 @@ func createAgents(ds model.DataStore, pluginLoader PluginLoader) *Agents { return &Agents{ ds: ds, pluginLoader: pluginLoader, + cooldowns: cooldowns{until: map[string]time.Time{}}, } } @@ -171,7 +207,7 @@ func (a *Agents) GetArtistMBID(ctx context.Context, id string, name string) (str return callAgentMethod(ctx, a, "GetArtistMBID", func(ag Interface) (string, error) { retriever, ok := ag.(ArtistMBIDRetriever) if !ok { - return "", ErrNotFound + return "", errUnsupported } return retriever.GetArtistMBID(ctx, id, name) }) @@ -188,7 +224,7 @@ func (a *Agents) GetArtistURL(ctx context.Context, id, name, mbid string) (strin return callAgentMethod(ctx, a, "GetArtistURL", func(ag Interface) (string, error) { retriever, ok := ag.(ArtistURLRetriever) if !ok { - return "", ErrNotFound + return "", errUnsupported } return retriever.GetArtistURL(ctx, id, name, mbid) }) @@ -205,7 +241,7 @@ func (a *Agents) GetArtistBiography(ctx context.Context, id, name, mbid string) return callAgentMethod(ctx, a, "GetArtistBiography", func(ag Interface) (string, error) { retriever, ok := ag.(ArtistBiographyRetriever) if !ok { - return "", ErrNotFound + return "", errUnsupported } return retriever.GetArtistBiography(ctx, id, name, mbid) }) @@ -224,7 +260,11 @@ func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, l overLimit := int(float64(limit) * conf.Server.DevExternalArtistFetchMultiplier) start := time.Now() + attempts := newAttempts(&a.cooldowns) for _, enabledAgent := range a.getEnabledAgentNames() { + if attempts.skip(enabledAgent.name) { + continue + } ag := a.getAgent(enabledAgent) if ag == nil { continue @@ -237,6 +277,7 @@ func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, l continue } similar, err := retriever.GetSimilarArtists(ctx, id, name, mbid, overLimit) + attempts.record(enabledAgent.name, err) if len(similar) > 0 && err == nil { if log.IsGreaterOrEqualTo(log.LevelTrace) { log.Debug(ctx, "Got Similar Artists", "agent", ag.AgentName(), "artist", name, "similar", similar, "elapsed", time.Since(start)) @@ -246,7 +287,7 @@ func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, l return similar, err } } - return nil, ErrNotFound + return nil, attempts.noResultErr() } func (a *Agents) GetArtistImages(ctx context.Context, id, name, mbid string) ([]ExternalImage, error) { @@ -260,7 +301,7 @@ func (a *Agents) GetArtistImages(ctx context.Context, id, name, mbid string) ([] return callAgentSliceMethod(ctx, a, "GetArtistImages", func(ag Interface) ([]ExternalImage, error) { retriever, ok := ag.(ArtistImageRetriever) if !ok { - return nil, ErrNotFound + return nil, errUnsupported } return retriever.GetArtistImages(ctx, id, name, mbid) }) @@ -281,7 +322,7 @@ func (a *Agents) GetArtistTopSongs(ctx context.Context, id, artistName, mbid str return callAgentSliceMethod(ctx, a, "GetArtistTopSongs", func(ag Interface) ([]Song, error) { retriever, ok := ag.(ArtistTopSongsRetriever) if !ok { - return nil, ErrNotFound + return nil, errUnsupported } return retriever.GetArtistTopSongs(ctx, id, artistName, mbid, overLimit) }) @@ -295,7 +336,7 @@ func (a *Agents) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (* return callAgentMethod(ctx, a, "GetAlbumInfo", func(ag Interface) (*AlbumInfo, error) { retriever, ok := ag.(AlbumInfoRetriever) if !ok { - return nil, ErrNotFound + return nil, errUnsupported } return retriever.GetAlbumInfo(ctx, name, artist, mbid) }) @@ -309,7 +350,7 @@ func (a *Agents) GetAlbumImages(ctx context.Context, name, artist, mbid string) return callAgentSliceMethod(ctx, a, "GetAlbumImages", func(ag Interface) ([]ExternalImage, error) { retriever, ok := ag.(AlbumImageRetriever) if !ok { - return nil, ErrNotFound + return nil, errUnsupported } return retriever.GetAlbumImages(ctx, name, artist, mbid) }) @@ -320,7 +361,7 @@ func (a *Agents) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, m return callAgentSliceMethod(ctx, a, "GetSimilarSongsByTrack", func(ag Interface) ([]Song, error) { retriever, ok := ag.(SimilarSongsByTrackRetriever) if !ok { - return nil, ErrNotFound + return nil, errUnsupported } return retriever.GetSimilarSongsByTrack(ctx, id, name, artist, mbid, count) }) @@ -331,7 +372,7 @@ func (a *Agents) GetSimilarSongsByAlbum(ctx context.Context, id, name, artist, m return callAgentSliceMethod(ctx, a, "GetSimilarSongsByAlbum", func(ag Interface) ([]Song, error) { retriever, ok := ag.(SimilarSongsByAlbumRetriever) if !ok { - return nil, ErrNotFound + return nil, errUnsupported } return retriever.GetSimilarSongsByAlbum(ctx, id, name, artist, mbid, count) }) @@ -349,16 +390,61 @@ func (a *Agents) GetSimilarSongsByArtist(ctx context.Context, id, name, mbid str return callAgentSliceMethod(ctx, a, "GetSimilarSongsByArtist", func(ag Interface) ([]Song, error) { retriever, ok := ag.(SimilarSongsByArtistRetriever) if !ok { - return nil, ErrNotFound + return nil, errUnsupported } return retriever.GetSimilarSongsByArtist(ctx, id, name, mbid, count) }) } -func callAgentMethod[T comparable](ctx context.Context, agents *Agents, methodName string, fn func(Interface) (T, error)) (T, error) { +// agentAttempts tallies what the enabled agents did in one dispatch. +type agentAttempts struct { + cooldowns *cooldowns + throttled bool + answered bool +} + +func newAttempts(c *cooldowns) agentAttempts { + return agentAttempts{cooldowns: c} +} + +// skip reports whether name is still cooling down, counting it as throttled for this dispatch. +func (t *agentAttempts) skip(name string) bool { + if !t.cooldowns.active(name) { + return false + } + t.throttled = true + return true +} + +// record files one agent's outcome, parking it when it asked to be retried later. +func (t *agentAttempts) record(name string, err error) { + switch retry, isRetryLater := errors.AsType[*RetryLaterError](err); { + case errors.Is(err, errUnsupported): + case isRetryLater: + t.cooldowns.park(name, cmp.Or(retry.RetryIn, agentCooldown)) + t.throttled = true + default: + t.answered = true + } +} + +// noResultErr tells a retryable empty dispatch (nobody answered) from a definitive miss. +func (t *agentAttempts) noResultErr() error { + if t.throttled && !t.answered { + return ErrRetryLater + } + return ErrNotFound +} + +// callAgent tries each enabled agent in order until found reports a usable result. +func callAgent[T any](ctx context.Context, agents *Agents, methodName string, fn func(Interface) (T, error), found func(T) bool) (T, error) { var zero T start := time.Now() + attempts := newAttempts(&agents.cooldowns) for _, enabledAgent := range agents.getEnabledAgentNames() { + if attempts.skip(enabledAgent.name) { + continue + } ag := agents.getAgent(enabledAgent) if ag == nil { continue @@ -367,41 +453,29 @@ func callAgentMethod[T comparable](ctx context.Context, agents *Agents, methodNa break } result, err := fn(ag) + attempts.record(enabledAgent.name, err) if err != nil { log.Trace(ctx, "Agent method call error", "method", methodName, "agent", ag.AgentName(), "error", err) continue } - if result != zero { + if found(result) { log.Debug(ctx, "Got result", "method", methodName, "agent", ag.AgentName(), "elapsed", time.Since(start)) return result, nil } } - return zero, ErrNotFound + return zero, attempts.noResultErr() +} + +func callAgentMethod[T comparable](ctx context.Context, agents *Agents, methodName string, fn func(Interface) (T, error)) (T, error) { + return callAgent(ctx, agents, methodName, fn, func(result T) bool { + var zero T + return result != zero + }) } func callAgentSliceMethod[T any](ctx context.Context, agents *Agents, methodName string, fn func(Interface) ([]T, error)) ([]T, error) { - start := time.Now() - for _, enabledAgent := range agents.getEnabledAgentNames() { - ag := agents.getAgent(enabledAgent) - if ag == nil { - continue - } - if utils.IsCtxDone(ctx) { - break - } - results, err := fn(ag) - if err != nil { - log.Trace(ctx, "Agent method call error", "method", methodName, "agent", ag.AgentName(), "error", err) - continue - } - - if len(results) > 0 { - log.Debug(ctx, "Got results", "method", methodName, "agent", ag.AgentName(), "count", len(results), "elapsed", time.Since(start)) - return results, nil - } - } - return nil, ErrNotFound + return callAgent(ctx, agents, methodName, fn, func(results []T) bool { return len(results) > 0 }) } var _ Interface = (*Agents)(nil) diff --git a/core/agents/agents_test.go b/core/agents/agents_test.go index e79b2b3c8..35ebf18d8 100644 --- a/core/agents/agents_test.go +++ b/core/agents/agents_test.go @@ -3,6 +3,7 @@ package agents import ( "context" "errors" + "time" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" @@ -14,6 +15,29 @@ import ( . "github.com/onsi/gomega" ) +var _ = Describe("cooldowns", func() { + // Calls to one agent overlap, so a short cooldown can land after a long one started. + It("keeps the longer deadline when a shorter park lands after it", func() { + c := cooldowns{until: map[string]time.Time{}} + + c.park("fake", time.Hour) + c.park("fake", time.Millisecond) + + time.Sleep(10 * time.Millisecond) + Expect(c.active("fake")).To(BeTrue()) + }) + + It("extends the deadline when the later park is longer", func() { + c := cooldowns{until: map[string]time.Time{}} + + c.park("fake", time.Millisecond) + c.park("fake", time.Hour) + + time.Sleep(10 * time.Millisecond) + Expect(c.active("fake")).To(BeTrue()) + }) +}) + var _ = Describe("Agents", func() { var ctx context.Context var cancel context.CancelFunc @@ -160,6 +184,102 @@ var _ = Describe("Agents", func() { }) }) + Describe("cooldown", func() { + It("skips an agent that returned RetryLaterError until the deadline", func() { + mock.Err = &RetryLaterError{RetryIn: time.Hour} + _, err := ag.GetArtistBiography(ctx, "id", "name", "mbid") + Expect(errors.Is(err, ErrRetryLater)).To(BeTrue()) + + // Immediately after: agent is skipped, not called + mock.Err = nil + calls := mock.Calls + _, err = ag.GetArtistBiography(ctx, "id", "name", "mbid") + Expect(mock.Calls).To(Equal(calls)) + Expect(errors.Is(err, ErrRetryLater)).To(BeTrue()) + }) + + // Providers that throttle without saying for how long (Last.fm sends no delay at all) + // must still be parked, or the aggregate keeps calling them on every request. + It("parks an agent that asked to be retried without a delay", func() { + mock.Err = ErrRetryLater + _, err := ag.GetArtistBiography(ctx, "id", "name", "mbid") + Expect(errors.Is(err, ErrRetryLater)).To(BeTrue()) + + mock.Err = nil + calls := mock.Calls + _, err = ag.GetArtistBiography(ctx, "id", "name", "mbid") + Expect(mock.Calls).To(Equal(calls), "the default cooldown must outlast the request") + Expect(errors.Is(err, ErrRetryLater)).To(BeTrue()) + }) + + It("calls the agent again once the cooldown expires", func() { + mock.Err = &RetryLaterError{RetryIn: 10 * time.Millisecond} + _, err := ag.GetArtistBiography(ctx, "id", "name", "mbid") + Expect(errors.Is(err, ErrRetryLater)).To(BeTrue()) + + mock.Err = nil + Eventually(func() (string, error) { + return ag.GetArtistBiography(ctx, "id", "name", "mbid") + }, 5*time.Second, 10*time.Millisecond).Should(Equal("bio")) + }) + + It("returns ErrNotFound, not ErrRetryLater, when agents failed for other reasons", func() { + mock.Err = errors.New("boom") + _, err := ag.GetArtistBiography(ctx, "id", "name", "mbid") + Expect(errors.Is(err, ErrNotFound)).To(BeTrue()) + Expect(errors.Is(err, ErrRetryLater)).To(BeFalse()) + }) + + // ErrRetryLater tells the caller "nobody answered, do not cache this". A definitive + // answer from any other agent is an answer, throttled peer or not. + It("returns ErrNotFound when another agent answered with a definitive miss", func() { + other := &mockAgent{Err: ErrNotFound} + Register("fake2", func(model.DataStore) Interface { return other }) + conf.Server.Agents = "fake,fake2" + ag = createAgents(ds, nil) + mock.Err = &RetryLaterError{RetryIn: time.Hour} + + _, err := ag.GetArtistBiography(ctx, "id", "name", "mbid") + Expect(errors.Is(err, ErrNotFound)).To(BeTrue()) + Expect(errors.Is(err, ErrRetryLater)).To(BeFalse()) + + // The cooldown was still recorded for the throttled agent + calls := mock.Calls + _, _ = ag.GetArtistBiography(ctx, "id", "name", "mbid") + Expect(mock.Calls).To(Equal(calls)) + }) + + It("returns ErrNotFound when another agent answered with an empty slice", func() { + empty := &testImageAgent{Name: "emptyImages"} + Register("emptyImages", func(model.DataStore) Interface { return empty }) + conf.Server.Agents = "fake,emptyImages" + ag = createAgents(ds, nil) + mock.Err = &RetryLaterError{RetryIn: time.Hour} + + _, err := ag.GetArtistImages(ctx, "123", "test", "mb123") + Expect(errors.Is(err, ErrNotFound)).To(BeTrue()) + Expect(errors.Is(err, ErrRetryLater)).To(BeFalse()) + }) + + It("returns ErrRetryLater from GetSimilarArtists when only cooling agents remain", func() { + mock.Err = &RetryLaterError{RetryIn: time.Hour} + _, err := ag.GetSimilarArtists(ctx, "123", "test", "mb123", 2) + Expect(errors.Is(err, ErrRetryLater)).To(BeTrue()) + }) + + It("returns ErrNotFound from GetSimilarArtists when another agent answered", func() { + other := &mockAgent{Err: ErrNotFound} + Register("fake2", func(model.DataStore) Interface { return other }) + conf.Server.Agents = "fake,fake2" + ag = createAgents(ds, nil) + mock.Err = &RetryLaterError{RetryIn: time.Hour} + + _, err := ag.GetSimilarArtists(ctx, "123", "test", "mb123", 2) + Expect(errors.Is(err, ErrNotFound)).To(BeTrue()) + Expect(errors.Is(err, ErrRetryLater)).To(BeFalse()) + }) + }) + Describe("GetArtistImages", func() { It("returns on first match", func() { Expect(ag.GetArtistImages(ctx, "123", "test", "mb123")).To(Equal([]ExternalImage{{ @@ -423,8 +543,9 @@ var _ = Describe("Agents", func() { }) type mockAgent struct { - Args []any - Err error + Args []any + Err error + Calls int } func (a *mockAgent) AgentName() string { @@ -449,6 +570,7 @@ func (a *mockAgent) GetArtistURL(_ context.Context, id, name, mbid string) (stri func (a *mockAgent) GetArtistBiography(_ context.Context, id, name, mbid string) (string, error) { a.Args = []any{id, name, mbid} + a.Calls++ if a.Err != nil { return "", a.Err } diff --git a/core/agents/interfaces.go b/core/agents/interfaces.go index 7fc5de361..9225a0442 100644 --- a/core/agents/interfaces.go +++ b/core/agents/interfaces.go @@ -3,6 +3,9 @@ package agents import ( "context" "errors" + "fmt" + "strconv" + "time" "github.com/gohugoio/hashstructure" "github.com/navidrome/navidrome/model" @@ -52,11 +55,49 @@ func (s Song) Equals(other Song) bool { return h1 == h2 } -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") -) +// 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. +var ErrNotFound = errors.New("not found") + +// ErrRetryLater is the zero-delay RetryLaterError: the provider is temporarily unavailable +// or throttling us, but did not say for how long. Both errors.Is(err, ErrRetryLater) and +// errors.AsType[*RetryLaterError] match it and every delay-carrying variant. +// Treat it as immutable; build a new RetryLaterError to name a delay. +var ErrRetryLater = &RetryLaterError{} + +// RetryLaterError asks callers to back off, optionally for the delay the provider requested. +type RetryLaterError struct { + RetryIn time.Duration +} + +func (e *RetryLaterError) Error() string { + if e.RetryIn > 0 { + return fmt.Sprintf("retry later (in %s)", e.RetryIn) + } + return "retry later" +} + +func (e *RetryLaterError) Is(target error) bool { + _, ok := target.(*RetryLaterError) + return ok +} + +// MaxRetryIn caps a delay parsed from a provider, so a bogus value cannot park it indefinitely. +const MaxRetryIn = time.Hour +const maxRetryInSeconds = int(MaxRetryIn / time.Second) + +// ParseRetryIn reads a provider's delay given in seconds, from a header or a plugin token. +// Anything unparseable or non-positive means unspecified. +func ParseRetryIn(seconds string) time.Duration { + // Clamp in seconds: scaling first would wrap a huge value past int64 nanoseconds, + // turning "wait an age" into a fraction of a second. Parse at a fixed width so the + // cap holds on the 32-bit targets we ship, where a plain Atoi would overflow first. + secs, err := strconv.ParseInt(seconds, 10, 64) + if err != nil || secs <= 0 { + return 0 + } + return time.Duration(min(secs, int64(maxRetryInSeconds))) * time.Second +} // AlbumInfoRetriever provides album info (no images) type AlbumInfoRetriever interface { diff --git a/core/agents/interfaces_test.go b/core/agents/interfaces_test.go index c13710a38..6acbc545d 100644 --- a/core/agents/interfaces_test.go +++ b/core/agents/interfaces_test.go @@ -1,27 +1,42 @@ -package agents +package agents_test import ( + "errors" + "fmt" + "time" + + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/scrobbler" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) -var _ = Describe("Song.Equals", func() { - base := Song{ID: "1", Name: "S", Artists: []Artist{{ID: "x", Name: "A"}}} - It("true for identical songs incl Artists", func() { - Expect(base.Equals(base)).To(BeTrue()) +var _ = Describe("RetryLaterError", func() { + It("matches the ErrRetryLater sentinel via errors.Is", func() { + err := &agents.RetryLaterError{RetryIn: 30 * time.Second} + Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) }) - It("false when Artists differ", func() { - other := base - other.Artists = []Artist{{ID: "y", Name: "B"}} - Expect(base.Equals(other)).To(BeFalse()) + + It("matches through errors.Join and wrapping", func() { + err := fmt.Errorf("calling LB: %w", errors.Join(errors.New("http 429"), &agents.RetryLaterError{})) + Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) }) - It("false when a scalar differs", func() { - other := base - other.Name = "T" - Expect(base.Equals(other)).To(BeFalse()) + + It("exposes the delay through the wrapped error", func() { + err := errors.Join(errors.New("http 429"), &agents.RetryLaterError{RetryIn: 42 * time.Second}) + retry, ok := errors.AsType[*agents.RetryLaterError](err) + Expect(ok).To(BeTrue()) + Expect(retry.RetryIn).To(Equal(42 * time.Second)) }) - It("true when both have empty Artists and equal scalars", func() { - a := Song{ID: "1", Name: "S"} - Expect(a.Equals(a)).To(BeTrue()) + + It("matches the sentinel too, reporting no delay", func() { + retry, ok := errors.AsType[*agents.RetryLaterError](agents.ErrRetryLater) + Expect(ok).To(BeTrue()) + Expect(retry.RetryIn).To(BeZero()) + }) + + It("is the same sentinel as scrobbler.ErrRetryLater", func() { + Expect(errors.Is(scrobbler.ErrRetryLater, agents.ErrRetryLater)).To(BeTrue()) + Expect(errors.Is(&agents.RetryLaterError{}, scrobbler.ErrRetryLater)).To(BeTrue()) }) }) diff --git a/core/agents/song_test.go b/core/agents/song_test.go new file mode 100644 index 000000000..c13710a38 --- /dev/null +++ b/core/agents/song_test.go @@ -0,0 +1,27 @@ +package agents + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Song.Equals", func() { + base := Song{ID: "1", Name: "S", Artists: []Artist{{ID: "x", Name: "A"}}} + It("true for identical songs incl Artists", func() { + Expect(base.Equals(base)).To(BeTrue()) + }) + It("false when Artists differ", func() { + other := base + other.Artists = []Artist{{ID: "y", Name: "B"}} + Expect(base.Equals(other)).To(BeFalse()) + }) + It("false when a scalar differs", func() { + other := base + other.Name = "T" + Expect(base.Equals(other)).To(BeFalse()) + }) + It("true when both have empty Artists and equal scalars", func() { + a := Song{ID: "1", Name: "S"} + Expect(a.Equals(a)).To(BeTrue()) + }) +}) diff --git a/core/artwork/agent_images.go b/core/artwork/agent_images.go index 95596dabc..985abacd7 100644 --- a/core/artwork/agent_images.go +++ b/core/artwork/agent_images.go @@ -2,6 +2,7 @@ package artwork import ( "context" + "errors" "io" "net/url" @@ -41,22 +42,36 @@ func bestImageURL(imgs []agents.ExternalImage) *url.URL { return best } -// fetchArtistImage tries each enabled artist-image agent in order. extErr is true only when no +// longerRetry keeps whichever external failure asks for the longer wait, so one provider's +// short delay cannot shorten another's. +func longerRetry(a, b error) error { + if a == nil { + return b + } + var ra, rb *agents.RetryLaterError + if errors.As(b, &rb) && (!errors.As(a, &ra) || rb.RetryIn > ra.RetryIn) { + return b + } + return a +} + +// fetchArtistImage tries each enabled artist-image agent in order. The error is non-nil 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) { +func fetchArtistImage(ctx context.Context, ag *agents.Agents, gate gateFunc, ar model.Artist) (io.ReadCloser, string, error) { // Synthetic artists would otherwise get an unrelated agent result assigned to them. switch ar.ID { case consts.UnknownArtistID, consts.VariousArtistsID: traceFrom(ctx).add(TraceStep{Candidate: externalCandidate, Outcome: OutcomeSkipped, Detail: "synthetic artist"}) - return nil, "", false + return nil, "", nil } name := externalName(ar.Name) imageAgents := ag.ArtistImageAgents() if len(imageAgents) == 0 { traceFrom(ctx).add(TraceStep{Candidate: externalCandidate, Outcome: OutcomeSkipped, Detail: "no enabled agent provides artist images"}) - return nil, "", false + return nil, "", nil } + var extErr error for _, a := range imageAgents { reader, path, err := gate(a.Name, func() (io.ReadCloser, string, error) { imgs, err := a.Retriever.GetArtistImages(ctx, ar.ID, name, ar.MbzArtistID) @@ -71,10 +86,10 @@ func fetchArtistImage(ctx context.Context, ag *agents.Agents, gate gateFunc, ar }) recordAgent(ctx, a.Name, reader, path, err) if reader != nil { - return reader, a.Name, false + return reader, a.Name, nil } if isTransientExternal(err) { - extErr = true + extErr = longerRetry(extErr, err) log.Debug(ctx, "Artwork: External artist-image lookup failed", "agent", a.Name, "artist", ar.Name, err) } } @@ -82,14 +97,15 @@ func fetchArtistImage(ctx context.Context, ag *agents.Agents, gate gateFunc, ar } // 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) { +func fetchAlbumImage(ctx context.Context, ag *agents.Agents, gate gateFunc, al model.Album) (io.ReadCloser, string, error) { name, artist := externalName(al.Name), externalName(al.AlbumArtist) imageAgents := ag.AlbumImageAgents() if len(imageAgents) == 0 { traceFrom(ctx).add(TraceStep{Candidate: externalCandidate, Outcome: OutcomeSkipped, Detail: "no enabled agent provides album images"}) - return nil, "", false + return nil, "", nil } + var extErr error for _, a := range imageAgents { reader, path, err := gate(a.Name, func() (io.ReadCloser, string, error) { imgs, err := a.Retriever.GetAlbumImages(ctx, name, artist, al.MbzAlbumID) @@ -104,10 +120,10 @@ func fetchAlbumImage(ctx context.Context, ag *agents.Agents, gate gateFunc, al m }) recordAgent(ctx, a.Name, reader, path, err) if reader != nil { - return reader, a.Name, false + return reader, a.Name, nil } if isTransientExternal(err) { - extErr = true + extErr = longerRetry(extErr, err) log.Debug(ctx, "Artwork: External album-image lookup failed", "agent", a.Name, "album", al.Name, err) } } diff --git a/core/artwork/agent_images_test.go b/core/artwork/agent_images_test.go index 60a34352d..d0c2429b0 100644 --- a/core/artwork/agent_images_test.go +++ b/core/artwork/agent_images_test.go @@ -2,11 +2,13 @@ package artwork import ( "context" + "errors" "io" "net/http" "net/http/httptest" "strings" "sync" + "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" @@ -153,11 +155,11 @@ var _ = Describe("agent images", 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"}) + r, name, err := 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()) + Expect(err).ToNot(HaveOccurred()) }) It("skips the external lookup for synthetic artists", func() { @@ -165,10 +167,10 @@ var _ = Describe("agent images", func() { 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"}) + r, name, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: id, Name: "Various Artists"}) Expect(r).To(BeNil()) Expect(name).To(BeEmpty()) - Expect(extErr).To(BeFalse()) + Expect(err).ToNot(HaveOccurred()) } Expect(a.artistCalls).To(Equal(0), "synthetic artists never reach the agents") }) @@ -177,9 +179,9 @@ var _ = Describe("agent images", func() { ag := imageAgents() t := &ChainTrace{} - r, _, extErr := fetchArtistImage(withTrace(ctx, t), ag, passthroughGate, model.Artist{ID: "ar1"}) + r, _, err := fetchArtistImage(withTrace(ctx, t), ag, passthroughGate, model.Artist{ID: "ar1"}) Expect(r).To(BeNil()) - Expect(extErr).To(BeFalse()) + Expect(err).ToNot(HaveOccurred()) Expect(t.Steps()).To(Equal([]TraceStep{{Candidate: "external", Outcome: OutcomeSkipped, Detail: "no enabled agent provides artist images"}}), "a configured external token must never be silently absent from the chain") @@ -211,11 +213,11 @@ var _ = Describe("agent images", func() { 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"}) + r, name, err := 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(err).ToNot(HaveOccurred(), "a later hit clears an earlier agent's error") Expect(a.artistCalls).To(Equal(1)) Expect(b.artistCalls).To(Equal(1)) }) @@ -225,20 +227,43 @@ var _ = Describe("agent images", func() { b := &fakeImageAgent{name: "agentB", err: agents.ErrNotFound} ag := imageAgents(a, b) - r, name, extErr := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"}) + r, name, err := 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") + Expect(err).ToNot(HaveOccurred(), "not-found is definitive, never a transient failure") }) - It("reports extErr when one agent fails transiently and the rest find nothing", func() { + It("reports an error 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"}) + r, _, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"}) Expect(r).To(BeNil()) - Expect(extErr).To(BeTrue()) + Expect(err).To(HaveOccurred()) + }) + + // The worker reschedules on this delay, so it is only honored if the agent loop + // returns it. Two throttled agents: the longest wait is the one that must survive. + It("returns the longest retry delay the providers asked for", func() { + a := &fakeImageAgent{name: "agentA", err: &agents.RetryLaterError{RetryIn: 10 * time.Second}} + b := &fakeImageAgent{name: "agentB", err: &agents.RetryLaterError{RetryIn: 5 * time.Second}} + ag := imageAgents(a, b) + + r, _, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"}) + Expect(r).To(BeNil()) + retry, ok := errors.AsType[*agents.RetryLaterError](err) + Expect(ok).To(BeTrue()) + Expect(retry.RetryIn).To(Equal(10 * time.Second)) + }) + + It("returns no delay when the provider did not ask for one", func() { + ag := imageAgents(&fakeImageAgent{name: "agentA", err: errors.New("boom")}) + + _, _, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"}) + Expect(err).To(HaveOccurred()) + _, ok := errors.AsType[*agents.RetryLaterError](err) + Expect(ok).To(BeFalse(), "a plain failure must not look like a throttle") }) }) @@ -247,11 +272,11 @@ var _ = Describe("agent images", 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"}) + r, name, err := 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(err).ToNot(HaveOccurred()) Expect(a.albumCalls).To(Equal(1)) }) @@ -259,21 +284,21 @@ var _ = Describe("agent images", func() { ag := imageAgents() t := &ChainTrace{} - r, _, extErr := fetchAlbumImage(withTrace(ctx, t), ag, passthroughGate, model.Album{Name: "Album"}) + r, _, err := fetchAlbumImage(withTrace(ctx, t), ag, passthroughGate, model.Album{Name: "Album"}) Expect(r).To(BeNil()) - Expect(extErr).To(BeFalse()) + Expect(err).ToNot(HaveOccurred()) Expect(t.Steps()).To(Equal([]TraceStep{{Candidate: "external", Outcome: OutcomeSkipped, Detail: "no enabled agent provides album images"}}), "a configured external token must never be silently absent from the chain") }) - It("reports extErr when the only agent fails transiently", func() { + It("reports an error 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"}) + r, _, err := fetchAlbumImage(ctx, ag, passthroughGate, model.Album{Name: "Album"}) Expect(r).To(BeNil()) - Expect(extErr).To(BeTrue()) + Expect(err).To(HaveOccurred()) }) }) diff --git a/core/artwork/processor.go b/core/artwork/processor.go index fdb28189a..cf2176775 100644 --- a/core/artwork/processor.go +++ b/core/artwork/processor.go @@ -16,6 +16,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/core/artwork/blurhash" "github.com/navidrome/navidrome/core/artwork/dominant" "github.com/navidrome/navidrome/core/artwork/thumbhash" @@ -80,7 +81,7 @@ type processor struct { // 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) { +func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (out outcome, got *acquired, retryIn time.Duration) { repo := p.ds.Artwork(ctx) start := time.Now() defer func() { @@ -92,10 +93,13 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o if err != nil { traceStage(ctx, "resolve", err) log.Warn(ctx, "Artwork: Could not resolve item", "kind", item.ItemKind, "id", item.ItemID, err) - return outcomeFailed, nil + return outcomeFailed, nil, 0 + } + if retry, ok := errors.AsType[*agents.RetryLaterError](res.extErr); ok { + retryIn = retry.RetryIn } if res.reader == nil { - if res.extError || res.localError { + if res.extErr != nil || res.localError { // A fault is not a definitive "no image": never settle absent, keep serving old state. // A chainless resolver (playlist/radio) records no step, so leave a fallback or explain is blank. if t := traceFrom(ctx); len(t.Steps()) == 0 { @@ -106,10 +110,10 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o t.add(TraceStep{Candidate: cmp.Or(res.source, "source"), Outcome: outcome}) } 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 + "kind", item.ItemKind, "id", item.ItemID, "extErr", res.extErr, "localError", res.localError) + return outcomeFailed, nil, retryIn } - return writeAbsent(ctx, repo, item), nil + return writeAbsent(ctx, repo, item), nil, 0 } defer res.reader.Close() @@ -118,7 +122,7 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o if err != nil { traceStage(ctx, "read", err) log.Warn(ctx, "Artwork: Failed to read resolved image", "kind", item.ItemKind, "id", item.ItemID, "source", res.source, err) - return outcomeFailed, nil + return outcomeFailed, nil, retryIn } log.Debug(ctx, "Artwork: Read resolved image", "kind", item.ItemKind, "id", item.ItemID, "source", res.source, "bytes", len(data), "elapsed", time.Since(readStart)) @@ -128,7 +132,7 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o if err != nil { traceStage(ctx, "hash", err) log.Warn(ctx, "Artwork: Failed to hash image", "kind", item.ItemKind, "id", item.ItemID, err) - return outcomeFailed, nil + return outcomeFailed, nil, retryIn } log.Trace(ctx, "Artwork: Hashed image", "kind", item.ItemKind, "id", item.ItemID, "hash", hash, "bytes", len(data), "elapsed", time.Since(hashStart)) @@ -152,14 +156,14 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o if err != nil { traceStage(ctx, "decode", err) log.Warn(ctx, "Artwork: Failed to decode resolved image", "kind", item.ItemKind, "id", item.ItemID, err) - return outcomeFailed, nil + return outcomeFailed, nil, retryIn } 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: traceStage(ctx, "lookup", err) log.Warn(ctx, "Artwork: Failed to look up image hash", "kind", item.ItemKind, "id", item.ItemID, err) - return outcomeFailed, nil + return outcomeFailed, nil, retryIn } art.SizeBytes = int64(len(data)) @@ -167,15 +171,15 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o if err != nil { traceStage(ctx, "store", err) log.Warn(ctx, "Artwork: Failed to persist resolved image", "kind", item.ItemKind, "id", item.ItemID, err) - return outcomeFailed, nil + return outcomeFailed, nil, retryIn } got = &acquired{ia: ia, mime: art.Mime, data: data} - if res.extError { + if res.extErr != nil { 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 outcomeFoundStale, got, retryIn } - return outcomeFound, got + return outcomeFound, got, retryIn } // persist places the bytes and commits the rows referencing them, excluding Prune for that diff --git a/core/artwork/processor_test.go b/core/artwork/processor_test.go index 0ca5a308e..554ca08dc 100644 --- a/core/artwork/processor_test.go +++ b/core/artwork/processor_test.go @@ -90,7 +90,7 @@ var _ = Describe("processor.acquire", func() { {ID: "al1", Name: "Album", FolderIDs: []string{"f1"}}, }) - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1"}) + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1"}) Expect(out).To(Equal(outcomeFound)) ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary) @@ -127,7 +127,7 @@ var _ = Describe("processor.acquire", func() { {ID: "alL1", Name: "Album", FolderIDs: []string{"f1"}}, }) - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alL1"}) + 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") @@ -141,7 +141,7 @@ var _ = Describe("processor.acquire", func() { {ID: "alL2", Name: "Album", FolderIDs: []string{"f1"}}, }) - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alL2"}) + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alL2"}) Expect(out).To(Equal(outcomeAbsent)) Expect(lock.locks).To(BeZero()) }) @@ -153,7 +153,7 @@ var _ = Describe("processor.acquire", func() { }) folderRepo.result = nil - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2"}) + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2"}) Expect(out).To(Equal(outcomeFound)) ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al2", model.ImageTypePrimary) @@ -176,7 +176,7 @@ var _ = Describe("processor.acquire", func() { {ID: "al3", Name: "Album"}, }) - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3"}) + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3"}) Expect(out).To(Equal(outcomeAbsent)) ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al3", model.ImageTypePrimary) @@ -197,7 +197,7 @@ var _ = Describe("processor.acquire", func() { {ID: "al-io", Name: "Album", FolderIDs: []string{"f1"}}, }) - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al-io"}) + 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) @@ -222,7 +222,7 @@ var _ = Describe("processor.acquire", func() { 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"}) + 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) @@ -249,7 +249,7 @@ var _ = Describe("processor.acquire", func() { radioRepo.Data["ra-tr"] = &model.Radio{ID: "ra-tr", Name: "Station", UploadedImage: "ra-tr.jpg"} trace := &ChainTrace{} - out, _ := proc.acquire(withTrace(ctx, trace), model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra-tr"}) + out, _, _ := proc.acquire(withTrace(ctx, trace), model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra-tr"}) Expect(out).To(Equal(outcomeFailed)) steps := trace.Steps() @@ -265,13 +265,26 @@ var _ = Describe("processor.acquire", func() { }) imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4"}) + out, _, retryIn := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4"}) Expect(out).To(Equal(outcomeFailed)) + Expect(retryIn).To(BeZero(), "a plain failure asks for no particular delay") _, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al4", model.ImageTypePrimary) Expect(err).To(MatchError(model.ErrNotFound)) }) + It("failed-on-extError: reports the delay a throttled provider asked for", func() { + conf.Server.CoverArtPriority = "external" + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ + {ID: "al4r", Name: "Album"}, + }) + imageAgents(&fakeImageAgent{name: "throttled", err: &agents.RetryLaterError{RetryIn: 42 * time.Second}}) + + out, _, retryIn := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4r"}) + Expect(out).To(Equal(outcomeFailed)) + Expect(retryIn).To(Equal(42 * time.Second)) + }) + 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{{ @@ -283,7 +296,7 @@ var _ = Describe("processor.acquire", func() { }) imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alstale"}) + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alstale"}) Expect(out).To(Equal(outcomeFoundStale)) ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alstale", model.ImageTypePrimary) @@ -300,7 +313,7 @@ var _ = Describe("processor.acquire", func() { ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alU", Name: "Album", FolderIDs: []string{"f1"}}}) folderRepo.result = []model.Folder{{Path: "album", ImageFiles: []string{"cover.jpg"}}} - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alU"}) + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alU"}) Expect(out).To(Equal(outcomeFound)) ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alU", model.ImageTypePrimary) @@ -320,7 +333,7 @@ var _ = Describe("processor.acquire", func() { ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alE", Name: "Album", FolderIDs: []string{"f1"}}}) folderRepo.result = []model.Folder{{Path: "album", ImageFiles: []string{"cover.jpg"}}} - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alE"}) + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alE"}) Expect(out).To(Equal(outcomeFailed)) _, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alE", model.ImageTypePrimary) @@ -338,7 +351,7 @@ var _ = Describe("processor.acquire", func() { ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alX", Name: "Album"}}) imageAgents(&fakeImageAgent{name: "deezerFake", imgs: []agents.ExternalImage{{URL: srv.URL, Size: 500}}}) - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alX"}) + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alX"}) Expect(out).To(Equal(outcomeFailed)) _, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alX", model.ImageTypePrimary) @@ -357,7 +370,7 @@ var _ = Describe("processor.acquire", func() { 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"}) + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alext"}) Expect(out).To(Equal(outcomeFound)) ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alext", model.ImageTypePrimary) @@ -382,7 +395,7 @@ var _ = Describe("processor.acquire", func() { {ID: "al6", Name: "Album B", FolderIDs: []string{"f1"}}, }) - out1, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al5"}) + 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()) @@ -392,7 +405,7 @@ var _ = Describe("processor.acquire", func() { poisoned.BlurHash = "SENTINEL" artRepo.Data[ia1.Hash] = poisoned - out2, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al6"}) + 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()) @@ -422,7 +435,7 @@ var _ = Describe("processor.acquire", func() { }) folderRepo.result = []model.Folder{{Path: "album-a", ImageFiles: []string{"cover.jpg"}}} - outN, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alA"}) + 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()) @@ -436,7 +449,7 @@ var _ = Describe("processor.acquire", func() { 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"}) + 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()) @@ -467,7 +480,7 @@ var _ = Describe("processor.acquire", func() { 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"}) + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra1"}) Expect(out).To(Equal(outcomeFailed)) _, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary) @@ -488,7 +501,7 @@ var _ = Describe("processor.acquire", func() { 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"}) + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "big"}) Expect(out).To(Equal(outcomeFailed)) _, err = artRepo.GetItemArtwork(model.KindRadioArtwork, "big", model.ImageTypePrimary) @@ -554,7 +567,7 @@ var _ = Describe("processor.acquire", func() { Expect(err).ToNot(HaveOccurred()) Expect(artRepo.PutImage(&model.Artwork{Hash: hash, Mime: "application/octet-stream"})).To(Succeed()) - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alM"}) + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alM"}) Expect(out).To(Equal(outcomeFound)) upgraded, err := artRepo.GetImage(hash) @@ -574,7 +587,7 @@ var _ = Describe("processor.acquire", func() { Expect(os.WriteFile(blockedRoot, []byte("x"), 0600)).To(Succeed()) proc.store = NewImageStore(blockedRoot) - out, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al7"}) + out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al7"}) Expect(out).To(Equal(outcomeFailed)) _, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al7", model.ImageTypePrimary) diff --git a/core/artwork/resolve.go b/core/artwork/resolve.go index 6663679fa..e7a2d3765 100644 --- a/core/artwork/resolve.go +++ b/core/artwork/resolve.go @@ -25,9 +25,9 @@ type resolution struct { 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 faulted external source, carrying the provider's requested delay when it named one. + // With no reader it forces failed (never absent); on a hit, serve this but retry later. + extErr error // 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 @@ -36,14 +36,15 @@ type resolution struct { // 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 - trace *ChainTrace // nil only where no caller attached one + extErr error + localErr bool + trace *ChainTrace // nil only where no caller attached one } // try stamps the accumulated external failure onto a hit, and records the miss otherwise. func (c *chainState) try(candidate string, res resolution, ok bool) (resolution, bool) { if ok { - res.extError = c.extErr + res.extErr = c.extErr c.record(candidate, OutcomeHit, res.sourcePath) return res, true } @@ -62,7 +63,7 @@ func (c *chainState) record(candidate string, out Outcome, detail string) { // 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} + return resolution{extErr: c.extErr, localError: c.localErr} } // externalSource holds the agents to ask and the rate limiter/circuit breaker to ask them through. @@ -181,16 +182,16 @@ func chainFetchesExternal(priority string) bool { // Album and artist fetches stop here when the resolver is local-only, rather than at each point in // the chain walk; resolvePlaylist gates the third network path, the m3u image URL, itself. -func (r *resolver) fetchExternalAlbum(ctx context.Context, al model.Album) (io.ReadCloser, string, bool) { +func (r *resolver) fetchExternalAlbum(ctx context.Context, al model.Album) (io.ReadCloser, string, error) { if r.ext == nil { - return nil, "", false + return nil, "", nil } return fetchAlbumImage(ctx, r.ext.agents, r.ext.gate, al) } -func (r *resolver) fetchExternalArtist(ctx context.Context, ar model.Artist) (io.ReadCloser, string, bool) { +func (r *resolver) fetchExternalArtist(ctx context.Context, ar model.Artist) (io.ReadCloser, string, error) { if r.ext == nil { - return nil, "", false + return nil, "", nil } return fetchArtistImage(ctx, r.ext.agents, r.ext.gate, ar) } @@ -223,10 +224,10 @@ func (r *resolver) resolveAlbum(ctx context.Context, albumID string) (resolution return res, nil } case pattern == externalCandidate: - if rd, name, isErr := r.fetchExternalAlbum(ctx, *al); rd != nil { + if rd, name, err := r.fetchExternalAlbum(ctx, *al); rd != nil { return resolution{reader: rd, source: ExternalPrefix + name}, nil - } else if isErr { - chain.extErr = true + } else if err != nil { + chain.extErr = longerRetry(chain.extErr, err) } case len(imgFiles) > 0: res, ok := resolveFolderFile(ctx, lib, imgFiles, pattern) @@ -285,10 +286,10 @@ func (r *resolver) resolveArtist(ctx context.Context, artistID string) (resoluti } switch { case pattern == externalCandidate: - if rd, name, isErr := r.fetchExternalArtist(ctx, *ar); rd != nil { + if rd, name, err := r.fetchExternalArtist(ctx, *ar); rd != nil { return resolution{reader: rd, source: ExternalPrefix + name}, nil - } else if isErr { - chain.extErr = true + } else if err != nil { + chain.extErr = longerRetry(chain.extErr, err) } case pattern == "image-folder": res, ok := resolveArtistImageFolder(ar) @@ -332,7 +333,7 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso return resolution{}, err } - var extErr bool + var extErr error for _, src := range []struct{ path, source string }{ {pl.UploadedImagePath(), "upload"}, {findPlaylistSidecarPath(ctx, pl.Path), "folder"}, @@ -366,7 +367,7 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso if res, ok, err := resolveExternalStep(r.ext.gate, "m3u", sf); ok { return res, nil } else if err != nil { - extErr = true + extErr = longerRetry(extErr, err) // Record it here with its detail: once album sampling adds its own steps, the processor's // empty-trace fallback no longer fires, and the error that forced the retry would be lost. traceFrom(ctx).add(TraceStep{Candidate: ExternalPrefix + "m3u", Outcome: OutcomeError, Detail: err.Error()}) @@ -389,8 +390,8 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso } continue } - if res.extError { - extErr = true + if res.extErr != nil { + extErr = longerRetry(extErr, res.extErr) } if res.reader == nil { continue @@ -409,7 +410,7 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso if tileErr != nil { return resolution{}, fmt.Errorf("resolvePlaylist: sampled album art failed: %w", tileErr) } - return resolution{extError: extErr}, nil + return resolution{extErr: extErr}, nil } // Grow to 4 tiles by repeating what we have. switch len(tiles) { @@ -420,9 +421,9 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso } 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{extErr: extErr}, nil //nolint:nilerr // encode failure is a soft "no image", not a resolution error } - return resolution{reader: grid, source: "generated", extError: extErr}, nil + return resolution{reader: grid, source: "generated", extErr: extErr}, nil } // resolveRadio serves only an uploaded image; there is no fallback. diff --git a/core/artwork/resolve_test.go b/core/artwork/resolve_test.go index 236e76b9b..402a11363 100644 --- a/core/artwork/resolve_test.go +++ b/core/artwork/resolve_test.go @@ -100,7 +100,7 @@ var _ = Describe("resolveItem", func() { 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()) + Expect(res.extErr).ToNot(HaveOccurred()) }) It("resolves absent when the track has no cover art", func() { @@ -111,7 +111,7 @@ var _ = Describe("resolveItem", func() { 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()) + Expect(res.extErr).ToNot(HaveOccurred()) }) It("resolves absent when media file cover art is disabled", func() { @@ -154,7 +154,7 @@ var _ = Describe("resolveItem", func() { 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()) + Expect(res.extErr).ToNot(HaveOccurred()) }) It("falls back to embedded art when no folder image matches", func() { @@ -172,7 +172,7 @@ var _ = Describe("resolveItem", func() { Expect(res.refMtime).To(BeNumerically(">", 0)) }) - It("sets extError when the external source errors without being not-found", func() { + It("sets extErr 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"}, @@ -182,10 +182,10 @@ var _ = Describe("resolveItem", func() { 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()) + Expect(res.extErr).To(HaveOccurred()) }) - It("does not set extError when the external source reports not-found", func() { + It("does not set extErr when the external source reports not-found", func() { conf.Server.CoverArtPriority = "external" ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ {ID: "al4", Name: "Album"}, @@ -195,10 +195,10 @@ var _ = Describe("resolveItem", func() { 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()) + Expect(res.extErr).ToNot(HaveOccurred()) }) - It("carries extError onto a fallback folder hit after a transient external failure", func() { + It("carries extErr 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", @@ -214,10 +214,10 @@ var _ = Describe("resolveItem", func() { Expect(res.reader).ToNot(BeNil()) defer res.reader.Close() Expect(res.source).To(Equal("folder")) - Expect(res.extError).To(BeTrue()) + Expect(res.extErr).To(HaveOccurred()) }) - It("does not carry extError onto a fallback folder hit after a definitive external not-found", func() { + It("does not carry extErr 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", @@ -233,7 +233,7 @@ var _ = Describe("resolveItem", func() { Expect(res.reader).ToNot(BeNil()) defer res.reader.Close() Expect(res.source).To(Equal("folder")) - Expect(res.extError).To(BeFalse()) + Expect(res.extErr).ToNot(HaveOccurred()) }) It("routes the external step through the injected gate, keyed by agent name", func() { @@ -250,7 +250,7 @@ var _ = Describe("resolveItem", func() { 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(res.extErr).To(HaveOccurred()) Expect(gatedNames).To(Equal([]string{"failAgent"})) }) }) @@ -298,7 +298,7 @@ var _ = Describe("resolveItem", func() { 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() { + It("sets extErr 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"}}) @@ -308,10 +308,10 @@ var _ = Describe("resolveItem", func() { 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()) + Expect(res.extErr).To(HaveOccurred()) }) - It("does not set extError when the external source reports not-found", func() { + It("does not set extErr when the external source reports not-found", func() { conf.Server.ArtistArtPriority = "external" artistRepo := tests.CreateMockArtistRepo() artistRepo.SetData(model.Artists{{ID: "ar4", Name: "Artist"}}) @@ -321,7 +321,7 @@ var _ = Describe("resolveItem", func() { 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()) + Expect(res.extErr).ToNot(HaveOccurred()) }) It("routes the external step through the injected gate, keyed by agent name", func() { @@ -338,7 +338,7 @@ var _ = Describe("resolveItem", func() { 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(res.extErr).To(HaveOccurred()) Expect(gatedNames).To(Equal([]string{"failAgent"})) }) }) @@ -516,7 +516,7 @@ var _ = Describe("resolveItem", func() { 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(res.extErr).To(HaveOccurred()) Expect(gatedNames).To(Equal([]string{"m3u"}), "the playlist URL fetch is gated under \"m3u\"") }) @@ -537,7 +537,7 @@ var _ = Describe("resolveItem", func() { res, err := newResolver(ds, ag, ffm, gate).resolve(withTrace(ctx, trace), model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plm3u"}) Expect(err).ToNot(HaveOccurred()) - Expect(res.extError).To(BeTrue()) + Expect(res.extErr).To(HaveOccurred()) steps := trace.Steps() var m3u *TraceStep @@ -562,7 +562,7 @@ var _ = Describe("resolveItem", func() { 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()) + Expect(res.extErr).ToNot(HaveOccurred()) }) It("treats an ExternalImageURL 404 as a definitive miss and falls through to the grid", func() { @@ -582,7 +582,7 @@ var _ = Describe("resolveItem", func() { Expect(res.reader).ToNot(BeNil()) defer res.reader.Close() Expect(res.source).To(Equal("generated")) - Expect(res.extError).To(BeFalse()) + Expect(res.extErr).ToNot(HaveOccurred()) }) // A local resolver holds no agents: reaching the external branch would panic, not degrade. @@ -594,7 +594,7 @@ var _ = Describe("resolveItem", func() { 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") + Expect(res.extErr).ToNot(HaveOccurred(), "a skipped step is not a failed one") }) // The worker resolving the same playlist is asserted alongside, so this cannot pass vacuously. @@ -642,7 +642,7 @@ var _ = Describe("resolveItem", func() { 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()) + Expect(res.extErr).To(HaveOccurred()) }) It("yields an empty resolution when no album has art", func() { diff --git a/core/artwork/worker.go b/core/artwork/worker.go index 0358708c0..bea478aa5 100644 --- a/core/artwork/worker.go +++ b/core/artwork/worker.go @@ -241,7 +241,7 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc item.ImageType = cmp.Or(item.ImageType, model.ImageTypePrimary) trace := &ChainTrace{} ctx = withTrace(ctx, trace) - out, got := w.proc.acquire(ctx, item) + out, got, retryIn := w.proc.acquire(ctx, item) queue := w.proc.ds.ArtworkQueue(ctx) switch out { @@ -252,7 +252,7 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc 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)) + retryAt := time.Now().Add(retryDelay(item.Attempts, retryIn)) encoded := trace.encode("") if retryAt.Before(item.EnqueuedAt.Add(giveUpAfter)) { // A mid-flight re-enqueue reset retry_at; stale backoff must not stomp its @@ -341,3 +341,8 @@ func backoffFor(attempts int, jitter float64) time.Duration { func backoff(attempts int) time.Duration { return backoffFor(attempts, rand.Float64()*0.8-0.4) //nolint:gosec // retry jitter, not security-sensitive } + +// retryDelay is how long a failed item waits: our backoff, unless the provider asked for longer. +func retryDelay(attempts int, hint time.Duration) time.Duration { + return max(backoff(attempts), hint) +} diff --git a/core/artwork/worker_soak_test.go b/core/artwork/worker_soak_test.go index eb7346102..803cc2dfe 100644 --- a/core/artwork/worker_soak_test.go +++ b/core/artwork/worker_soak_test.go @@ -95,7 +95,7 @@ var _ = Describe("Worker soak", func() { start := time.Now() for i := range soakCycles { it := items[i%len(items)] - out, _ := proc.acquire(context.Background(), it) + out, _, _ := proc.acquire(context.Background(), it) // Read-back exercises the surfaces a caller would use after acquisition. if out == outcomeFound { diff --git a/core/artwork/worker_test.go b/core/artwork/worker_test.go index 248e400e1..b0ef665fc 100644 --- a/core/artwork/worker_test.go +++ b/core/artwork/worker_test.go @@ -245,6 +245,23 @@ var _ = Describe("Worker", func() { Expect(err).To(MatchError(model.ErrNotFound), "a timeout must never settle on absent") }) + It("reschedules past the provider's requested delay when it exceeds the backoff", func() { + conf.Server.CoverArtPriority = "external" + ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al9", Name: "Album"}}) + // Well above backoff(0)'s jittered ceiling, so only the hint can produce this retry_at. + const askedFor = 90 * time.Minute + imageAgents(&fakeImageAgent{name: "throttledAgent", err: &agents.RetryLaterError{RetryIn: askedFor}}) + Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al9"})).To(Succeed()) + + n, err := w.drain(ctx, 2) + Expect(err).ToNot(HaveOccurred()) + Expect(n).To(Equal(1)) + + it := findQueued(queueRepo, "al", "al9") + Expect(it).ToNot(BeNil()) + Expect(it.RetryAt).To(BeTemporally("~", time.Now().Add(askedFor), time.Minute)) + }) + It("reschedules a found-stale item via MarkFailed while keeping its served state", func() { conf.Server.CoverArtPriority = "external, cover.jpg" folderRepo.result = []model.Folder{{ @@ -911,3 +928,19 @@ var _ = Describe("backoff", func() { } }) }) + +var _ = Describe("retryDelay", func() { + It("uses the backoff schedule when the provider asked for nothing", func() { + d := retryDelay(0, 0) + Expect(d).To(BeNumerically(">=", 3*time.Second)) + Expect(d).To(BeNumerically("<=", 7*time.Second)) + }) + + It("waits the provider's delay when it is longer than the backoff", func() { + Expect(retryDelay(0, time.Hour)).To(Equal(time.Hour)) + }) + + It("keeps the backoff when it is longer than the provider's delay", func() { + Expect(retryDelay(4, time.Second)).To(BeNumerically(">=", 3*time.Second)) + }) +}) diff --git a/core/external/provider.go b/core/external/provider.go index 5c46dc644..3a3f4bd46 100644 --- a/core/external/provider.go +++ b/core/external/provider.go @@ -148,7 +148,8 @@ func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAl start := time.Now() albumName := album.Name() info, err := e.ag.GetAlbumInfo(ctx, albumName, album.AlbumArtist, album.MbzAlbumID) - if errors.Is(err, agents.ErrNotFound) { + // Throttled joins not-found: no answer to store, and an unstamped timestamp retries next call. + if errors.Is(err, agents.ErrNotFound) || errors.Is(err, agents.ErrRetryLater) { return album, nil } if err != nil { @@ -253,28 +254,37 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au start := time.Now() // Get MBID first, if it is not yet available artistName := artist.Name() + var mbidErr error if artist.MbzArtistID == "" { mbid, err := e.ag.GetArtistMBID(ctx, artist.ID, artistName) + mbidErr = err if mbid != "" && err == nil { artist.MbzArtistID = mbid } } - // Call all registered agents and collect information + // Call all registered agents and collect information. The group carries no context, so a + // returned error does not cancel the siblings; only throttling is reported back. g := errgroup.Group{} g.SetLimit(2) - 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 }) - _ = g.Wait() + g.Go(func() error { return retryLaterOnly(e.callGetImage(ctx, e.ag, &artist)) }) + g.Go(func() error { return retryLaterOnly(e.callGetBiography(ctx, e.ag, &artist)) }) + g.Go(func() error { return retryLaterOnly(e.callGetURL(ctx, e.ag, &artist)) }) + g.Go(func() error { + return retryLaterOnly(e.callGetSimilarArtists(ctx, e.ag, &artist, maxSimilarArtists, true)) + }) + throttled := errors.Is(g.Wait(), agents.ErrRetryLater) || errors.Is(mbidErr, agents.ErrRetryLater) if utils.IsCtxDone(ctx) { log.Warn(ctx, "ArtistInfo update canceled", "id", artist.ID, "name", artistName, "elapsed", time.Since(start), ctx.Err()) return artist, ctx.Err() } - artist.ExternalInfoUpdatedAt = new(time.Now()) + // A throttled round keeps the previous timestamp, so the next call retries instead of + // serving an empty cache entry for the whole TTL. + if !throttled { + artist.ExternalInfoUpdatedAt = new(time.Now()) + } err := e.ds.Artist(ctx).UpdateExternalInfo(&artist.Artist) if err != nil { log.Error(ctx, "Error trying to update artist external information", "id", artist.ID, "name", artistName, @@ -334,8 +344,9 @@ func (e *provider) TopSongs(ctx context.Context, artistName, id string, count in songs, err := e.getMatchingTopSongs(ctx, e.ag, artist, count) if err != nil { switch { - case errors.Is(err, agents.ErrNotFound): - log.Trace(ctx, "TopSongs not found", "name", artistName) + // Throttled is not an answer, but the caller keeps the empty 200 it got before. + case errors.Is(err, agents.ErrNotFound), errors.Is(err, agents.ErrRetryLater): + log.Trace(ctx, "TopSongs not found", "name", artistName, err) return nil, model.ErrNotFound case errors.Is(err, context.Canceled): log.Debug(ctx, "TopSongs call canceled", err) @@ -385,22 +396,33 @@ func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistT return mfs, nil } -func (e *provider) callGetURL(ctx context.Context, agent agents.ArtistURLRetriever, artist *auxArtist) { - artisURL, err := agent.GetArtistURL(ctx, artist.ID, artist.Name(), artist.MbzArtistID) - if err != nil { - return +// retryLaterOnly discards every failure the caller does not act on, so errgroup's +// first-error slot is reserved for the throttling signal. +func retryLaterOnly(err error) error { + if errors.Is(err, agents.ErrRetryLater) { + return err } - artist.ExternalUrl = artisURL + return nil } -func (e *provider) callGetBiography(ctx context.Context, agent agents.ArtistBiographyRetriever, artist *auxArtist) { +func (e *provider) callGetURL(ctx context.Context, agent agents.ArtistURLRetriever, artist *auxArtist) error { + artisURL, err := agent.GetArtistURL(ctx, artist.ID, artist.Name(), artist.MbzArtistID) + if err != nil { + return err + } + artist.ExternalUrl = artisURL + return nil +} + +func (e *provider) callGetBiography(ctx context.Context, agent agents.ArtistBiographyRetriever, artist *auxArtist) error { bio, err := agent.GetArtistBiography(ctx, artist.ID, artist.Name(), artist.MbzArtistID) if err != nil { - return + return err } bio = str.SanitizeText(bio) bio = strings.ReplaceAll(bio, "\n", " ") artist.Biography = strings.ReplaceAll(bio, "(retry_later[:seconds])` token, which is +// all a plugin fault carries back across the WASM boundary. The capability is part of the +// pattern, so another capability's token in the same message cannot mask this one. The leading +// \b keeps a superstring like `useragent(retry_later)` from matching `agent`. +var ( + agentRetryLaterRe = retryLaterRe("agent") + scrobblerRetryLaterRe = retryLaterRe("scrobbler") +) + +func retryLaterRe(capability string) *regexp.Regexp { + return regexp.MustCompile(`\b` + capability + `\(retry_later(?::(\d+))?\)`) +} + +// parseRetryLater reports whether msg carries the capability's retry_later token, with its delay. +func parseRetryLater(re *regexp.Regexp, msg string) (*agents.RetryLaterError, bool) { + m := re.FindStringSubmatch(msg) + if m == nil { + return nil, false + } + return &agents.RetryLaterError{RetryIn: agents.ParseRetryIn(m[1])}, true +} diff --git a/plugins/capabilities/metadata_agent.go b/plugins/capabilities/metadata_agent.go index 72cb1622f..8fec7f5a8 100644 --- a/plugins/capabilities/metadata_agent.go +++ b/plugins/capabilities/metadata_agent.go @@ -224,3 +224,15 @@ type SimilarSongsResponse struct { // Songs is the list of similar songs. Songs []types.SongRef `json:"songs"` } + +// MetadataAgentError represents an error type for metadata agent operations. +type MetadataAgentError string + +const ( + // MetadataAgentErrorRetryLater indicates the provider is throttling; retry later. + // Append ":" inside the parentheses to request a specific delay. + MetadataAgentErrorRetryLater MetadataAgentError = "agent(retry_later)" +) + +// Error implements the error interface for MetadataAgentError. +func (e MetadataAgentError) Error() string { return string(e) } diff --git a/plugins/metadata_agent.go b/plugins/metadata_agent.go index 607926438..17062ba6b 100644 --- a/plugins/metadata_agent.go +++ b/plugins/metadata_agent.go @@ -56,6 +56,9 @@ func agentErr(err error) error { if errors.Is(err, errNotImplemented) || errors.Is(err, errFunctionNotFound) { return errors.Join(agents.ErrNotFound, err) } + if retryLater, ok := parseRetryLater(agentRetryLaterRe, err.Error()); ok { + return errors.Join(retryLater, err) + } return err } diff --git a/plugins/metadata_agent_test.go b/plugins/metadata_agent_test.go index 2dc67d41c..a7a0aa8b8 100644 --- a/plugins/metadata_agent_test.go +++ b/plugins/metadata_agent_test.go @@ -5,6 +5,7 @@ package plugins import ( "errors" "fmt" + "time" "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/plugins/capabilities" @@ -31,6 +32,31 @@ var _ = Describe("agentErr", func() { Entry("a non-zero exit is a fault", errors.New("plugin call exited with code 1"), false), ) + + DescribeTable("agentErr retry-later", + func(msg string, wantDelay time.Duration) { + err := agentErr(errors.New(msg)) + Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) + retry, _ := errors.AsType[*agents.RetryLaterError](err) + d := retry.RetryIn + Expect(d).To(Equal(wantDelay)) + }, + Entry("bare token", "agent(retry_later)", time.Duration(0)), + Entry("with seconds", "agent(retry_later:120)", 120*time.Second), + Entry("capped at 1h", "agent(retry_later:999999)", time.Hour), + // Scaling to nanoseconds before capping wraps past 2^64, landing on ~0.29s. + Entry("capped before it can overflow", "agent(retry_later:18446744074)", time.Hour), + ) + + It("leaves other plugin errors untouched", func() { + orig := errors.New("some plugin failure") + Expect(agentErr(orig)).To(Equal(orig)) + }) + + It("does not treat a superstring token as a throttle", func() { + orig := errors.New("useragent(retry_later)") + Expect(agentErr(orig)).To(Equal(orig)) + }) }) var _ = Describe("MetadataAgent", Ordered, func() { diff --git a/plugins/pdk/go/metadata/metadata.go b/plugins/pdk/go/metadata/metadata.go index bb0ae9620..57546352e 100644 --- a/plugins/pdk/go/metadata/metadata.go +++ b/plugins/pdk/go/metadata/metadata.go @@ -18,6 +18,18 @@ type ArtistRef = types.ArtistRef // Deprecated: use types.SongRef. type SongRef = types.SongRef +// MetadataAgentError represents an error type for metadata agent operations. +type MetadataAgentError string + +const ( + // MetadataAgentErrorRetryLater indicates the provider is throttling; retry later. + // Append ":" inside the parentheses to request a specific delay. + MetadataAgentErrorRetryLater MetadataAgentError = "agent(retry_later)" +) + +// Error implements the error interface for MetadataAgentError. +func (e MetadataAgentError) Error() string { return string(e) } + // AlbumImagesResponse is the response for GetAlbumImages. type AlbumImagesResponse struct { // Images is the list of album images. diff --git a/plugins/pdk/go/metadata/metadata_stub.go b/plugins/pdk/go/metadata/metadata_stub.go index 572eba4da..f979419a9 100644 --- a/plugins/pdk/go/metadata/metadata_stub.go +++ b/plugins/pdk/go/metadata/metadata_stub.go @@ -16,6 +16,18 @@ type ArtistRef = types.ArtistRef // Deprecated: use types.SongRef. type SongRef = types.SongRef +// MetadataAgentError represents an error type for metadata agent operations. +type MetadataAgentError string + +const ( + // MetadataAgentErrorRetryLater indicates the provider is throttling; retry later. + // Append ":" inside the parentheses to request a specific delay. + MetadataAgentErrorRetryLater MetadataAgentError = "agent(retry_later)" +) + +// Error implements the error interface for MetadataAgentError. +func (e MetadataAgentError) Error() string { return string(e) } + // AlbumImagesResponse is the response for GetAlbumImages. type AlbumImagesResponse struct { // Images is the list of album images. diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/metadata.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/metadata.rs index 38fcae9da..890e16954 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/metadata.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/metadata.rs @@ -24,6 +24,11 @@ pub type ArtistRef = nd_pdk_types::ArtistRef; #[deprecated(note = "use nd_pdk::types::SongRef")] pub type SongRef = nd_pdk_types::SongRef; +/// MetadataAgentError represents an error type for metadata agent operations. +pub type MetadataAgentError = &'static str; +/// MetadataAgentErrorRetryLater indicates the provider is throttling; retry later. +/// Append ":" inside the parentheses to request a specific delay. +pub const METADATA_AGENT_ERROR_RETRY_LATER: MetadataAgentError = "agent(retry_later)"; /// AlbumImagesResponse is the response for GetAlbumImages. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/plugins/scrobbler_adapter.go b/plugins/scrobbler_adapter.go index b3203a352..721f0d3fa 100644 --- a/plugins/scrobbler_adapter.go +++ b/plugins/scrobbler_adapter.go @@ -174,11 +174,12 @@ func mapScrobblerError(err error) error { return nil } errMsg := err.Error() + retryLater, isRetryLater := parseRetryLater(scrobblerRetryLaterRe, errMsg) switch { case strings.Contains(errMsg, capabilities.ScrobblerErrorNotAuthorized.Error()): return scrobbler.ErrNotAuthorized - case strings.Contains(errMsg, capabilities.ScrobblerErrorRetryLater.Error()): - return scrobbler.ErrRetryLater + case isRetryLater: + return retryLater case strings.Contains(errMsg, capabilities.ScrobblerErrorUnrecoverable.Error()): return scrobbler.ErrUnrecoverable default: diff --git a/plugins/scrobbler_adapter_test.go b/plugins/scrobbler_adapter_test.go index 56a452742..3efd5d1b1 100644 --- a/plugins/scrobbler_adapter_test.go +++ b/plugins/scrobbler_adapter_test.go @@ -7,6 +7,7 @@ import ( "errors" "time" + "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -362,4 +363,25 @@ var _ = Describe("mapScrobblerError", func() { err := mapScrobblerError(errors.New("some unknown error")) Expect(err).To(MatchError(scrobbler.ErrUnrecoverable)) }) + + DescribeTable("mapScrobblerError retry-later", + func(msg string, wantDelay time.Duration) { + err := mapScrobblerError(errors.New(msg)) + Expect(errors.Is(err, scrobbler.ErrRetryLater)).To(BeTrue()) + retry, _ := errors.AsType[*agents.RetryLaterError](err) + d := retry.RetryIn + Expect(d).To(Equal(wantDelay)) + }, + Entry("bare token", "scrobbler(retry_later)", time.Duration(0)), + Entry("with seconds", "scrobbler(retry_later:30)", 30*time.Second), + Entry("capped at 1h", "scrobbler(retry_later:999999)", time.Hour), + // Scaling to nanoseconds before capping wraps past 2^64, landing on ~0.29s. + Entry("capped before it can overflow", "scrobbler(retry_later:18446744074)", time.Hour), + Entry("wrapped in context", "plugin xyz: scrobbler(retry_later:5)", 5*time.Second), + ) + + It("still maps unknown errors to unrecoverable", func() { + err := mapScrobblerError(errors.New("scrobbler(retry_later_garbage")) + Expect(errors.Is(err, scrobbler.ErrUnrecoverable)).To(BeTrue()) + }) }) From b134f16fd5f95308700b1e66940fc4f495f80ff2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 30 Aug 2026 11:11:35 -0400 Subject: [PATCH 34/78] feat(plugins): surface the valid agent names in logs and the Plugins UI (#5910) The agent name used in the `Agents` config option comes from the .ndp file name, not from the manifest. The Plugins UI showed the ID but never said what it was for, so renaming a plugin file silently breaks the config with only a Debug-level "Unknown agent ignored" line to go on. Add a caption under the ID in the Plugins UI, and list the accepted names alongside the rejected one in that log line. Related to navidrome/apple-music-plugin#14 --- core/agents/agents.go | 10 +++++++- core/agents/agents_test.go | 17 ++++++++++++++ resources/i18n/pt-br.json | 1 + ui/src/i18n/en.json | 1 + ui/src/plugin/InfoCard.jsx | 9 +++++++- ui/src/plugin/InfoCard.test.jsx | 41 +++++++++++++++++++++++++++++++++ ui/src/plugin/styles.js | 4 ++++ 7 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 ui/src/plugin/InfoCard.test.jsx diff --git a/core/agents/agents.go b/core/agents/agents.go index ac623951c..8ae3124ef 100644 --- a/core/agents/agents.go +++ b/core/agents/agents.go @@ -4,6 +4,7 @@ import ( "cmp" "context" "errors" + "maps" "slices" "strings" "sync" @@ -126,12 +127,19 @@ func (a *Agents) getEnabledAgentNames() []enabledAgent { } else if isPlugin { validAgents = append(validAgents, enabledAgent{name: name, isPlugin: true}) } else { - log.Debug("Unknown agent ignored", "name", name) + log.Debug("Unknown agent ignored", "name", name, "available", availableAgentNames(availablePlugins)) } } return validAgents } +// availableAgentNames returns every name accepted by the Agents config option. +func availableAgentNames(plugins []string) []string { + names := append(slices.Collect(maps.Keys(Map)), plugins...) + slices.Sort(names) + return names +} + func (a *Agents) getAgent(ea enabledAgent) Interface { if ea.isPlugin { // Try to load WASM plugin agent (if plugin loader is available) diff --git a/core/agents/agents_test.go b/core/agents/agents_test.go index 35ebf18d8..6163c7f3c 100644 --- a/core/agents/agents_test.go +++ b/core/agents/agents_test.go @@ -3,6 +3,7 @@ package agents import ( "context" "errors" + "slices" "time" "github.com/navidrome/navidrome/conf/configtest" @@ -91,6 +92,22 @@ var _ = Describe("Agents", func() { Expect(ags).ToNot(ContainElement("disabled")) }) + Describe("availableAgentNames", func() { + It("combines built-in agents with the given plugins", func() { + names := availableAgentNames([]string{"apple-music"}) + Expect(names).To(ContainElements("apple-music", LocalAgentName, "fake", "empty")) + }) + + It("returns the names sorted", func() { + names := availableAgentNames([]string{"zz-plugin", "aa-plugin"}) + Expect(slices.IsSorted(names)).To(BeTrue()) + }) + + It("works when there are no plugins", func() { + Expect(availableAgentNames(nil)).To(ContainElement(LocalAgentName)) + }) + }) + Describe("GetArtistMBID", func() { It("returns on first match", func() { Expect(ag.GetArtistMBID(ctx, "123", "test")).To(Equal("mbid")) diff --git a/resources/i18n/pt-br.json b/resources/i18n/pt-br.json index a4ad6bc8c..ccc5f872b 100644 --- a/resources/i18n/pt-br.json +++ b/resources/i18n/pt-br.json @@ -394,6 +394,7 @@ "invalidJson": "A configuração deve ser um JSON válido" }, "messages": { + "idHelp": "O ID do plugin, derivado do nome do arquivo. Use-o ao referenciar este plugin em opções de configuração, como Agents.", "configHelp": "Configure o plugin usando pares chave-valor. Deixe vazio se o plugin não precisa de configuração.", "clickPermissions": "Clique em uma permissão para ver detalhes", "noConfig": "Nenhuma configuração definida", diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index 8823a6749..de96d47c0 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -397,6 +397,7 @@ "invalidJson": "Configuration must be valid JSON" }, "messages": { + "idHelp": "The plugin ID, derived from its file name. Use it when referencing this plugin in configuration options, such as Agents.", "configHelp": "Configure the plugin using key-value pairs. Leave empty if the plugin requires no configuration.", "configValidationError": "Configuration validation failed:", "schemaRenderError": "Unable to render configuration form. The plugin's schema may be invalid.", diff --git a/ui/src/plugin/InfoCard.jsx b/ui/src/plugin/InfoCard.jsx index 8fb6853fe..3a7bb24c7 100644 --- a/ui/src/plugin/InfoCard.jsx +++ b/ui/src/plugin/InfoCard.jsx @@ -123,6 +123,13 @@ export const InfoCard = ({ record, manifest, classes, translate, isSmall }) => ( isSmall={isSmall} > {record.id} + + {translate('resources.plugin.messages.idHelp')} + {manifest?.name && ( @@ -201,7 +208,7 @@ export const InfoCard = ({ record, manifest, classes, translate, isSmall }) => ( {translate('resources.plugin.messages.clickPermissions')} diff --git a/ui/src/plugin/InfoCard.test.jsx b/ui/src/plugin/InfoCard.test.jsx new file mode 100644 index 000000000..24fa48caa --- /dev/null +++ b/ui/src/plugin/InfoCard.test.jsx @@ -0,0 +1,41 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' + +vi.mock('../common', () => ({ + DateField: ({ source }) => , +})) + +const { InfoCard } = await import('./InfoCard') + +const record = { + id: 'apple-music', + path: '/data/plugins/apple-music.ndp', + updatedAt: '2026-01-01T00:00:00Z', + createdAt: '2026-01-01T00:00:00Z', +} + +const renderCard = () => + render( + key} + isSmall={false} + />, + ) + +describe('InfoCard', () => { + it('shows the plugin ID', () => { + renderCard() + expect(screen.getByText('apple-music')).toBeInTheDocument() + }) + + it('explains that the ID is the name used in config options', () => { + renderCard() + expect( + screen.getByText('resources.plugin.messages.idHelp'), + ).toBeInTheDocument() + }) +}) diff --git a/ui/src/plugin/styles.js b/ui/src/plugin/styles.js index 104d8bc0f..68c0be929 100644 --- a/ui/src/plugin/styles.js +++ b/ui/src/plugin/styles.js @@ -45,6 +45,10 @@ export const usePluginShowStyles = makeStyles( fontSize: '0.85rem', wordBreak: 'break-all', }, + fieldHelp: { + marginTop: theme.spacing(0.5), + display: 'block', + }, permissionsContainer: { display: 'flex', flexWrap: 'wrap', From 3867fab4da6ea7142bfc6374c94b34a708e03b4e Mon Sep 17 00:00:00 2001 From: Aditya Raj Singh Date: Sun, 30 Aug 2026 20:51:14 +0530 Subject: [PATCH 35/78] fix(transcoding): report AAC streams as audio/aac instead of audio/mp4 (#5998) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default AAC transcode emits raw ADTS (`ffmpeg ... -f adts -`), but the MIME table mapped `.aac` to `audio/mp4`. Clients that dispatch strictly on Content-Type could reject the stream because the declared container did not match the payload. `.m4a` and `.alac` stay on `audio/mp4`, since those really are MP4. Fixes #5958 Signed-off-by: Aditya Raj Singh Co-authored-by: Deluan Quintão --- resources/mime_types.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/mime_types.yaml b/resources/mime_types.yaml index 83abf2e5c..18a2c22b5 100644 --- a/resources/mime_types.yaml +++ b/resources/mime_types.yaml @@ -10,7 +10,7 @@ types: .ogg: audio/ogg .oga: audio/ogg .opus: audio/ogg - .aac: audio/mp4 + .aac: audio/aac .alac: audio/mp4 .m4a: audio/mp4 .m4b: audio/mp4 From ff033d8db62c9b045ae3dea54fc794a8114dd5e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 30 Aug 2026 12:43:15 -0400 Subject: [PATCH 36/78] chore(deps): upgrade to Go 1.27 (#5990) * build: upgrade to Go 1.27 Bumps the toolchain in go.mod, both golang base images in the Dockerfile, and the devcontainer VARIANT. CI needs no change, as the workflows resolve the version through go-version-file: go.mod. Tests, race tests, build and vet all pass on go1.27.0. * build: upgrade golangci-lint to v2.13.0 v2.13.0 is the first release built with Go 1.27, so it can lint a module whose go directive is 1.27. It also enables gosec's G404 on math/rand/v2, which flags the three rand.Shuffle call sites. Shuffle order is not a security decision, and the crypto-backed alternative in utils/random costs 25x and allocates per swap, so the call sites are annotated rather than the rule excluded, keeping G404 active for the cases where it would matter. * chore(deps): update Go dependencies to latest versions Signed-off-by: Deluan * build: bump golangci-lint to v2.13.2 --------- Signed-off-by: Deluan --- .devcontainer/devcontainer.json | 2 +- Dockerfile | 4 ++-- Makefile | 2 +- core/external/provider_similarsongs.go | 1 + core/playback/queue.go | 1 + db/backup_test.go | 1 + go.mod | 2 +- 7 files changed, 8 insertions(+), 5 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index c9e4ba2bf..efe965428 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -4,7 +4,7 @@ "dockerfile": "Dockerfile", "args": { // Update the VARIANT arg to pick a version of Go: 1, 1.15, 1.14 - "VARIANT": "1.26", + "VARIANT": "1.27", // Options "INSTALL_NODE": "true", "NODE_VERSION": "v24" diff --git a/Dockerfile b/Dockerfile index 847c19bf7..3d7a03f5d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -43,7 +43,7 @@ COPY --from=ui /build /build ######################################################################################################################## ### Build Navidrome binary for Docker image (dynamic musl, enables native libwebp via dlopen) -FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS build-alpine +FROM --platform=$BUILDPLATFORM golang:1.27-alpine AS build-alpine COPY --from=xx / / ARG TARGETPLATFORM @@ -85,7 +85,7 @@ EOT ######################################################################################################################## ### Build Navidrome binary for standalone distribution (static glibc, cross-compiled) -FROM --platform=$BUILDPLATFORM golang:1.26-trixie AS base +FROM --platform=$BUILDPLATFORM golang:1.27-trixie AS base RUN apt-get update && apt-get install -y clang lld COPY --from=xx / / WORKDIR /workspace diff --git a/Makefile b/Makefile index fa0d10475..81a609422 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,7 @@ IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "lin PLATFORMS ?= $(SUPPORTED_PLATFORMS) DOCKER_TAG ?= deluan/navidrome:develop -GOLANGCI_LINT_VERSION ?= v2.12.0 +GOLANGCI_LINT_VERSION ?= v2.13.2 UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*") diff --git a/core/external/provider_similarsongs.go b/core/external/provider_similarsongs.go index 7720c9349..4ab465b03 100644 --- a/core/external/provider_similarsongs.go +++ b/core/external/provider_similarsongs.go @@ -167,6 +167,7 @@ func (e *provider) seedMix(ctx context.Context, count int, sample func() (model. if len(matched) == 0 { matched = seeds } + //nolint:gosec // shuffle order is not a security decision rand.Shuffle(len(matched), func(i, j int) { matched[i], matched[j] = matched[j], matched[i] }) if len(matched) > count { matched = matched[:count] diff --git a/core/playback/queue.go b/core/playback/queue.go index d15eaad96..116db5fe6 100644 --- a/core/playback/queue.go +++ b/core/playback/queue.go @@ -100,6 +100,7 @@ func (pd *Queue) Shuffle() { backupID = current.ID } + //nolint:gosec // shuffle order is not a security decision rand.Shuffle(len(pd.Items), func(i, j int) { pd.Items[i], pd.Items[j] = pd.Items[j], pd.Items[i] }) var err error diff --git a/db/backup_test.go b/db/backup_test.go index 5e8f877e6..5d1bfc6e3 100644 --- a/db/backup_test.go +++ b/db/backup_test.go @@ -68,6 +68,7 @@ var _ = Describe("database backups", func() { timesShuffled = make([]time.Time, len(timesDecreasingChronologically)) copy(timesShuffled, timesDecreasingChronologically) + //nolint:gosec // shuffle order is not a security decision rand.Shuffle(len(timesShuffled), func(i, j int) { timesShuffled[i], timesShuffled[j] = timesShuffled[j], timesShuffled[i] }) diff --git a/go.mod b/go.mod index 93e84b052..a7b005c25 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/navidrome/navidrome -go 1.26 +go 1.27 // Fork to implement raw tags support replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3 From b3ecaddd9c49534f6612a12715a83bd457fe0a67 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sun, 30 Aug 2026 11:28:32 -0400 Subject: [PATCH 37/78] chore(deps): update fscache and stream dependencies to latest versions Signed-off-by: Deluan --- go.mod | 7 ++----- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index a7b005c25..cdf8fc699 100644 --- a/go.mod +++ b/go.mod @@ -5,9 +5,6 @@ go 1.27 // Fork to implement raw tags support replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3 -// Fork to implement CloseWithError, proposed upstream in https://github.com/djherbis/fscache/pull/22 -replace github.com/djherbis/fscache => github.com/deluan/fscache v0.9.1-0.20260829205053-654a9d517048 - require ( github.com/Masterminds/squirrel v1.5.4 github.com/andybalholm/cascadia v1.3.4 @@ -16,8 +13,8 @@ require ( github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 github.com/dexterlb/mpvipc v0.0.0-20260722094525-0cf47d745b36 github.com/djherbis/atime v1.1.0 - github.com/djherbis/fscache v0.10.2-0.20231127215153-442a07e326c4 - github.com/djherbis/stream v1.5.0 + github.com/djherbis/fscache v0.10.2-0.20260829235704-6d85d5878c22 + github.com/djherbis/stream v1.5.1 github.com/djherbis/times v1.6.0 github.com/dustin/go-humanize v1.0.1 github.com/extism/go-sdk v1.7.1 diff --git a/go.sum b/go.sum index c75b72dd0..d852285f1 100644 --- a/go.sum +++ b/go.sum @@ -29,8 +29,6 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= -github.com/deluan/fscache v0.9.1-0.20260829205053-654a9d517048 h1:u3oDvM7pOIouwDGeIoMu9SYgSzVMtiSkOc+xywtKiJk= -github.com/deluan/fscache v0.9.1-0.20260829205053-654a9d517048/go.mod h1:Bbk9SqpJcg/saiPfG6byM1G4G/LQndknrsLVOQ+VJqY= github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3 h1:j7eSXqgtjhlNfwnMEzRdXnJGZTEw4I7J9TeQAll83bU= github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3/go.mod h1:QGxQ4Z1IWyY9w56xNEFjYAaWE8uSxA/gneQ7RPcFJrY= github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf h1:tb246l2Zmpt/GpF9EcHCKTtwzrd0HGfEmoODFA/qnk4= @@ -41,8 +39,10 @@ github.com/dexterlb/mpvipc v0.0.0-20260722094525-0cf47d745b36 h1:KtPfdSST6e0vJbM github.com/dexterlb/mpvipc v0.0.0-20260722094525-0cf47d745b36/go.mod h1:RkQWLNITKkXHLP7LXxZSgEq+uFWU25M5qW7qfEhL9Wc= github.com/djherbis/atime v1.1.0 h1:rgwVbP/5by8BvvjBNrbh64Qz33idKT3pSnMSJsxhi0g= github.com/djherbis/atime v1.1.0/go.mod h1:28OF6Y8s3NQWwacXc5eZTsEsiMzp7LF8MbXE+XJPdBE= -github.com/djherbis/stream v1.5.0 h1:+ewqpS/ndTmEiJRH142JyOpmmjnkTxdbJtqoAG5sg0Y= -github.com/djherbis/stream v1.5.0/go.mod h1:cqjC1ZRq3FFwkGmUtHwcldbnW8f0Q4YuVsGW1eAFtOk= +github.com/djherbis/fscache v0.10.2-0.20260829235704-6d85d5878c22 h1:GNKxzBirvK9arfVRGVebhFYBp3tnOZG3nlIog6N5/6I= +github.com/djherbis/fscache v0.10.2-0.20260829235704-6d85d5878c22/go.mod h1:Bbk9SqpJcg/saiPfG6byM1G4G/LQndknrsLVOQ+VJqY= +github.com/djherbis/stream v1.5.1 h1:9AoCl0bnm7imWT2uUORqT8aLuTh+YllyynYpolpjIqY= +github.com/djherbis/stream v1.5.1/go.mod h1:cqjC1ZRq3FFwkGmUtHwcldbnW8f0Q4YuVsGW1eAFtOk= github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= From b7ea48057682f564e6e8955de95a5115e99f959b Mon Sep 17 00:00:00 2001 From: Deluan Date: Sun, 30 Aug 2026 12:44:04 -0400 Subject: [PATCH 38/78] refactor: simplify return statements Signed-off-by: Deluan --- core/share.go | 3 +-- tests/mock_artist_repo.go | 3 +-- tests/mock_mediafile_repo.go | 3 +-- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/core/share.go b/core/share.go index 5a611c7f0..b2f32ba39 100644 --- a/core/share.go +++ b/core/share.go @@ -123,8 +123,7 @@ func (r *shareRepositoryWrapper) Save(entity any) (string, error) { s.Contents = str.TruncateRunes(s.Contents, 30, "...") - id, err = r.Persistable.Save(s) - return id, err + return r.Persistable.Save(s) } func (r *shareRepositoryWrapper) Update(id string, entity any, _ ...string) error { diff --git a/tests/mock_artist_repo.go b/tests/mock_artist_repo.go index b71942208..af393129e 100644 --- a/tests/mock_artist_repo.go +++ b/tests/mock_artist_repo.go @@ -205,8 +205,7 @@ func (m *MockArtistRepo) Search(q string, options ...model.QueryOptions) (model. return nil, errors.New("unexpected error") } // Simple mock implementation - just return all artists for testing - allArtists, err := m.GetAll() - return allArtists, err + return m.GetAll() } var _ model.ArtistRepository = (*MockArtistRepo)(nil) diff --git a/tests/mock_mediafile_repo.go b/tests/mock_mediafile_repo.go index 58e84785b..f18280fd5 100644 --- a/tests/mock_mediafile_repo.go +++ b/tests/mock_mediafile_repo.go @@ -319,8 +319,7 @@ func (m *MockMediaFileRepo) Search(q string, options ...model.QueryOptions) (mod return nil, errors.New("unexpected error") } // Simple mock implementation - just return all media files for testing - allFiles, err := m.GetAll() - return allFiles, err + return m.GetAll() } // Cross-library move detection mock methods From aee8a705b1a579e328b88672826ee0d68da80a27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 30 Aug 2026 14:25:36 -0400 Subject: [PATCH 39/78] build(docker): upgrade Alpine base image to 3.22 (#6048) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves both the xx-build toolchain stage and the final runtime image from Alpine 3.20 (past end of active support) to 3.22. 3.22 is the last release where ffmpeg is still 6.1.x — it jumps to 8.0 in 3.23 — so transcoding behavior is unchanged by this bump. Alpine 3.21 repackaged mesa, and from that release on `mpv` requires so:libEGL.so.1 and so:libgbm.so.1. Those pull mesa -> llvm20-libs (156MB) plus the gallium drivers (62MB), which took the image from 231MB/62MB compressed to 578MB/147MB. mesa-egl is the only provider of libEGL.so.1, and newer Alpine releases do not improve on this. Navidrome runs mpv headless for jukebox audio and never enters a video path, so this replaces libEGL/libgbm with generated no-op stubs and drops the mesa/LLVM stack. The stub symbol list is read from real mesa at build time and cross-compiled with the existing xx toolchain, so it adapts per architecture rather than being hardcoded. Verified with logging stubs across mp3/flac/ogg/opus/m4a/wav driving the default MPVCmdTemplate (pause, volume, time-pos seek, quit): zero calls into the stubbed libraries. The final stage now also runs mpv once at build time, so a broken stub fails the build instead of shipping. Image size: 323MB -> 325MB (84MB -> 86MB compressed). --- Dockerfile | 39 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 3d7a03f5d..3427d6806 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM --platform=$BUILDPLATFORM ghcr.io/crazy-max/osxcross:14.5-debian AS osxcros ######################################################################################################################## ### Build xx (original image: tonistiigi/xx) -FROM --platform=$BUILDPLATFORM alpine:3.20 AS xx-build +FROM --platform=$BUILDPLATFORM alpine:3.22 AS xx-build # v1.9.0 ENV XX_VERSION=a5592eab7a57895e8d385394ff12241bc65ecd50 @@ -152,19 +152,52 @@ RUN xx-verify --static /out/navidrome* FROM scratch AS binary COPY --from=build /out / +######################################################################################################################## +### Build no-op stubs for mpv's video-output libraries +# mpv links libEGL/libgbm for video output only; Navidrome drives it headless, for audio. +# Real mesa pulls in LLVM + gallium (+218MB uncompressed), so ship stubs it never calls. +FROM --platform=$BUILDPLATFORM alpine:3.22 AS mpv-stubs +COPY --from=xx / / +RUN apk add --no-cache clang lld binutils mesa-egl mesa-gbm +ARG TARGETPLATFORM +RUN xx-apk add --no-cache musl-dev +RUN < /tmp/stub.c + test -s /tmp/stub.c + xx-clang -shared -nostdlib -fPIC -Wl,-soname,$so -o /out/$so /tmp/stub.c + xx-verify /out/$so + done +EOT + ######################################################################################################################## ### Build Final Image -FROM alpine:3.20 AS final +FROM alpine:3.22 AS final LABEL maintainer="deluan@navidrome.org" LABEL org.opencontainers.image.source="https://github.com/navidrome/navidrome" # Install runtime dependencies # - libwebp + symlinks: enables native WebP encoding via purego/dlopen +# The mesa/LLVM stack mpv pulls in for video output is dropped in this same layer, +# otherwise the deleted bytes still ship in the image. RUN apk add -U --no-cache ffmpeg mpv sqlite libwebp libwebpdemux libwebpmux && \ for lib in libwebp libwebpdemux libwebpmux; do \ target=$(ls /usr/lib/$lib.so.* 2>/dev/null | head -1) && \ [ -n "$target" ] && ln -sf "$target" /usr/lib/$lib.so; \ - done + done && \ + rm -rf /usr/lib/gallium-pipe /usr/lib/dri \ + /usr/lib/libEGL.so* /usr/lib/libgbm.so* /usr/lib/libgallium*.so /usr/lib/libLLVM.so* \ + /usr/lib/libGL.so* /usr/lib/libGLESv2.so* /usr/lib/libglapi.so* + +COPY --from=mpv-stubs /out/ /usr/lib/ +RUN mpv --no-video --ao=null --version > /dev/null # Copy navidrome binary (musl build for Docker, enables native libwebp) COPY --from=build-alpine /out/navidrome /app/ From 46041bb90884b818c8dbcaf851855cb1ee1aeccd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 30 Aug 2026 16:06:08 -0400 Subject: [PATCH 40/78] ci: cache the plugins test suite WASM compilation across runs (#6049) * ci: cache the plugins test suite WASM compilation across runs The 'Test Go code' job was dominated by a single package: 'plugins' took 541s of the 699s test step. The suite builds 25 test plugins as full-Go wasip1 modules of ~4.5MB each, and wazero must compile every one to machine code. Under -race that compiler work is instrumented, so each module costs around 11 seconds. The suite already shared a wazero compilation cache, but three things kept it from paying off. It lived in a fresh temp dir, so nothing survived the run. The default plugins.cachesize of 200MB was smaller than the 334MB the cache actually needs, so the purge evicted entries mid-run. And the wasm binaries embedded VCS stamps, so every commit produced different bytes and missed the content-addressed cache anyway. Point CacheFolder at plugins/testdata/.wazero-cache, raise the test cache limit past what the suite needs, build the test plugins with -buildvcs=false, and restore the directory in CI. Locally the package goes from 256s to 74s with the cache warm and the wasm rebuilt from scratch. * ci: key the WASM cache on what actually changes the modules The test plugins are separate Go modules with their own go.mod and go.sum; they reach the PDK through a replace directive and never read the root module. So the root go.sum has no bearing on the wasm bytes, and the wazero version it pins is already namespaced by wazero itself, which stores entries under wazero---. Keying on it only rotated the cache on every unrelated dependency bump. Drop it, and add the go.mod files that were missing: the test plugins' own and the PDK's. The root go.mod stays, since it selects the toolchain that builds the modules. * ci: key the WASM cache on the toolchain version, not go.mod Only the Go toolchain in the root go.mod affects the built wasm, but the file also changes on every direct dependency bump, which would rotate the cache for no reason. Take setup-go's go-version output instead: it is the version that actually built the modules. --- .github/workflows/pipeline.yml | 10 ++++++++++ .gitignore | 5 ++++- plugins/plugins_suite_test.go | 21 ++++++++------------- plugins/testdata/Makefile | 6 ++++-- 4 files changed, 26 insertions(+), 16 deletions(-) diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index b91c19505..012e3a9c3 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -131,12 +131,22 @@ jobs: uses: actions/checkout@v7 - uses: actions/setup-go@v6 + id: setup-go with: go-version-file: go.mod - name: Download dependencies run: go mod download + # Without this, the plugins suite recompiles every test plugin WASM module, + # which dominates the job runtime under -race. + - name: Cache the plugins test suite WASM compilation cache + uses: actions/cache@v6 + with: + path: plugins/testdata/.wazero-cache + key: wazero-${{ runner.os }}-go${{ steps.setup-go.outputs.go-version }}-${{ hashFiles('plugins/testdata/*/*.go', 'plugins/testdata/*/go.*', 'plugins/pdk/go/**/*.go', 'plugins/pdk/go/go.*') }} + restore-keys: wazero-${{ runner.os }}- + - name: Test run: go test -shuffle=on -tags netgo,sqlite_fts5 -race ./... -v diff --git a/.gitignore b/.gitignore index 6459ded9b..810074e72 100644 --- a/.gitignore +++ b/.gitignore @@ -43,4 +43,7 @@ go.work* .playwright-mcp/ # Temp benchmark files -zz_*_test.go \ No newline at end of file +zz_*_test.go + +# wazero compilation cache for the plugins test suite +/plugins/testdata/.wazero-cache/ diff --git a/plugins/plugins_suite_test.go b/plugins/plugins_suite_test.go index 77956e2f9..c8585f2c1 100644 --- a/plugins/plugins_suite_test.go +++ b/plugins/plugins_suite_test.go @@ -25,7 +25,10 @@ import ( . "github.com/onsi/gomega" ) -const testDataDir = "plugins/testdata" +const ( + testDataDir = "plugins/testdata" + wazeroCacheDir = ".wazero-cache" +) // Shared test state initialized in BeforeSuite var ( @@ -38,18 +41,10 @@ func TestPlugins(t *testing.T) { tests.Init(t, false) buildTestPlugins(t, testDataDir) - // Create a shared wazero compilation cache directory. - // All test managers will point CacheFolder here so that WASM compilation - // is done once per binary and then reused from disk cache. - sharedCacheDir, err := os.MkdirTemp("", "plugins-shared-cache-*") - if err != nil { - t.Fatalf("Failed to create shared cache dir: %v", err) - } - t.Cleanup(func() { os.RemoveAll(sharedCacheDir) }) - - // Set CacheFolder globally so all tests (including those using - // configtest.SetupConfig) inherit it without needing to set it manually. - conf.Server.CacheFolder = conf.NewDir(sharedCacheDir) + // Set globally so tests using configtest.SetupConfig inherit it. The cache + // persists between runs; entries are content-addressed, so a stale one only misses. + conf.Server.CacheFolder = conf.NewDir(filepath.Join(testDataDir, wazeroCacheDir)) + conf.Server.Plugins.CacheSize = "1GB" // the default evicts the cache mid-run log.SetLevel(log.LevelFatal) RegisterFailHandler(Fail) diff --git a/plugins/testdata/Makefile b/plugins/testdata/Makefile index d53f2aaee..758497095 100644 --- a/plugins/testdata/Makefile +++ b/plugins/testdata/Makefile @@ -10,6 +10,7 @@ all: $(PLUGINS:%=%.ndp) clean: rm -f $(PLUGINS:%=%.ndp) $(PLUGINS:%=%.wasm) + rm -rf .wazero-cache # PDK source files that trigger rebuild when changed (recursive) PDK_SOURCES := $(shell find ../pdk/go -name '*.go' 2>/dev/null) @@ -22,10 +23,11 @@ PDK_SOURCES := $(shell find ../pdk/go -name '*.go' 2>/dev/null) @rm -f plugin.wasm @mv $< $<.tmp && mv $<.tmp $< # Touch wasm to ensure it's older than ndp -# Build the wasm binary +# Build the wasm binary. -buildvcs=false keeps the bytes stable across commits, so +# the test suite's wazero compilation cache still hits after a rebuild. %.wasm: %/*.go %/go.mod $(PDK_SOURCES) ifdef TINYGO cd $* && tinygo build -target wasip1 -buildmode=c-shared -o ../$@ . else - cd $* && GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o ../$@ . + cd $* && GOOS=wasip1 GOARCH=wasm go build -buildvcs=false -buildmode=c-shared -o ../$@ . endif \ No newline at end of file From a2de8e61efe82ec1d81d67d985026c083ad541fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 30 Aug 2026 16:57:40 -0400 Subject: [PATCH 41/78] ci: run the plugins test suite in parallel processes (#6051) * test(plugins): make the suite safe to run in parallel processes Two things broke when the suite ran across several Ginkgo processes. buildTestPlugins ran in every process, so N copies of make raced in the same directory. The packaging rule made that worse by staging every plugin through one shared plugin.wasm, so concurrent targets clobbered each other and left orphaned temp files behind. That also ruled out make -j. Stage each package under its own per-target directory, and move the build into SynchronizedBeforeSuite so process 1 does it once while the others wait. * ci: run the plugins suite in parallel processes With the compilation cache warm the suite is bound by spec execution, which splits cleanly across processes. Run it as its own step with the ginkgo CLI, already declared as a tool in go.mod, and drop the package from the main go test invocation so it is not run twice. Locally, with -race: 69s to 19s warm, and 256s to 96s cold. * ci: give the plugins suite its own job so it runs concurrently Running it as a second step in the go job serialised it against the other 90 packages, which cancelled out the parallel win: the job went from 5m41s to only 5m29s even though the suite itself dropped from ~175s to 82s. Move it to its own job so the two run at the same time. The WASM compilation cache moves with it, since the go job no longer runs the suite. --- .github/workflows/pipeline.yml | 38 +++++++++++++++++++++++----------- .gitignore | 1 + plugins/plugins_suite_test.go | 20 ++++++++---------- plugins/testdata/Makefile | 10 +++++---- 4 files changed, 42 insertions(+), 27 deletions(-) diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 012e3a9c3..9da99e864 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -131,24 +131,14 @@ jobs: uses: actions/checkout@v7 - uses: actions/setup-go@v6 - id: setup-go with: go-version-file: go.mod - name: Download dependencies run: go mod download - # Without this, the plugins suite recompiles every test plugin WASM module, - # which dominates the job runtime under -race. - - name: Cache the plugins test suite WASM compilation cache - uses: actions/cache@v6 - with: - path: plugins/testdata/.wazero-cache - key: wazero-${{ runner.os }}-go${{ steps.setup-go.outputs.go-version }}-${{ hashFiles('plugins/testdata/*/*.go', 'plugins/testdata/*/go.*', 'plugins/pdk/go/**/*.go', 'plugins/pdk/go/go.*') }} - restore-keys: wazero-${{ runner.os }}- - - name: Test - run: go test -shuffle=on -tags netgo,sqlite_fts5 -race ./... -v + run: go test -shuffle=on -tags netgo,sqlite_fts5 -race -v $(go list ./... | grep -v '/plugins$') - name: Test ndpgen run: | @@ -157,6 +147,30 @@ jobs: go build -o ndpgen . ./ndpgen --help + go-plugins: + name: Test Go plugins + runs-on: ubuntu-latest + steps: + - name: Check out code into the Go module directory + uses: actions/checkout@v7 + + - uses: actions/setup-go@v6 + id: setup-go + with: + go-version-file: go.mod + + # Without this, the suite recompiles every test plugin WASM module, + # which dominates its runtime under -race. + - name: Cache the WASM compilation cache + uses: actions/cache@v6 + with: + path: plugins/testdata/.wazero-cache + key: wazero-${{ runner.os }}-go${{ steps.setup-go.outputs.go-version }}-${{ hashFiles('plugins/testdata/*/*.go', 'plugins/testdata/*/go.*', 'plugins/pdk/go/**/*.go', 'plugins/pdk/go/go.*') }} + restore-keys: wazero-${{ runner.os }}- + + - name: Test plugins + run: go tool ginkgo -p -race -tags netgo,sqlite_fts5 ./plugins/ + go-windows: name: Test Go code (Windows) runs-on: windows-2022 @@ -294,7 +308,7 @@ jobs: build: name: Build - needs: [js, go, go-windows, go-lint, i18n-lint, git-version, check-push-enabled, validate-migrations] + needs: [js, go, go-plugins, go-windows, go-lint, i18n-lint, git-version, check-push-enabled, validate-migrations] strategy: matrix: platform: [ linux/amd64, linux/arm64, linux/arm/v5, linux/arm/v6, linux/arm/v7, linux/386, linux/riscv64, darwin/amd64, darwin/arm64, windows/amd64, windows/386 ] diff --git a/.gitignore b/.gitignore index 810074e72..4ed971491 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,4 @@ zz_*_test.go # wazero compilation cache for the plugins test suite /plugins/testdata/.wazero-cache/ +/plugins/testdata/*.stage/ diff --git a/plugins/plugins_suite_test.go b/plugins/plugins_suite_test.go index c8585f2c1..2dae65d00 100644 --- a/plugins/plugins_suite_test.go +++ b/plugins/plugins_suite_test.go @@ -7,6 +7,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "fmt" "net/http" "os" "os/exec" @@ -39,7 +40,6 @@ var ( func TestPlugins(t *testing.T) { tests.Init(t, false) - buildTestPlugins(t, testDataDir) // Set globally so tests using configtest.SetupConfig inherit it. The cache // persists between runs; entries are content-addressed, so a stale one only misses. @@ -51,16 +51,11 @@ func TestPlugins(t *testing.T) { RunSpecs(t, "Plugins Suite") } -func buildTestPlugins(t *testing.T, path string) { - t.Helper() +func buildTestPlugins(path string) { start := time.Now() - t.Logf("[BeforeSuite] Current working directory: %s", path) - cmd := exec.Command("make", "-C", path) - out, err := cmd.CombinedOutput() - t.Logf("[BeforeSuite] Make output: %s elapsed: %s", string(out), time.Since(start)) - if err != nil { - t.Fatalf("Failed to build test plugins: %v", err) - } + out, err := exec.Command("make", "-C", path).CombinedOutput() + fmt.Fprintf(GinkgoWriter, "[BeforeSuite] built test plugins in %s:\n%s", time.Since(start), out) + Expect(err).ToNot(HaveOccurred(), "failed to build test plugins") } // createTestManager creates a new plugin Manager with the given plugin config. @@ -146,7 +141,10 @@ func createTestManagerWithPluginsAndMetrics(pluginConfig map[string]map[string]s return manager, tmpDir } -var _ = BeforeSuite(func() { +var _ = SynchronizedBeforeSuite(func() { + // Build once: the testdata Makefile is not safe to run concurrently. + buildTestPlugins(testDataDir) +}, func() { // Get testdata directory (where test plugin .ndp packages live) _, currentFile, _, ok := runtime.Caller(0) Expect(ok).To(BeTrue()) diff --git a/plugins/testdata/Makefile b/plugins/testdata/Makefile index 758497095..de948a2bb 100644 --- a/plugins/testdata/Makefile +++ b/plugins/testdata/Makefile @@ -10,17 +10,19 @@ all: $(PLUGINS:%=%.ndp) clean: rm -f $(PLUGINS:%=%.ndp) $(PLUGINS:%=%.wasm) - rm -rf .wazero-cache + rm -rf .wazero-cache $(PLUGINS:%=%.stage) # PDK source files that trigger rebuild when changed (recursive) PDK_SOURCES := $(shell find ../pdk/go -name '*.go' 2>/dev/null) # Build the .ndp package (zip containing manifest.json + plugin.wasm) +# Stage under a per-target name: a shared plugin.wasm breaks concurrent builds. %.ndp: %.wasm %/manifest.json @rm -f $@ - @cp $< plugin.wasm - zip -j $@ $*/manifest.json plugin.wasm - @rm -f plugin.wasm + @rm -rf $*.stage && mkdir -p $*.stage + @cp $< $*.stage/plugin.wasm + zip -j $@ $*/manifest.json $*.stage/plugin.wasm + @rm -rf $*.stage @mv $< $<.tmp && mv $<.tmp $< # Touch wasm to ensure it's older than ndp # Build the wasm binary. -buildvcs=false keeps the bytes stable across commits, so From 9ff005862071ee16f60f1ca103397b993821201d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 30 Aug 2026 21:24:50 -0400 Subject: [PATCH 42/78] fix: assorted scanner, plugin, and server fixes from the Go 1.27 work (#6050) * fix(plugins): stop the cache janitor when a plugin cache is dropped newCacheService started a ttlcache janitor goroutine that only stopped via the explicit Close() path, so a cache service that was discarded without being closed leaked its janitor for the process lifetime. It now registers the same runtime.AddCleanup safety net that utils/cache.simpleCache already uses. * fix(scanner): stop splitting multi-byte characters when truncating tags sanitize() capped tag values with a byte slice, so a value whose limit falls in the middle of a multi-byte character was stored as invalid UTF-8. defaultMaxTagLength is 1024, which is not a multiple of 3, so any sufficiently long CJK title hit this. Only trailing invalid bytes are trimmed, leaving bad bytes elsewhere in the value untouched. * fix(scanner): store MusicBrainz ids in their canonical form uuid.Parse accepts a UUID wrapped in any two bytes, as well as braced and urn: forms, but sanitize() returned the raw string. A tag like {} or a quoted value was therefore persisted with its wrapper into the mbz_* columns, where the exact-match MBID search can never find it. The parsed value is now stored, which also lowercases uppercase ids and adds the dashes to unhyphenated ones. * fix(plugins): parse IPv6 hosts correctly in the websocket allowlist isHostAllowed cut the host at the last colon, which mangles an IPv6 literal: "[::1]:8080" became "[::1]" and "[::1]" became "[:". A plugin manifest could therefore never allow an IPv6 host. It now uses net.SplitHostPort, falling back to unwrapping the brackets when there is no port. * fix(server): serve pprof profiles when a BaseURL is configured net/http/pprof's Index resolves the profile name by trimming "/debug/pprof/" from the raw request path, which never matches once MountRouter prepends the BasePath. Requests for any profile without an explicit chi route fell through to the index page, returning HTML with a 200 instead of the profile. The handler now strips the BasePath first. * test(scanner): run the goroutine leak check unconditionally The scanner suite's goleak check only ran when the GOLEAK env var was set, so it never ran in CI and could not catch a regression. It passes with the existing ignore list, verified over repeated runs, so the gate is removed. * fix(server): close the background image body on a non-200 response serveImage returned early on an unexpected status code without closing the response body, pinning the connection until the 5s client timeout. The nolint:bodyclose above the request suppressed the linter that would have caught it, and its justification only holds on the success path, where the body is handed to the CachedStream wrapper. * test(scanner): repair BenchmarkScan so it can actually run The benchmark failed three ways before reaching its first iteration: it reused a shared temp DB and tried to repoint the default library, it never loaded the config defaults so the scanner got a concurrency of 0, and it lacked the notify ignore that the suite already carries. tests.Init now takes a testing.TB so a benchmark can load the test config the same way the suites do. * refactor(artwork): drop the unused sourceFunc Stringer sourceFunc.String derived a label from the closure's symbol name via reflection, but nothing called it: the trace output builds its candidate labels from explicit strings. Whole-program analysis confirms it is unreachable, and dropping it removes a reflection-based dependency on compiler closure-naming details. * refactor(plugins): reuse extractHostname in the websocket allowlist The IPv6 host parsing added for isHostAllowed duplicated extractHostname, which already lives in the same package and backs the HTTP client's identical allowlist check. Two copies of a security-relevant parser can drift, so the websocket service now calls the existing helper. The port-stripping specs move into the URL Validation block that already covered them. * perf(scanner): bound the tag truncation trim to a partial rune The trim loop dropped every trailing byte that failed to decode, so a value ending in a long run of invalid bytes was walked one byte at a time: a 1 MiB lyrics tag measured 2.58ms against 45ns for a normal cut. A partial rune is at most 3 trailing bytes, so the loop is capped there, which also stops it consuming a pre-existing invalid run. * test: tighten the tests added with the Go 1.27 bugfixes Drop the testItem stub in favour of the package's own cacheKey, register the pprof test profile once at package scope, and replace the hand-rolled goroutine settle loop with Eventually. Also corrects a comment that credited a TestMain the scanner suite does not have. * test(scanner): ignore notify's nonrecursive-tree goroutines on Linux The goroutine leak check only ignored the recursive tree (macOS/FSEvents). Linux CI uses inotify, whose nonrecursive tree leaks dispatch and internal goroutines after Stop(), failing the check. * fix(scanner): avoid a truncation panic when MaxLength is 1 or 2 A value of only UTF-8 continuation bytes drained the partial-rune loop to empty, then sliced value[:-1] and panicked. Break when DecodeLastRune returns size 0 (empty string) by testing size != 1 instead of size > 1. * fix: address Codex review on the pprof base path and scan benchmark - profilerHandler: treat a root BasePath ("/") as no prefix, so http.StripPrefix keeps the leading slash chi needs; without this the profiler 404s when BaseURL is "/". Cover the root case in the test. - BenchmarkScan: make it run regardless of test/benchmark ordering. Add singleton.DeleteInstance so a fresh DB is opened after TestScanner closes the shared one, guard driver registration with sync.Once so the rebuild does not re-Register, and ignore the Ginkgo interrupt-handler and Linux notify goroutines the preceding suite leaves behind. * fix: address Codex round 2 on BasePath trailing slash and benchmark DB cleanup - profilerHandler: trim all trailing slashes (TrimRight), not just a bare "/", so a BaseURL like "/music/" strips correctly instead of 404ing. Cover it in the test. - BenchmarkScan: keep and defer db.Init's closer so the DB is closed before b.TempDir cleanup, which otherwise cannot delete the open SQLite/WAL files on Windows. --- cmd/root.go | 11 +++- cmd/root_test.go | 46 +++++++++++++ core/artwork/sources.go | 12 ---- db/db.go | 21 ++++-- model/metadata/metadata.go | 14 +++- model/metadata/metadata_internal_test.go | 20 ++++++ model/metadata/metadata_test.go | 38 +++++++++++ plugins/host_cache.go | 10 ++- plugins/host_cache_test.go | 34 ++++++++++ plugins/host_websocket.go | 6 +- plugins/host_websocket_test.go | 26 ++++---- scanner/scanner_benchmark_test.go | 22 ++++++- scanner/scanner_suite_test.go | 20 +++--- server/backgrounds/backgrounds_suite_test.go | 17 +++++ server/backgrounds/handler.go | 3 +- server/backgrounds/handler_test.go | 68 ++++++++++++++++++++ tests/init_tests.go | 2 +- utils/singleton/singleton.go | 10 +++ 18 files changed, 323 insertions(+), 57 deletions(-) create mode 100644 cmd/root_test.go create mode 100644 model/metadata/metadata_internal_test.go create mode 100644 server/backgrounds/backgrounds_suite_test.go create mode 100644 server/backgrounds/handler_test.go diff --git a/cmd/root.go b/cmd/root.go index ff1641bd3..94f861f40 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "net/http" "os" "os/signal" "strings" @@ -138,7 +139,7 @@ func startServer(ctx context.Context) func() error { a.MountRouter("Prometheus metrics", conf.Server.Prometheus.MetricsPath, p.GetHandler()) } if conf.Server.DevEnableProfiler { - a.MountRouter("Profiling", "/debug", middleware.Profiler()) + a.MountRouter("Profiling", "/debug", profilerHandler()) } if strings.HasPrefix(conf.Server.UILoginBackgroundURL, "/") { a.MountRouter("Background images", conf.Server.UILoginBackgroundURL, backgrounds.NewHandler()) @@ -147,6 +148,14 @@ func startServer(ctx context.Context) func() error { } } +// profilerHandler returns the pprof handler. net/http/pprof resolves the profile +// name from the raw request path, so the BasePath has to come off first. +func profilerHandler() http.Handler { + // A trailing or root slash would make StripPrefix drop the leading slash chi needs. + basePath := strings.TrimRight(conf.Server.BasePath, "/") + return http.StripPrefix(basePath, middleware.Profiler()) +} + // schedulePeriodicScan schedules a periodic scan of the music library, if configured. func schedulePeriodicScan(ctx context.Context) func() error { return func() error { diff --git a/cmd/root_test.go b/cmd/root_test.go new file mode 100644 index 000000000..af8d44e7e --- /dev/null +++ b/cmd/root_test.go @@ -0,0 +1,46 @@ +package cmd + +import ( + "net/http" + "net/http/httptest" + "path" + "runtime/pprof" + + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = pprof.NewProfile("nd-profiler-test") + +var _ = Describe("profilerHandler", func() { + // Mirrors how server.MountRouter mounts the handler. + mount := func() http.Handler { + router := chi.NewRouter() + router.Mount(path.Join(conf.Server.BasePath, "/debug"), profilerHandler()) + return router + } + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + + DescribeTable("serves a named profile", + func(basePath string) { + conf.Server.BasePath = basePath + + w := httptest.NewRecorder() + target := path.Join(basePath, "/debug/pprof/nd-profiler-test") + "?debug=1" + mount().ServeHTTP(w, httptest.NewRequest(http.MethodGet, target, nil)) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.String()).To(HavePrefix("nd-profiler-test profile: total 0")) + }, + Entry("without a BasePath", ""), + Entry("with a BasePath", "/music"), + Entry("with a root BasePath", "/"), + Entry("with a trailing-slash BasePath", "/music/"), + ) +}) diff --git a/core/artwork/sources.go b/core/artwork/sources.go index 069b7bf5a..f2abf9da5 100644 --- a/core/artwork/sources.go +++ b/core/artwork/sources.go @@ -10,9 +10,7 @@ import ( "net/http" "net/url" "path/filepath" - "reflect" "regexp" - "runtime" "strings" "time" @@ -29,16 +27,6 @@ var errSourceUnreadable = errors.New("artwork source unreadable") type sourceFunc func() (r io.ReadCloser, path string, err error) -func (f sourceFunc) String() string { - name := runtime.FuncForPC(reflect.ValueOf(f).Pointer()).Name() - name = strings.TrimPrefix(name, "github.com/navidrome/navidrome/core/artwork.") - if _, after, found := strings.Cut(name, ")."); found { - name = after - } - name = strings.TrimSuffix(name, ".func1") - return name -} - func fromExternalFile(ctx context.Context, libFS fs.FS, files []string, pattern string) sourceFunc { return func() (io.ReadCloser, string, error) { var openErr error diff --git a/db/db.go b/db/db.go index a325dd3f5..c53aa364a 100644 --- a/db/db.go +++ b/db/db.go @@ -6,6 +6,7 @@ import ( "embed" "errors" "fmt" + "sync" "time" "github.com/mattn/go-sqlite3" @@ -33,15 +34,21 @@ var embedMigrations embed.FS const migrationsFolder = "migrations" +// sql.Register panics if called twice, so guard it: the singleton instance can be reset +// (tests/benchmarks) and rebuilt, but the driver is process-global and registers only once. +var registerDriverOnce sync.Once + func Db() *sql.DB { return singleton.GetInstance(func() *sql.DB { - sql.Register(Driver, &sqlite3.SQLiteDriver{ - ConnectHook: func(conn *sqlite3.SQLiteConn) error { - if err := conn.RegisterFunc("SEEDEDRAND", hasher.HashFunc(), false); err != nil { - return err - } - return conn.RegisterCollation(NaturalCollation, natural.CompareFold) - }, + registerDriverOnce.Do(func() { + sql.Register(Driver, &sqlite3.SQLiteDriver{ + ConnectHook: func(conn *sqlite3.SQLiteConn) error { + if err := conn.RegisterFunc("SEEDEDRAND", hasher.HashFunc(), false); err != nil { + return err + } + return conn.RegisterCollation(NaturalCollation, natural.CompareFold) + }, + }) }) Path = conf.Server.DbPath if Path == ":memory:" { diff --git a/model/metadata/metadata.go b/model/metadata/metadata.go index 729e83564..0efbe94ec 100644 --- a/model/metadata/metadata.go +++ b/model/metadata/metadata.go @@ -9,6 +9,7 @@ import ( "strconv" "strings" "time" + "unicode/utf8" "github.com/google/uuid" "github.com/navidrome/navidrome/consts" @@ -366,6 +367,14 @@ func sanitize(filePath string, tagName model.TagName, tag model.TagConf, value s if len(value) > maxLength { log.Trace("Truncated tag value", "tag", tagName, "value", value, "length", len(value), "maxLength", maxLength) value = value[:maxLength] + // Drop the partial rune the cut may have left: at most 3 trailing bytes, + // so a pre-existing invalid run elsewhere is never consumed. + for range 3 { + if r, size := utf8.DecodeLastRuneInString(value); r != utf8.RuneError || size != 1 { + break + } + value = value[:len(value)-1] + } } switch tag.Type { @@ -387,11 +396,14 @@ func sanitize(filePath string, tagName model.TagName, tag model.TagConf, value s return "" } case model.TagTypeUUID: - _, err := uuid.Parse(value) + u, err := uuid.Parse(value) if err != nil { log.Trace("Invalid UUID tag value", "tag", tagName, "value", value) return "" } + // Store the canonical form: uuid.Parse accepts braces, urn: prefixes and any + // two-byte wrapper, and a wrapped value would never match an exact-match query + value = u.String() } return value } diff --git a/model/metadata/metadata_internal_test.go b/model/metadata/metadata_internal_test.go new file mode 100644 index 000000000..99fbba328 --- /dev/null +++ b/model/metadata/metadata_internal_test.go @@ -0,0 +1,20 @@ +package metadata + +import ( + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = DescribeTable("sanitize truncation with a tiny MaxLength", + // A value of only continuation bytes drains the partial-rune loop to empty; the loop + // must stop there instead of slicing value[:-1] and panicking. + func(maxLength int, value string) { + Expect(func() { + Expect(sanitize("file.mp3", "title", model.TagConf{MaxLength: maxLength}, value)).To(Equal("")) + }).NotTo(Panic()) + }, + Entry("maxLength 1", 1, "\x80\x80"), + Entry("maxLength 2", 2, "\x80\x80\x80"), + Entry("maxLength 3", 3, "\x80\x80\x80\x80"), +) diff --git a/model/metadata/metadata_test.go b/model/metadata/metadata_test.go index 7ebe9fa4a..09a2dfde0 100644 --- a/model/metadata/metadata_test.go +++ b/model/metadata/metadata_test.go @@ -4,6 +4,7 @@ import ( "os" "strings" "time" + "unicode/utf8" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/metadata" @@ -122,6 +123,43 @@ var _ = Describe("Metadata", func() { Expect(pair[0].Value()).To(HaveLen(1048570)) }) + It("should not split a multi-byte character when truncating", func() { + // 1024 is not a multiple of 3, so a byte-wise cut lands mid-rune. + props.Tags = model.RawTags{ + "Title": {strings.Repeat("日", 2048)}, + } + md = metadata.New(filePath, props) + + title := md.String(model.TagTitle) + Expect(utf8.ValidString(title)).To(BeTrue(), "truncation produced invalid UTF-8") + Expect(len(title)).To(BeNumerically("<=", 1024)) + }) + + It("should keep invalid bytes that are not at the truncation point", func() { + props.Tags = model.RawTags{ + "Title": {"a\xffb" + strings.Repeat("c", 2048)}, + } + md = metadata.New(filePath, props) + + Expect(md.String(model.TagTitle)).To(HaveLen(1024)) + }) + + DescribeTable("should normalize UUID tags to their canonical form", + func(raw, expected string) { + props.Tags = model.RawTags{"musicbrainz_artistid": {raw}} + md = metadata.New(filePath, props) + + Expect(md.String(model.TagMusicBrainzArtistID)).To(Equal(expected)) + }, + Entry("canonical", "f81d4fae-7dec-11d0-a765-00a0c91e6bf6", "f81d4fae-7dec-11d0-a765-00a0c91e6bf6"), + Entry("uppercase", "F81D4FAE-7DEC-11D0-A765-00A0C91E6BF6", "f81d4fae-7dec-11d0-a765-00a0c91e6bf6"), + Entry("braced", "{f81d4fae-7dec-11d0-a765-00a0c91e6bf6}", "f81d4fae-7dec-11d0-a765-00a0c91e6bf6"), + Entry("urn prefix", "urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6", "f81d4fae-7dec-11d0-a765-00a0c91e6bf6"), + Entry("quoted", `"f81d4fae-7dec-11d0-a765-00a0c91e6bf6"`, "f81d4fae-7dec-11d0-a765-00a0c91e6bf6"), + Entry("no dashes", "f81d4fae7dec11d0a76500a0c91e6bf6", "f81d4fae-7dec-11d0-a765-00a0c91e6bf6"), + Entry("not a uuid", "the beatles", ""), + ) + It("should split multiple values", func() { props.Tags = model.RawTags{ "Genre": {"Rock/Pop;;Punk"}, diff --git a/plugins/host_cache.go b/plugins/host_cache.go index b90d790cf..f410dfd3d 100644 --- a/plugins/host_cache.go +++ b/plugins/host_cache.go @@ -2,6 +2,7 @@ package plugins import ( "context" + "runtime" "time" "github.com/jellydator/ttlcache/v3" @@ -29,11 +30,18 @@ func newCacheService(pluginName string) *cacheServiceImpl { // Start the janitor goroutine to clean up expired entries go cache.Start() - return &cacheServiceImpl{ + svc := &cacheServiceImpl{ pluginName: pluginName, cache: cache, defaultTTL: defaultCacheTTL, } + + // Automatic cleanup to prevent goroutine leak when the service is garbage collected + runtime.AddCleanup(svc, func(ttlCache *ttlcache.Cache[string, any]) { + ttlCache.Stop() + }, cache) + + return svc } // getTTL converts seconds to a duration, using default if 0 or negative diff --git a/plugins/host_cache_test.go b/plugins/host_cache_test.go index cf3973fc4..5e91b04ec 100644 --- a/plugins/host_cache_test.go +++ b/plugins/host_cache_test.go @@ -8,9 +8,11 @@ import ( "encoding/hex" "encoding/json" "errors" + "fmt" "net/http" "os" "path/filepath" + "runtime" "time" "github.com/navidrome/navidrome/conf" @@ -599,3 +601,35 @@ var _ = Describe("CacheService Integration", Ordered, func() { }) }) }) + +var _ = Describe("newCacheService", func() { + // The suite above leaves goroutines winding down, so settle before sampling. + settledBaseline := func() int { + var n int + Eventually(func() int { + runtime.GC() + prev := n + n = runtime.NumGoroutine() + return n - prev + }).WithTimeout(10 * time.Second).WithPolling(20 * time.Millisecond).Should(BeZero()) + return n + } + + It("stops the janitor goroutine once the service is unreachable", func() { + const numServices = 5 + baseline := settledBaseline() + + func() { + services := make([]*cacheServiceImpl, 0, numServices) + for i := range numServices { + services = append(services, newCacheService(fmt.Sprintf("plugin_%d", i))) + } + Expect(runtime.NumGoroutine()).To(BeNumerically(">=", baseline+numServices), + "expected one janitor goroutine per cache service") + }() + + Eventually(func() int { runtime.GC(); return runtime.NumGoroutine() }). + WithTimeout(10*time.Second).WithPolling(20*time.Millisecond). + Should(BeNumerically("<=", baseline), "janitor goroutines leaked") + }) +}) diff --git a/plugins/host_websocket.go b/plugins/host_websocket.go index 90403f4c0..82aded0cb 100644 --- a/plugins/host_websocket.go +++ b/plugins/host_websocket.go @@ -243,11 +243,7 @@ func (s *webSocketServiceImpl) getConnection(connectionID string) (*wsConnection } func (s *webSocketServiceImpl) isHostAllowed(host string) bool { - // Strip port from host if present - hostWithoutPort := host - if idx := strings.LastIndex(host, ":"); idx != -1 { - hostWithoutPort = host[:idx] - } + hostWithoutPort := extractHostname(host) for _, pattern := range s.requiredHosts { if matchHostPattern(pattern, hostWithoutPort) { diff --git a/plugins/host_websocket_test.go b/plugins/host_websocket_test.go index e41cfbb82..2aa85cd21 100644 --- a/plugins/host_websocket_test.go +++ b/plugins/host_websocket_test.go @@ -144,20 +144,18 @@ var _ = Describe("WebSocketService", Ordered, func() { Expect(allowed).To(BeFalse()) }) - It("should strip port before checking host", func() { - // Implementation strips port before matching against patterns - // test-websocket manifest has "localhost:*" which matches "localhost" - // after port stripping - // Note: The port wildcard pattern isn't actually implemented, but - // since port is stripped, "localhost:*" is compared against "localhost" - // which won't match. To make localhost work, we'd need exact "localhost" - // in the allowed hosts list. - - // Testing that port is properly stripped - // The pattern "localhost:*" won't match "localhost" due to exact match - allowed := testService.isHostAllowed("localhost:8080") - Expect(allowed).To(BeFalse()) - }) + DescribeTable("should match against the host with its port stripped", + func(allowed []string, host string, expected bool) { + svc := &webSocketServiceImpl{requiredHosts: allowed} + Expect(svc.isHostAllowed(host)).To(Equal(expected)) + }, + Entry("hostname with port", []string{"example.com"}, "example.com:8080", true), + Entry("IPv6 with port", []string{"::1"}, "[::1]:8080", true), + Entry("IPv6 without port", []string{"::1"}, "[::1]", true), + Entry("host not in the list", []string{"::2"}, "[::1]:8080", false), + // "localhost:*" is matched against the stripped "localhost", so it never hits + Entry("port wildcards are not supported", []string{"localhost:*"}, "localhost:8080", false), + ) }) Describe("Connection Management", func() { diff --git a/scanner/scanner_benchmark_test.go b/scanner/scanner_benchmark_test.go index ca1064631..65410d500 100644 --- a/scanner/scanner_benchmark_test.go +++ b/scanner/scanner_benchmark_test.go @@ -2,8 +2,8 @@ package scanner_test import ( "context" + "database/sql" "fmt" - "os" "path/filepath" "runtime" "testing" @@ -21,6 +21,8 @@ import ( "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/utils/singleton" "go.uber.org/goleak" ) @@ -31,11 +33,25 @@ func BenchmarkScan(b *testing.B) { goleak.IgnoreAnyFunction("testing.(*B).doBench"), // Ignore database/sql.(*DB).connectionOpener, as we are not closing the database connection goleak.IgnoreAnyFunction("database/sql.(*DB).connectionOpener"), + // A preceding TestScanner leaves Ginkgo's interrupt handler running. + goleak.IgnoreTopFunction("github.com/onsi/ginkgo/v2/internal/interrupt_handler.(*InterruptHandler).registerForInterrupts.func2"), + // The notify library keeps watcher goroutines alive after Stop(); recursive on macOS, 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"), ) - tmpDir := os.TempDir() + tests.Init(b, false) + + tmpDir := b.TempDir() conf.Server.DbPath = filepath.Join(tmpDir, "test-scanner.db?_journal_mode=WAL") - db.Init(context.Background()) + // The default library is seeded from MusicFolder, and its path cannot be changed afterwards + conf.Server.MusicFolder = "fake:///music" + // TestScanner may run first and close the shared DB singleton; drop it so db.Init + // opens a fresh one whether or not the test suite ran before this benchmark. + singleton.DeleteInstance[*sql.DB]() + // Close before b.TempDir cleanup runs, or Windows cannot delete the open DB/WAL files. + defer db.Init(context.Background())() ds := persistence.New(db.Db()) conf.Server.DevExternalScanner = false diff --git a/scanner/scanner_suite_test.go b/scanner/scanner_suite_test.go index 10be0401f..07ffd6e3a 100644 --- a/scanner/scanner_suite_test.go +++ b/scanner/scanner_suite_test.go @@ -3,7 +3,6 @@ package scanner_test import ( "context" "io/fs" - "os" "testing" "github.com/navidrome/navidrome/consts" @@ -31,16 +30,15 @@ func init() { } func TestScanner(t *testing.T) { - // Only run goleak checks when the GOLEAK env var is set - if os.Getenv("GOLEAK") != "" { - // Detect any goroutine leaks in the scanner code under test - defer goleak.VerifyNone(t, - goleak.IgnoreTopFunction("github.com/onsi/ginkgo/v2/internal/interrupt_handler.(*InterruptHandler).registerForInterrupts.func2"), - // The notify library creates internal goroutines for file watching that persist after Stop() is called. - // These are created by the plugins package tests and are expected behavior. - goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*recursiveTree).dispatch"), - ) - } + // Detect any goroutine leaks in the scanner code under test + defer goleak.VerifyNone(t, + goleak.IgnoreTopFunction("github.com/onsi/ginkgo/v2/internal/interrupt_handler.(*InterruptHandler).registerForInterrupts.func2"), + // The notify library keeps internal goroutines alive after Stop(). The backend picks the tree per + // platform: recursive on macOS (FSEvents), nonrecursive on Linux (inotify), so ignore both. + 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, true) defer db.Close(context.Background()) diff --git a/server/backgrounds/backgrounds_suite_test.go b/server/backgrounds/backgrounds_suite_test.go new file mode 100644 index 000000000..5ad81907b --- /dev/null +++ b/server/backgrounds/backgrounds_suite_test.go @@ -0,0 +1,17 @@ +package backgrounds + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestBackgrounds(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Backgrounds Suite") +} diff --git a/server/backgrounds/handler.go b/server/backgrounds/handler.go index f6e159b4b..dcaaa9c66 100644 --- a/server/backgrounds/handler.go +++ b/server/backgrounds/handler.go @@ -81,7 +81,7 @@ func (h *Handler) serveImage(ctx context.Context, item cache.Item) (io.Reader, e } c := httpclient.New(imageRequestTimeout) req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageURL(image), nil) - resp, err := c.Do(req) //nolint:bodyclose,gosec // No need to close resp.Body, it will be closed via the CachedStream wrapper + resp, err := c.Do(req) //nolint:bodyclose,gosec // On success the body is closed via the CachedStream wrapper if errors.Is(err, context.DeadlineExceeded) { defaultImage, _ := base64.StdEncoding.DecodeString(consts.DefaultUILoginBackgroundOffline) return strings.NewReader(string(defaultImage)), nil @@ -90,6 +90,7 @@ func (h *Handler) serveImage(ctx context.Context, item cache.Item) (io.Reader, e return nil, fmt.Errorf("could not get background image from hosting service: %w", err) } if resp.StatusCode != http.StatusOK { + _ = resp.Body.Close() return nil, fmt.Errorf("unexpected status code getting background image from hosting service: %d", resp.StatusCode) } log.Debug(ctx, "Got background image from hosting service", "image", image, "elapsed", time.Since(start)) diff --git a/server/backgrounds/handler_test.go b/server/backgrounds/handler_test.go new file mode 100644 index 000000000..e8c77380f --- /dev/null +++ b/server/backgrounds/handler_test.go @@ -0,0 +1,68 @@ +package backgrounds + +import ( + "context" + "io" + "net/http" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type recordingBody struct { + io.Reader + closed *bool +} + +func (b recordingBody) Close() error { + *b.closed = true + return nil +} + +type stubTransport struct { + statusCode int + closed *bool +} + +func (t stubTransport) RoundTrip(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: t.statusCode, + Header: make(http.Header), + Body: recordingBody{Reader: strings.NewReader("image-bytes"), closed: t.closed}, + }, nil +} + +var _ = Describe("serveImage", func() { + var closed bool + + BeforeEach(func() { + closed = false + }) + + stubStatus := func(statusCode int) { + original := http.DefaultTransport + http.DefaultTransport = stubTransport{statusCode: statusCode, closed: &closed} + DeferCleanup(func() { http.DefaultTransport = original }) + } + + It("closes the response body when the hosting service returns an error", func() { + stubStatus(http.StatusNotFound) + + _, err := (&Handler{}).serveImage(context.Background(), cacheKey("some-image.webp")) + + Expect(err).To(MatchError(ContainSubstring("unexpected status code"))) + Expect(closed).To(BeTrue(), "response body was left open") + }) + + It("hands the still-open body to the caller on success", func() { + stubStatus(http.StatusOK) + + reader, err := (&Handler{}).serveImage(context.Background(), cacheKey("some-image.webp")) + + Expect(err).ToNot(HaveOccurred()) + Expect(closed).To(BeFalse(), "response body must stay open for the CachedStream wrapper") + body, _ := io.ReadAll(reader) + Expect(string(body)).To(Equal("image-bytes")) + }) +}) diff --git a/tests/init_tests.go b/tests/init_tests.go index 582ad95fc..902cf196d 100644 --- a/tests/init_tests.go +++ b/tests/init_tests.go @@ -13,7 +13,7 @@ import ( var once sync.Once -func Init(t *testing.T, skipOnShort bool) { +func Init(t testing.TB, skipOnShort bool) { if skipOnShort && testing.Short() { t.Skip("skipping test in short mode.") } diff --git a/utils/singleton/singleton.go b/utils/singleton/singleton.go index 83f8c53ab..8271034ad 100644 --- a/utils/singleton/singleton.go +++ b/utils/singleton/singleton.go @@ -67,3 +67,13 @@ func GetInstance[T any](constructor func() T) T { return newInstance } + +// DeleteInstance drops the cached instance of type T so the next GetInstance rebuilds it. +// Intended for tests and benchmarks that need a fresh instance regardless of run order. +func DeleteInstance[T any]() { + var v T + name := reflect.TypeOf(v).String() + lock.Lock() + delete(instances, name) + lock.Unlock() +} From dbd26ba2e71d0a5b79dba873a2beeff59f1cd8dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 30 Aug 2026 22:17:25 -0400 Subject: [PATCH 43/78] perf(scanner): improve playlist importing on large libraries (#6055) * perf(persistence): avoid a full media_file scan when resolving playlist paths FindByPaths built one OR-ed equality term per path. On the real media_file schema SQLite abandons the path index at just two OR-ed terms and falls back to SCAN media_file, re-testing every term against every row, so the cost grows with (rows x terms). Group the candidates by library and emit one IN list per library instead, which plans as SEARCH media_file USING INDEX media_file_path_nocase. The NOCASE collation is kept so ASCII case-insensitive matching still works. This is the dominant cost of M3U playlist import, which resolves every track on every scan. Measured with a 1000-track playlist against a migrated DB: 100k media_file rows: 397 -> 51,414 tracks/sec 500k media_file rows: 78.5 -> 47,174 tracks/sec The rate no longer degrades as the table grows, which is the expected shape for an index lookup. Reported in #6043, where an 8 hour scan of a 2M-song library spent 7h52m in the playlist phase. * docs(playlists): correct the stale reason for the M3U lookup chunk size The expression-tree depth ceiling applied to the old OR-per-path query, which capped a batch at roughly 500 terms. The IN form is bound by SQLite's 32766 variable limit instead, which the 400 candidates per chunk sit far below. --- core/playlists/parse_m3u.go | 4 ++-- persistence/mediafile_repository.go | 25 +++++++++++++++++------- persistence/mediafile_repository_test.go | 22 +++++++++++++++++++++ 3 files changed, 42 insertions(+), 9 deletions(-) diff --git a/core/playlists/parse_m3u.go b/core/playlists/parse_m3u.go index a64c337c9..286f2e420 100644 --- a/core/playlists/parse_m3u.go +++ b/core/playlists/parse_m3u.go @@ -25,8 +25,8 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m return err } var mfs model.MediaFiles - // Chunk size of 100 lines, as each line can generate up to 4 lookup candidates - // (NFC/NFD × raw/lowercase), and SQLite has a max expression tree depth of 1000. + // Chunked so a huge playlist is not held in memory at once. Each line yields up to + // 4 lookup candidates (NFC/NFD × raw/lowercase), far below SQLite's 32766 variables. for lines := range slice.CollectChunks(slice.LinesFrom(reader), 100) { filteredLines := make([]string, 0, len(lines)) for _, line := range lines { diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index 320b95ef2..ed18333de 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "iter" + "maps" "slices" "strconv" "strings" @@ -355,7 +356,10 @@ func (r *mediaFileRepository) GetCursorWithArtwork(options ...model.QueryOptions // Library-qualified paths search within the specified library, while unqualified paths // search across all libraries for backward compatibility. func (r *mediaFileRepository) FindByPaths(paths []string) (model.MediaFiles, error) { - query := Or{} + // One IN list per library instead of one OR term per path: SQLite abandons the + // path index at just two OR-ed equality terms and scans the whole table. + byLibrary := map[int][]string{} + var unqualified []string for _, path := range paths { parts := strings.SplitN(path, ":", 2) @@ -366,17 +370,24 @@ func (r *mediaFileRepository) FindByPaths(paths []string) (model.MediaFiles, err // Invalid format, skip continue } - relativePath := parts[1] - query = append(query, And{ - Eq{"path collate nocase": relativePath}, - Eq{"library_id": libraryID}, - }) + byLibrary[libraryID] = append(byLibrary[libraryID], parts[1]) } else { // Unqualified path: search across all libraries - query = append(query, Eq{"path collate nocase": path}) + unqualified = append(unqualified, path) } } + query := Or{} + for _, libraryID := range slices.Sorted(maps.Keys(byLibrary)) { + query = append(query, And{ + Eq{"path collate nocase": byLibrary[libraryID]}, + Eq{"library_id": libraryID}, + }) + } + if len(unqualified) > 0 { + query = append(query, Eq{"path collate nocase": unqualified}) + } + if len(query) == 0 { return model.MediaFiles{}, nil } diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go index c1a91c5a5..8a492a813 100644 --- a/persistence/mediafile_repository_test.go +++ b/persistence/mediafile_repository_test.go @@ -1055,6 +1055,28 @@ var _ = Describe("MediaRepository", func() { Expect(results).To(HaveLen(1)) Expect(results[0].ID).To(Equal("otherlib-track")) }) + + It("resolves paths from multiple libraries in a single call", func() { + adminMr := NewMediaFileRepository(request.WithUser(GinkgoT().Context(), adminUser), GetDBXBuilder()) + results, err := adminMr.FindByPaths([]string{ + "1:artist/Album/track.mp3", + fmt.Sprintf("%d:hidden/test.mp3", otherLib.ID), + }) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(2)) + Expect([]string{results[0].ID, results[1].ID}).To(ConsistOf("findpath-1", "otherlib-track")) + }) + + It("keeps each path scoped to its own library when several are queried", func() { + adminMr := NewMediaFileRepository(request.WithUser(GinkgoT().Context(), adminUser), GetDBXBuilder()) + // Each path exists, but under the other library's ID, so neither must match. + results, err := adminMr.FindByPaths([]string{ + fmt.Sprintf("%d:artist/Album/track.mp3", otherLib.ID), + "1:hidden/test.mp3", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(BeEmpty()) + }) }) }) From 96b051ffa774eab2e0d877f5fbabe3215bc4d4a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Mon, 31 Aug 2026 11:21:32 -0400 Subject: [PATCH 44/78] fix(nativeapi): stop partial PUTs from clearing untouched columns (#6058) * fix(nativeapi): stop partial PUTs from clearing untouched columns The REST layer parses the request body's top-level JSON keys and passes them to Repository.Update as colsToUpdate. The radio and library repositories discarded that list and issued a full-row UPDATE, so any field absent from the body was written as its zero value. For radio this wiped uploaded_image, deleting the station's cover on every partial update (the Web UI is unaffected because its form submits the whole record). For library it silently cleared remote_path and default_new_users. Thread the column list through to Put in both repositories, and extract the column-selection half of filterUpdateValues into selectUpdateColumns so library, which hand-builds its update map, shares the same rule instead of copying it. Fixes #6057 * refactor(persistence): drop pluginRepository's dead rest.Persistable methods Save and Update had no callers: PUT /api/plugin/{id} is served by the hand-written updatePlugin handler over a typed request struct, and the route only wires rest.GetAll and rest.Get. Both methods delegated to Put, which upserts all twelve columns, so wiring rest.Put to this repository would have reintroduced the partial-update clobbering fixed in the previous commit. Removing them, along with the rest.Persistable assertion, makes that a compile error instead of a silent data loss. Put itself is unchanged and still backs plugin discovery. --- core/library.go | 4 ++-- core/library_test.go | 9 +++++++++ model/library.go | 2 +- persistence/library_repository.go | 10 +++++----- persistence/library_repository_test.go | 20 ++++++++++++++++++++ persistence/plugin_repository.go | 26 -------------------------- persistence/radio_repository.go | 2 +- persistence/radio_repository_test.go | 18 ++++++++++++++++++ persistence/sql_base_repository.go | 15 +++++++++------ tests/mock_library_repo.go | 10 ++++++---- 10 files changed, 71 insertions(+), 45 deletions(-) diff --git a/core/library.go b/core/library.go index 365dcbd4c..d905e00cb 100644 --- a/core/library.go +++ b/core/library.go @@ -191,7 +191,7 @@ func (r *libraryRepositoryWrapper) Save(entity any) (string, error) { return strconv.Itoa(lib.ID), nil } -func (r *libraryRepositoryWrapper) Update(id string, entity any, _ ...string) error { +func (r *libraryRepositoryWrapper) Update(id string, entity any, cols ...string) error { lib := entity.(*model.Library) libID, err := strconv.Atoi(id) if err != nil { @@ -211,7 +211,7 @@ func (r *libraryRepositoryWrapper) Update(id string, entity any, _ ...string) er pathChanged := originalLib.Path != lib.Path - err = r.LibraryRepository.Put(lib) + err = r.LibraryRepository.Put(lib, cols...) if err != nil { return r.mapError(err) } diff --git a/core/library_test.go b/core/library_test.go index 175d9c37d..43097414d 100644 --- a/core/library_test.go +++ b/core/library_test.go @@ -188,6 +188,15 @@ var _ = Describe("Library Service", func() { Expect(libraryRepo.Data[1].Path).To(Equal(newTempDir)) }) + It("forwards the columns sent by the client to the repository", func() { + library := &model.Library{ID: 1, Name: "Updated Library", Path: tempDir} + + err := repo.Update("1", library, "name", "path") + + Expect(err).NotTo(HaveOccurred()) + Expect(libraryRepo.PutCols).To(Equal([]string{"name", "path"})) + }) + It("fails when library doesn't exist", func() { // Create a unique temporary directory to avoid path conflicts uniqueTempDir, err := os.MkdirTemp("", "navidrome-nonexistent-") diff --git a/model/library.go b/model/library.go index bcb2864c8..aceab533a 100644 --- a/model/library.go +++ b/model/library.go @@ -45,7 +45,7 @@ type LibraryRepository interface { GetPath(id int) (string, error) GetAll(...QueryOptions) (Libraries, error) CountAll(...QueryOptions) (int64, error) - Put(*Library) error + Put(l *Library, colsToUpdate ...string) error Delete(id int) error StoreMusicFolder() error AddArtist(id int, artistID string) error diff --git a/persistence/library_repository.go b/persistence/library_repository.go index 5a0142423..df5c9a066 100644 --- a/persistence/library_repository.go +++ b/persistence/library_repository.go @@ -70,7 +70,7 @@ func (r *libraryRepository) GetPath(id int) (string, error) { } } -func (r *libraryRepository) Put(l *model.Library) error { +func (r *libraryRepository) Put(l *model.Library, colsToUpdate ...string) error { if l.ID == model.DefaultLibraryID { currentLib, err := r.Get(1) // if we are creating it, it's ok. @@ -89,13 +89,13 @@ func (r *libraryRepository) Put(l *model.Library) error { err = r.db.Model(l).Insert() } else { // Try to update first - cols := map[string]any{ + cols := selectUpdateColumns(map[string]any{ "name": l.Name, "path": l.Path, "remote_path": l.RemotePath, "default_new_users": l.DefaultNewUsers, - "updated_at": l.UpdatedAt, - } + }, colsToUpdate...) + cols["updated_at"] = l.UpdatedAt sq := Update(r.tableName).SetMap(cols).Where(Eq{"id": l.ID}) rowsAffected, updateErr := r.executeSQL(sq) if updateErr != nil { @@ -340,7 +340,7 @@ func (r *libraryRepository) Update(id string, entity any, cols ...string) error } lib.ID = idInt - return r.Put(lib) + return r.Put(lib, cols...) } var _ model.LibraryRepository = (*libraryRepository)(nil) diff --git a/persistence/library_repository_test.go b/persistence/library_repository_test.go index 1743df209..949dd93c5 100644 --- a/persistence/library_repository_test.go +++ b/persistence/library_repository_test.go @@ -52,6 +52,26 @@ var _ = Describe("LibraryRepository", func() { }) }) + Context("when colsToUpdate is specified", func() { + It("only writes the requested columns", func() { + lib := &model.Library{ + Name: "Original Library", + Path: "/music/original", + RemotePath: "/remote/original", + DefaultNewUsers: true, + } + Expect(repo.Put(lib)).To(Succeed()) + + Expect(repo.Put(&model.Library{ID: lib.ID, Name: "Renamed", Path: lib.Path}, "name", "path")).To(Succeed()) + + saved, err := repo.Get(lib.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(saved.Name).To(Equal("Renamed")) + Expect(saved.RemotePath).To(Equal("/remote/original")) + Expect(saved.DefaultNewUsers).To(BeTrue()) + }) + }) + Context("when ID is non-zero and record exists", func() { It("updates the existing record", func() { // First create a library diff --git a/persistence/plugin_repository.go b/persistence/plugin_repository.go index 35c32de91..c1e36f0b1 100644 --- a/persistence/plugin_repository.go +++ b/persistence/plugin_repository.go @@ -141,31 +141,5 @@ func (r *pluginRepository) ReadAll(options ...rest.QueryOptions) (any, error) { return r.GetAll(r.parseRestOptions(r.ctx, options...)) } -func (r *pluginRepository) Save(entity any) (string, error) { - p := entity.(*model.Plugin) - if !r.isPermitted() { - return "", rest.ErrPermissionDenied - } - err := r.Put(p) - if errors.Is(err, model.ErrNotFound) { - return "", rest.ErrNotFound - } - return p.ID, err -} - -func (r *pluginRepository) Update(id string, entity any, cols ...string) error { - p := entity.(*model.Plugin) - p.ID = id - if !r.isPermitted() { - return rest.ErrPermissionDenied - } - err := r.Put(p) - if errors.Is(err, model.ErrNotFound) { - return rest.ErrNotFound - } - return err -} - var _ model.PluginRepository = (*pluginRepository)(nil) var _ rest.Repository = (*pluginRepository)(nil) -var _ rest.Persistable = (*pluginRepository)(nil) diff --git a/persistence/radio_repository.go b/persistence/radio_repository.go index b73487e40..915859559 100644 --- a/persistence/radio_repository.go +++ b/persistence/radio_repository.go @@ -152,7 +152,7 @@ func (r *radioRepository) Update(id string, entity any, cols ...string) error { if !r.isPermitted() { return rest.ErrPermissionDenied } - err := r.Put(t) + err := r.Put(t, cols...) if errors.Is(err, model.ErrNotFound) { return rest.ErrNotFound } diff --git a/persistence/radio_repository_test.go b/persistence/radio_repository_test.go index e2564455d..c35c85ad7 100644 --- a/persistence/radio_repository_test.go +++ b/persistence/radio_repository_test.go @@ -141,6 +141,24 @@ var _ = Describe("RadioRepository", func() { ))) }) }) + + Describe("Update", func() { + It("only writes the columns sent by the client", func() { + radio := radioWithHomePage + radio.UploadedImage = "cover.png" + Expect(repo.Put(&radio)).To(Succeed()) + + persistable := repo.(rest.Persistable) + Expect(persistable.Update(radio.ID, &model.Radio{Name: "Renamed"}, "name")).To(Succeed()) + + item, err := repo.Get(radio.ID) + Expect(err).To(BeNil()) + Expect(item.Name).To(Equal("Renamed")) + Expect(item.UploadedImage).To(Equal("cover.png")) + Expect(item.StreamUrl).To(Equal(radio.StreamUrl)) + Expect(item.HomePageUrl).To(Equal(radio.HomePageUrl)) + }) + }) }) Describe("Regular User", func() { diff --git a/persistence/sql_base_repository.go b/persistence/sql_base_repository.go index f49e1bc4f..5530d2568 100644 --- a/persistence/sql_base_repository.go +++ b/persistence/sql_base_repository.go @@ -560,13 +560,11 @@ func (r sqlRepository) putByMatch(filter Sqlizer, id string, m any, colsToUpdate return r.put(res.ID, m, colsToUpdate...) } -// filterUpdateValues selects, from a marshaled column map, the values to write in an UPDATE on the -// row identified by id: only the requested colsToUpdate (or all columns when none are specified), -// dropping columns that must never be overwritten on update (created_at, birth_time). -func filterUpdateValues(values map[string]any, id string, colsToUpdate ...string) map[string]any { +// selectUpdateColumns keeps only the requested colsToUpdate (or all columns when none are +// specified), dropping columns that must never be overwritten on update (created_at, birth_time). +func selectUpdateColumns(values map[string]any, colsToUpdate ...string) map[string]any { updateValues := map[string]any{} - // This is a map of the columns that need to be updated, if specified c2upd := slice.ToMap(colsToUpdate, func(s string) (string, struct{}) { return toSnakeCase(s), struct{}{} }) @@ -576,7 +574,6 @@ func filterUpdateValues(values map[string]any, id string, colsToUpdate ...string } } - updateValues["id"] = id delete(updateValues, "created_at") // To avoid updating the media_file birth_time on each scan. Not the best solution, but it works for now // TODO move to mediafile_repository when each repo has its own upsert method @@ -584,6 +581,12 @@ func filterUpdateValues(values map[string]any, id string, colsToUpdate ...string return updateValues } +func filterUpdateValues(values map[string]any, id string, colsToUpdate ...string) map[string]any { + updateValues := selectUpdateColumns(values, colsToUpdate...) + updateValues["id"] = id + return updateValues +} + func (r sqlRepository) put(id string, m any, colsToUpdate ...string) (newId string, err error) { values, err := toSQLArgs(m) if err != nil { diff --git a/tests/mock_library_repo.go b/tests/mock_library_repo.go index 3f0e576e9..1a16a7e0b 100644 --- a/tests/mock_library_repo.go +++ b/tests/mock_library_repo.go @@ -14,9 +14,10 @@ import ( type MockLibraryRepo struct { model.LibraryRepository - Data map[int]model.Library - Err error - PutFn func(*model.Library) error // Allow custom Put behavior for testing + Data map[int]model.Library + Err error + PutFn func(*model.Library) error // Allow custom Put behavior for testing + PutCols []string } func (m *MockLibraryRepo) SetData(data model.Libraries) { @@ -90,7 +91,8 @@ func (m *MockLibraryRepo) GetPath(id int) (string, error) { return "", model.ErrNotFound } -func (m *MockLibraryRepo) Put(library *model.Library) error { +func (m *MockLibraryRepo) Put(library *model.Library, colsToUpdate ...string) error { + m.PutCols = colsToUpdate if m.PutFn != nil { return m.PutFn(library) } From 1f861d27efd97fa7df2b7a6c3e83e665e43270a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Mon, 31 Aug 2026 21:27:43 -0400 Subject: [PATCH 45/78] fix(plugins): build public URLs on the caller's address instead of localhost (#6059) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(plugins): build public URLs on the caller's address instead of localhost The artwork host service had no `*http.Request`, so it passed `nil` to `publicurl.ImageURL`. With neither `ShareURL` nor `BaseURL` configured, that produced `http://localhost/share/img/...`, which is useless to anything outside the server. The Discord Rich Presence plugin explicitly drops localhost URLs, so it fell back to the Navidrome logo instead of the real cover art. `serverAddressMiddleware` already works out the client-facing scheme and host from the `X-Forwarded-*` headers. It now also records them in the request context, and `publicurl` takes a `context.Context` instead of an `*http.Request` so any caller can reach them. Extism passes the caller's context through to host functions, so plugins invoked during a request now get a reachable URL with no configuration. Switching the parameter also removes the need for a second, parallel entry point: the package previously wanted only a scheme, a host, and a context, and took a whole request to get them. `AbsoluteURL` no longer dereferences a possibly-nil request on its parse-error path. Plugin calls that start from `context.Background()` (scheduler and websocket callbacks, the buffered scrobble drain) still fall back to localhost, since they have no request to learn from. A debug log now points at `ShareURL` when that happens. * fix(publicurl): include the configured port in the localhost fallback The last-resort fallback built `http://localhost/...`, which points at port 80 and so is unreachable for a server listening anywhere else — the default 4533 included. Use `conf.Server.Port` so a consumer on the same machine can actually fetch the URL. * fix(publicurl): use https in the localhost fallback when TLS is configured The fallback hardcoded the http scheme, so a TLS-only server with no BaseURL advertised a URL it does not answer on. Mirror the server's own switch, which requires both a certificate and a key. * refactor(publicurl): tidy the localhost fallback and its tests Use gg.If for the fallback scheme so it reads as an expression, like the BaseScheme branch above it, instead of assigning http and overwriting it. Drop two tests the ctx refactor left redundant: one asserted PublicURL "works without a request" but became a byte-identical copy of the ShareURL spec once the *http.Request parameter went away, and the two port specs differed only in the integer, where the non-default port is the stronger assertion. * refactor(conf): add TLSEnabled and use it instead of repeating the predicate Whether the server speaks HTTPS was decided inline in three unconnected places. This PR added the third, in a URL-building package that has no business inferring the transport config. Move the rule to conf, next to the fields it derives from, and call it from publicurl and the insights collector. server.Run keeps its own expression: it takes the certificate and key as parameters, and its test passes values that do not come from the config. --- conf/configuration.go | 6 ++ conf/configuration_test.go | 27 ++++++++ core/metrics/insights.go | 2 +- core/publicurl/publicurl.go | 41 +++++------ core/publicurl/publicurl_test.go | 113 ++++++++++++++++++++----------- model/request/request.go | 21 ++++++ model/request/request_test.go | 31 +++++++++ plugins/host_artwork.go | 16 ++--- plugins/host_artwork_test.go | 11 ++- server/middlewares.go | 2 + server/middlewares_test.go | 29 ++++++++ server/public/handle_shares.go | 6 +- server/public/public.go | 5 +- server/subsonic/browsing.go | 12 ++-- server/subsonic/helpers.go | 4 +- server/subsonic/searching.go | 2 +- server/subsonic/sharing.go | 2 +- 17 files changed, 245 insertions(+), 85 deletions(-) diff --git a/conf/configuration.go b/conf/configuration.go index df22e4ae2..b9c9f027e 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -315,6 +315,12 @@ var currentGOOS = func() string { return runtime.GOOS } +// TLSEnabled reports whether the server serves HTTPS. Both halves are required, +// so callers cannot infer it from the certificate alone. +func (c *configOptions) TLSEnabled() bool { + return c.TLSCert != "" && c.TLSKey != "" +} + var ( Server = &configOptions{} hooks []func() diff --git a/conf/configuration_test.go b/conf/configuration_test.go index ac6477572..344ab3595 100644 --- a/conf/configuration_test.go +++ b/conf/configuration_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/log" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -453,3 +454,29 @@ var _ = Describe("Configuration", func() { Entry("JSON format", "json"), ) }) + +var _ = Describe("TLSEnabled", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + + It("is false when neither the certificate nor the key is set", func() { + Expect(conf.Server.TLSEnabled()).To(BeFalse()) + }) + + It("is true when both the certificate and the key are set", func() { + conf.Server.TLSCert = "cert.pem" + conf.Server.TLSKey = "key.pem" + Expect(conf.Server.TLSEnabled()).To(BeTrue()) + }) + + It("is false when only the certificate is set", func() { + conf.Server.TLSCert = "cert.pem" + Expect(conf.Server.TLSEnabled()).To(BeFalse()) + }) + + It("is false when only the key is set", func() { + conf.Server.TLSKey = "key.pem" + Expect(conf.Server.TLSEnabled()).To(BeFalse()) + }) +}) diff --git a/core/metrics/insights.go b/core/metrics/insights.go index 706df6559..d952f517a 100644 --- a/core/metrics/insights.go +++ b/core/metrics/insights.go @@ -198,7 +198,7 @@ var staticData = sync.OnceValue(func() insights.Data { // Config info data.Config.LogLevel = conf.Server.LogLevel data.Config.LogFileConfigured = conf.Server.LogFile != "" - data.Config.TLSConfigured = conf.Server.TLSCert != "" && conf.Server.TLSKey != "" + data.Config.TLSConfigured = conf.Server.TLSEnabled() data.Config.DefaultBackgroundURLSet = conf.Server.UILoginBackgroundURL == consts.DefaultUILoginBackgroundURL data.Config.EnableArtworkPrecache = conf.Server.EnableArtworkPrecache data.Config.EnableArtworkUpload = conf.Server.EnableArtworkUpload diff --git a/core/publicurl/publicurl.go b/core/publicurl/publicurl.go index b0865e78b..345606324 100644 --- a/core/publicurl/publicurl.go +++ b/core/publicurl/publicurl.go @@ -2,7 +2,7 @@ package publicurl import ( "cmp" - "net/http" + "context" "net/url" "path" "strconv" @@ -13,35 +13,36 @@ 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/utils/gg" ) // ImageURL generates a public URL for artwork images. // It creates a signed token for the artwork ID and builds a complete public URL. -func ImageURL(req *http.Request, artID model.ArtworkID, size int) string { +func ImageURL(ctx context.Context, artID model.ArtworkID, size int) string { token, _ := auth.CreatePublicToken(auth.Claims{ID: artID.String()}) uri := path.Join(consts.URLPathPublicImages, token) params := url.Values{} if size > 0 { params.Add("size", strconv.Itoa(size)) } - return PublicURL(req, uri, params) + return PublicURL(ctx, uri, params) } // PublicURL builds a full URL for public-facing resources. -// It uses ShareURL from config if available, otherwise falls back to extracting -// the scheme and host from the provided http.Request. -// If req is nil and ShareURL is not set, it defaults to http://localhost. -func PublicURL(req *http.Request, u string, params url.Values) string { +// It uses ShareURL from config if available, otherwise falls back to the address the +// client used to reach the server, recorded in the context. +func PublicURL(ctx context.Context, u string, params url.Values) string { if conf.Server.ShareURL == "" { - return AbsoluteURL(req, u, params) + return AbsoluteURL(ctx, u, params) } shareUrl, err := url.Parse(conf.Server.ShareURL) if err != nil { - return AbsoluteURL(req, u, params) + return AbsoluteURL(ctx, u, params) } buildUrl, err := url.Parse(u) if err != nil { - return AbsoluteURL(req, u, params) + return AbsoluteURL(ctx, u, params) } buildUrl.Scheme = shareUrl.Scheme buildUrl.Host = shareUrl.Host @@ -55,13 +56,12 @@ func PublicURL(req *http.Request, u string, params url.Values) string { } // AbsoluteURL builds an absolute URL from a relative path. -// It uses BaseHost/BaseScheme from config if available, otherwise extracts -// the scheme and host from the http.Request. -// If req is nil and BaseHost is not set, it defaults to http://localhost. -func AbsoluteURL(req *http.Request, u string, params url.Values) string { +// It uses BaseHost/BaseScheme from config if available, otherwise the address the client +// used to reach the server, recorded in the context by the server's address middleware. +func AbsoluteURL(ctx context.Context, u string, params url.Values) string { buildUrl, err := url.Parse(u) if err != nil { - log.Error(req.Context(), "Failed to parse URL path", "url", u, err) + log.Error(ctx, "Failed to parse URL path", "url", u, err) return "" } if strings.HasPrefix(u, "/") { @@ -69,12 +69,13 @@ func AbsoluteURL(req *http.Request, u string, params url.Values) string { if conf.Server.BaseHost != "" { buildUrl.Scheme = cmp.Or(conf.Server.BaseScheme, "http") buildUrl.Host = conf.Server.BaseHost - } else if req != nil { - buildUrl.Scheme = req.URL.Scheme - buildUrl.Host = req.Host + } else if scheme, host, ok := request.ServerAddressFrom(ctx); ok { + buildUrl.Scheme = scheme + buildUrl.Host = host } else { - buildUrl.Scheme = "http" - buildUrl.Host = "localhost" + log.Debug(ctx, "Building a public URL with no public address available; set ShareURL to make it reachable", "url", u) + buildUrl.Scheme = gg.If(conf.Server.TLSEnabled(), "https", "http") + buildUrl.Host = "localhost:" + strconv.Itoa(conf.Server.Port) } } if len(params) > 0 { diff --git a/core/publicurl/publicurl_test.go b/core/publicurl/publicurl_test.go index 7e9ee8b8e..0f8ea806c 100644 --- a/core/publicurl/publicurl_test.go +++ b/core/publicurl/publicurl_test.go @@ -1,7 +1,7 @@ package publicurl_test import ( - "net/http" + "context" "net/url" "testing" @@ -12,6 +12,7 @@ import ( "github.com/navidrome/navidrome/core/publicurl" "github.com/navidrome/navidrome/log" "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" @@ -36,24 +37,17 @@ var _ = Describe("Public URL Utilities", func() { }) It("uses ShareURL as the base", func() { - r, _ := http.NewRequest("GET", "http://localhost/test", nil) - result := publicurl.PublicURL(r, "/path/to/resource", nil) + result := publicurl.PublicURL(context.Background(), "/path/to/resource", nil) Expect(result).To(Equal("https://share.example.com/path/to/resource")) }) It("includes query parameters", func() { - r, _ := http.NewRequest("GET", "http://localhost/test", nil) params := url.Values{"size": []string{"300"}, "format": []string{"png"}} - result := publicurl.PublicURL(r, "/image/123", params) + result := publicurl.PublicURL(context.Background(), "/image/123", params) Expect(result).To(ContainSubstring("https://share.example.com/image/123")) Expect(result).To(ContainSubstring("size=300")) Expect(result).To(ContainSubstring("format=png")) }) - - It("works without a request", func() { - result := publicurl.PublicURL(nil, "/path/to/resource", nil) - Expect(result).To(Equal("https://share.example.com/path/to/resource")) - }) }) When("ShareURL includes a path", func() { @@ -62,21 +56,19 @@ var _ = Describe("Public URL Utilities", func() { }) It("prepends the ShareURL path to the resource", func() { - r, _ := http.NewRequest("GET", "http://localhost/test", nil) - result := publicurl.PublicURL(r, "/share/img/hash", nil) + result := publicurl.PublicURL(context.Background(), "/share/img/hash", nil) Expect(result).To(Equal("https://example.com/navi/share/img/hash")) }) It("prepends the ShareURL path and includes query parameters", func() { - r, _ := http.NewRequest("GET", "http://localhost/test", nil) params := url.Values{"size": []string{"600"}} - result := publicurl.PublicURL(r, "/share/img/hash", params) + result := publicurl.PublicURL(context.Background(), "/share/img/hash", params) Expect(result).To(Equal("https://example.com/navi/share/img/hash?size=600")) }) It("handles trailing slash in ShareURL path", func() { conf.Server.ShareURL = "https://example.com/navi/" - result := publicurl.PublicURL(nil, "/share/img/hash", nil) + result := publicurl.PublicURL(context.Background(), "/share/img/hash", nil) Expect(result).To(Equal("https://example.com/navi/share/img/hash")) }) }) @@ -87,15 +79,15 @@ var _ = Describe("Public URL Utilities", func() { }) It("falls back to AbsoluteURL with request", func() { - r, _ := http.NewRequest("GET", "https://myserver.com/test", nil) - r.Host = "myserver.com" - result := publicurl.PublicURL(r, "/path/to/resource", nil) + ctx := request.WithServerAddress(context.Background(), "https", "myserver.com") + result := publicurl.PublicURL(ctx, "/path/to/resource", nil) Expect(result).To(Equal("https://myserver.com/path/to/resource")) }) - It("falls back to localhost without request", func() { - result := publicurl.PublicURL(nil, "/path/to/resource", nil) - Expect(result).To(Equal("http://localhost/path/to/resource")) + It("falls back to localhost on the configured port without request", func() { + conf.Server.Port = 4533 + result := publicurl.PublicURL(context.Background(), "/path/to/resource", nil) + Expect(result).To(Equal("http://localhost:4533/path/to/resource")) }) }) }) @@ -109,15 +101,13 @@ var _ = Describe("Public URL Utilities", func() { }) It("uses BaseHost and BaseScheme", func() { - r, _ := http.NewRequest("GET", "http://localhost/test", nil) - result := publicurl.AbsoluteURL(r, "/path/to/resource", nil) + result := publicurl.AbsoluteURL(context.Background(), "/path/to/resource", nil) Expect(result).To(Equal("https://configured.example.com/path/to/resource")) }) It("defaults to http scheme if BaseScheme is empty", func() { conf.Server.BaseScheme = "" - r, _ := http.NewRequest("GET", "http://localhost/test", nil) - result := publicurl.AbsoluteURL(r, "/path/to/resource", nil) + result := publicurl.AbsoluteURL(context.Background(), "/path/to/resource", nil) Expect(result).To(Equal("http://configured.example.com/path/to/resource")) }) }) @@ -129,15 +119,30 @@ var _ = Describe("Public URL Utilities", func() { }) It("extracts host from request", func() { - r, _ := http.NewRequest("GET", "https://request.example.com/test", nil) - r.Host = "request.example.com" - result := publicurl.AbsoluteURL(r, "/path/to/resource", nil) + ctx := request.WithServerAddress(context.Background(), "https", "request.example.com") + result := publicurl.AbsoluteURL(ctx, "/path/to/resource", nil) Expect(result).To(Equal("https://request.example.com/path/to/resource")) }) - It("falls back to localhost without request", func() { - result := publicurl.AbsoluteURL(nil, "/path/to/resource", nil) - Expect(result).To(Equal("http://localhost/path/to/resource")) + It("falls back to localhost on the configured port without request", func() { + conf.Server.Port = 8080 + result := publicurl.AbsoluteURL(context.Background(), "/path/to/resource", nil) + Expect(result).To(Equal("http://localhost:8080/path/to/resource")) + }) + + It("uses https in the fallback when TLS is configured", func() { + conf.Server.Port = 4533 + conf.Server.TLSCert = "cert.pem" + conf.Server.TLSKey = "key.pem" + result := publicurl.AbsoluteURL(context.Background(), "/path/to/resource", nil) + Expect(result).To(Equal("https://localhost:4533/path/to/resource")) + }) + + It("stays on http when only the certificate is configured", func() { + conf.Server.Port = 4533 + conf.Server.TLSCert = "cert.pem" + result := publicurl.AbsoluteURL(context.Background(), "/path/to/resource", nil) + Expect(result).To(Equal("http://localhost:4533/path/to/resource")) }) }) @@ -149,24 +154,21 @@ var _ = Describe("Public URL Utilities", func() { }) It("prepends BasePath to the URL", func() { - r, _ := http.NewRequest("GET", "http://localhost/test", nil) - result := publicurl.AbsoluteURL(r, "/path/to/resource", nil) + result := publicurl.AbsoluteURL(context.Background(), "/path/to/resource", nil) Expect(result).To(Equal("https://example.com/navidrome/path/to/resource")) }) }) It("passes through absolute URLs unchanged", func() { - r, _ := http.NewRequest("GET", "http://localhost/test", nil) - result := publicurl.AbsoluteURL(r, "https://other.example.com/path", nil) + result := publicurl.AbsoluteURL(context.Background(), "https://other.example.com/path", nil) Expect(result).To(Equal("https://other.example.com/path")) }) It("includes query parameters", func() { conf.Server.BaseHost = "example.com" conf.Server.BaseScheme = "https" - r, _ := http.NewRequest("GET", "http://localhost/test", nil) params := url.Values{"key": []string{"value"}} - result := publicurl.AbsoluteURL(r, "/path", params) + result := publicurl.AbsoluteURL(context.Background(), "/path", params) Expect(result).To(Equal("https://example.com/path?key=value")) }) }) @@ -180,20 +182,51 @@ var _ = Describe("Public URL Utilities", func() { It("generates a URL with the artwork token", func() { artID := model.NewArtworkID(model.KindAlbumArtwork, "album-123", nil) - result := publicurl.ImageURL(nil, artID, 0) + result := publicurl.ImageURL(context.Background(), artID, 0) Expect(result).To(HavePrefix("https://share.example.com/share/img/")) }) It("includes size parameter when provided", func() { artID := model.NewArtworkID(model.KindArtistArtwork, "artist-1", nil) - result := publicurl.ImageURL(nil, artID, 300) + result := publicurl.ImageURL(context.Background(), artID, 300) Expect(result).To(ContainSubstring("size=300")) }) It("omits size parameter when zero", func() { artID := model.NewArtworkID(model.KindMediaFileArtwork, "track-1", nil) - result := publicurl.ImageURL(nil, artID, 0) + result := publicurl.ImageURL(context.Background(), artID, 0) Expect(result).ToNot(ContainSubstring("size=")) }) }) + + Describe("ImageURL address precedence", func() { + var artID model.ArtworkID + + BeforeEach(func() { + auth.PublicTokenAuth = jwtauth.New("HS256", []byte("test secret"), nil) + artID = model.NewArtworkID(model.KindMediaFileArtwork, "track-1", nil) + }) + + It("uses the address of the request that triggered the call", func() { + ctx := request.WithServerAddress(context.Background(), "https", "music.example.com") + + result := publicurl.ImageURL(ctx, artID, 300) + Expect(result).To(HavePrefix("https://music.example.com/share/img/")) + Expect(result).To(ContainSubstring("size=300")) + }) + + It("prefers ShareURL over the address in the context", func() { + conf.Server.ShareURL = "https://share.example.com" + ctx := request.WithServerAddress(context.Background(), "https", "music.example.com") + + result := publicurl.ImageURL(ctx, artID, 0) + Expect(result).To(HavePrefix("https://share.example.com/share/img/")) + }) + + It("falls back to localhost on the configured port when no address is available", func() { + conf.Server.Port = 4533 + result := publicurl.ImageURL(context.Background(), artID, 0) + Expect(result).To(HavePrefix("http://localhost:4533/share/img/")) + }) + }) }) diff --git a/model/request/request.go b/model/request/request.go index 2b1cfb9ef..33c49f797 100644 --- a/model/request/request.go +++ b/model/request/request.go @@ -20,6 +20,7 @@ const ( ReverseProxyIp = contextKey("reverseProxyIp") InternalAuth = contextKey("internalAuth") // Used for internal API calls, e.g., from the plugins TokenEpochHolder = contextKey("tokenEpochHolder") + ServerAddress = contextKey("serverAddress") ) var allKeys = []contextKey{ @@ -32,6 +33,7 @@ var allKeys = []contextKey{ ClientUniqueId, ReverseProxyIp, InternalAuth, + ServerAddress, } func WithUser(ctx context.Context, u model.User) context.Context { @@ -70,6 +72,25 @@ func WithInternalAuth(ctx context.Context, username string) context.Context { return context.WithValue(ctx, InternalAuth, username) } +// serverAddress is the public scheme and host the client used to reach this server, +// so code running without an http.Request can still build absolute URLs. +type serverAddress struct { + scheme string + host string +} + +func WithServerAddress(ctx context.Context, scheme, host string) context.Context { + return context.WithValue(ctx, ServerAddress, serverAddress{scheme: scheme, host: host}) +} + +func ServerAddressFrom(ctx context.Context) (scheme, host string, ok bool) { + a, ok := ctx.Value(ServerAddress).(serverAddress) + if !ok || a.host == "" { + return "", "", false + } + return a.scheme, a.host, true +} + func UserFrom(ctx context.Context) (model.User, bool) { v, ok := ctx.Value(User).(model.User) return v, ok diff --git a/model/request/request_test.go b/model/request/request_test.go index ef9af8231..da819f9c0 100644 --- a/model/request/request_test.go +++ b/model/request/request_test.go @@ -38,3 +38,34 @@ var _ = Describe("Token epoch holder", func() { Expect(ok).To(BeFalse()) }) }) + +var _ = Describe("Server address", func() { + It("reports nothing when unset", func() { + _, _, ok := ServerAddressFrom(context.TODO()) + Expect(ok).To(BeFalse()) + }) + + It("round-trips the scheme and host", func() { + ctx := WithServerAddress(context.TODO(), "https", "music.example.com") + + scheme, host, ok := ServerAddressFrom(ctx) + Expect(ok).To(BeTrue()) + Expect(scheme).To(Equal("https")) + Expect(host).To(Equal("music.example.com")) + }) + + It("reports nothing when the host is empty", func() { + ctx := WithServerAddress(context.TODO(), "https", "") + + _, _, ok := ServerAddressFrom(ctx) + Expect(ok).To(BeFalse()) + }) + + It("is carried over to a background context by AddValues", func() { + reqCtx := WithServerAddress(context.TODO(), "https", "music.example.com") + + _, host, ok := ServerAddressFrom(AddValues(context.Background(), reqCtx)) + Expect(ok).To(BeTrue()) + Expect(host).To(Equal("music.example.com")) + }) +}) diff --git a/plugins/host_artwork.go b/plugins/host_artwork.go index 49b9a285d..7a19eddb9 100644 --- a/plugins/host_artwork.go +++ b/plugins/host_artwork.go @@ -14,24 +14,24 @@ func newArtworkService() host.ArtworkService { return &artworkServiceImpl{} } -func (a *artworkServiceImpl) GetArtistUrl(_ context.Context, id string, size int32) (string, error) { +func (a *artworkServiceImpl) GetArtistUrl(ctx context.Context, id string, size int32) (string, error) { artID := model.ArtworkID{Kind: model.KindArtistArtwork, ID: id} - return publicurl.ImageURL(nil, artID, int(size)), nil + return publicurl.ImageURL(ctx, artID, int(size)), nil } -func (a *artworkServiceImpl) GetAlbumUrl(_ context.Context, id string, size int32) (string, error) { +func (a *artworkServiceImpl) GetAlbumUrl(ctx context.Context, id string, size int32) (string, error) { artID := model.ArtworkID{Kind: model.KindAlbumArtwork, ID: id} - return publicurl.ImageURL(nil, artID, int(size)), nil + return publicurl.ImageURL(ctx, artID, int(size)), nil } -func (a *artworkServiceImpl) GetTrackUrl(_ context.Context, id string, size int32) (string, error) { +func (a *artworkServiceImpl) GetTrackUrl(ctx context.Context, id string, size int32) (string, error) { artID := model.ArtworkID{Kind: model.KindMediaFileArtwork, ID: id} - return publicurl.ImageURL(nil, artID, int(size)), nil + return publicurl.ImageURL(ctx, artID, int(size)), nil } -func (a *artworkServiceImpl) GetPlaylistUrl(_ context.Context, id string, size int32) (string, error) { +func (a *artworkServiceImpl) GetPlaylistUrl(ctx context.Context, id string, size int32) (string, error) { artID := model.ArtworkID{Kind: model.KindPlaylistArtwork, ID: id} - return publicurl.ImageURL(nil, artID, int(size)), nil + return publicurl.ImageURL(ctx, artID, int(size)), nil } var _ host.ArtworkService = (*artworkServiceImpl)(nil) diff --git a/plugins/host_artwork_test.go b/plugins/host_artwork_test.go index 218d3d892..50a1a0335 100644 --- a/plugins/host_artwork_test.go +++ b/plugins/host_artwork_test.go @@ -16,6 +16,7 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -122,7 +123,7 @@ var _ = Describe("ArtworkService", Ordered, func() { Size: size, } inputBytes, _ := json.Marshal(input) - _, outputBytes, err := instance.Call("nd_test_artwork", inputBytes) + _, outputBytes, err := instance.CallWithContext(ctx, "nd_test_artwork", inputBytes) if err != nil { return "", err } @@ -189,6 +190,14 @@ var _ = Describe("ArtworkService", Ordered, func() { Expect(artID.ID).To(Equal("al-456")) }) + It("uses the address of the request that triggered the plugin", func() { + ctx := request.WithServerAddress(GinkgoT().Context(), "https", "music.example.com") + + url, err := callTestArtwork(ctx, "track", "mf-789", 300) + Expect(err).ToNot(HaveOccurred()) + Expect(url).To(HavePrefix("https://music.example.com/share/img/")) + }) + It("should handle unknown artwork type", func() { _, err := callTestArtwork(GinkgoT().Context(), "unknown", "id-123", 0) Expect(err).To(HaveOccurred()) diff --git a/server/middlewares.go b/server/middlewares.go index 23e11eaa6..b710b4068 100644 --- a/server/middlewares.go +++ b/server/middlewares.go @@ -208,6 +208,8 @@ func serverAddressMiddleware(h http.Handler) http.Handler { if rScheme, rHost := ServerAddress(r); rHost != "" { r.Host = rHost r.URL.Scheme = rScheme + // Recorded so code running without the request (e.g. plugins) can build public URLs. + r = r.WithContext(request.WithServerAddress(r.Context(), rScheme, rHost)) } // Call the next handler in the chain with the modified request and response. diff --git a/server/middlewares_test.go b/server/middlewares_test.go index 5cecba7d5..4ba9a853b 100644 --- a/server/middlewares_test.go +++ b/server/middlewares_test.go @@ -13,6 +13,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/publicurl" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" @@ -69,10 +70,15 @@ var _ = Describe("middlewares", func() { middleware http.Handler recorder *httptest.ResponseRecorder req *http.Request + gotScheme string + gotHost string + gotOK bool ) BeforeEach(func() { + gotScheme, gotHost, gotOK = "", "", false nextHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotScheme, gotHost, gotOK = request.ServerAddressFrom(r.Context()) w.WriteHeader(http.StatusOK) }) middleware = serverAddressMiddleware(nextHandler) @@ -89,6 +95,13 @@ var _ = Describe("middlewares", func() { Expect(req.Host).To(Equal("example.com")) Expect(req.URL.Scheme).To(Equal("http")) }) + + It("should record the address in the context", func() { + middleware.ServeHTTP(recorder, req) + Expect(gotOK).To(BeTrue()) + Expect(gotScheme).To(Equal("http")) + Expect(gotHost).To(Equal("example.com")) + }) }) Context("with X-Forwarded-Host header", func() { @@ -143,6 +156,22 @@ var _ = Describe("middlewares", func() { Expect(req.Host).To(Equal("forwarded.example.com")) Expect(req.URL.Scheme).To(Equal("https")) }) + + It("should record the forwarded address in the context", func() { + middleware.ServeHTTP(recorder, req) + Expect(gotOK).To(BeTrue()) + Expect(gotScheme).To(Equal("https")) + Expect(gotHost).To(Equal("forwarded.example.com")) + }) + + It("lets a handler build a public URL on the forwarded address", func() { + var got string + serverAddressMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = publicurl.AbsoluteURL(r.Context(), "/share/img/token", nil) + })).ServeHTTP(recorder, req) + + Expect(got).To(Equal("https://forwarded.example.com/share/img/token")) + }) }) Context("with multiple values in X-Forwarded-Host header", func() { diff --git a/server/public/handle_shares.go b/server/public/handle_shares.go index 13a7e4c32..d67cfe456 100644 --- a/server/public/handle_shares.go +++ b/server/public/handle_shares.go @@ -81,8 +81,8 @@ func checkShareError(ctx context.Context, w http.ResponseWriter, err error, id s } func (pub *Router) mapShareInfo(r *http.Request, s model.Share) *model.Share { - s.URL = ShareURL(r, s.ID) - s.ImageURL = publicurl.ImageURL(r, s.CoverArtID(), conf.Server.UICoverArtSize) + s.URL = ShareURL(r.Context(), s.ID) + s.ImageURL = publicurl.ImageURL(r.Context(), s.CoverArtID(), conf.Server.UICoverArtSize) for i := range s.Tracks { s.Tracks[i].ID = encodeMediafileShare(s, s.Tracks[i].ID) } @@ -92,7 +92,7 @@ func (pub *Router) mapShareInfo(r *http.Request, s model.Share) *model.Share { func (pub *Router) mapShareToM3U(r *http.Request, s model.Share) *model.Share { for i := range s.Tracks { id := encodeMediafileShare(s, s.Tracks[i].ID) - s.Tracks[i].Path = publicurl.PublicURL(r, path.Join(consts.URLPathPublic, "s", id), nil) + s.Tracks[i].Path = publicurl.PublicURL(r.Context(), path.Join(consts.URLPathPublic, "s", id), nil) } return &s } diff --git a/server/public/public.go b/server/public/public.go index 18867e1c4..142c474bd 100644 --- a/server/public/public.go +++ b/server/public/public.go @@ -1,6 +1,7 @@ package public import ( + "context" "net/http" "path" @@ -59,7 +60,7 @@ func (pub *Router) routes() http.Handler { return r } -func ShareURL(r *http.Request, id string) string { +func ShareURL(ctx context.Context, id string) string { uri := path.Join(consts.URLPathPublic, id) - return publicurl.PublicURL(r, uri, nil) + return publicurl.PublicURL(ctx, uri, nil) } diff --git a/server/subsonic/browsing.go b/server/subsonic/browsing.go index d32a5c857..ac0edb69f 100644 --- a/server/subsonic/browsing.go +++ b/server/subsonic/browsing.go @@ -231,9 +231,9 @@ func (api *Router) GetAlbumInfo(r *http.Request) (*responses.Subsonic, error) { response.AlbumInfo = &responses.AlbumInfo{} response.AlbumInfo.Notes = album.Description 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.SmallImageUrl = publicurl.ImageURL(r.Context(), album.CoverArtID(), 300) + response.AlbumInfo.MediumImageUrl = publicurl.ImageURL(r.Context(), album.CoverArtID(), 600) + response.AlbumInfo.LargeImageUrl = publicurl.ImageURL(r.Context(), album.CoverArtID(), 1200) } response.AlbumInfo.LastFmUrl = album.ExternalUrl @@ -298,9 +298,9 @@ func (api *Router) getArtistInfo(r *http.Request) (*responses.ArtistInfoBase, *m base := responses.ArtistInfoBase{} base.Biography = artist.Biography 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.SmallImageUrl = publicurl.ImageURL(r.Context(), artist.CoverArtID(), 300) + base.MediumImageUrl = publicurl.ImageURL(r.Context(), artist.CoverArtID(), 600) + base.LargeImageUrl = publicurl.ImageURL(r.Context(), artist.CoverArtID(), 1200) } base.LastFmUrl = artist.ExternalUrl base.MusicBrainzID = artist.MbzArtistID diff --git a/server/subsonic/helpers.go b/server/subsonic/helpers.go index e367cdee5..2d9d53b18 100644 --- a/server/subsonic/helpers.go +++ b/server/subsonic/helpers.go @@ -112,7 +112,7 @@ func toArtist(r *http.Request, a model.Artist) responses.Artist { CoverArt: coverArtOrEmpty(a.CoverArtID(), a.ImageAbsent), } if !a.ImageAbsent { - artist.ArtistImageUrl = publicurl.ImageURL(r, a.CoverArtID(), 600) + artist.ArtistImageUrl = publicurl.ImageURL(r.Context(), a.CoverArtID(), 600) } if conf.Server.Subsonic.EnableAverageRating { artist.AverageRating = a.AverageRating @@ -132,7 +132,7 @@ func toArtistID3(r *http.Request, a model.Artist) responses.ArtistID3 { UserRating: int32(a.Rating), } if !a.ImageAbsent { - artist.ArtistImageUrl = publicurl.ImageURL(r, a.CoverArtID(), 600) + artist.ArtistImageUrl = publicurl.ImageURL(r.Context(), a.CoverArtID(), 600) } if conf.Server.Subsonic.EnableAverageRating { artist.AverageRating = a.AverageRating diff --git a/server/subsonic/searching.go b/server/subsonic/searching.go index cc787f041..35233a98f 100644 --- a/server/subsonic/searching.go +++ b/server/subsonic/searching.go @@ -116,7 +116,7 @@ func (api *Router) Search2(r *http.Request) (*responses.Subsonic, error) { CoverArt: coverArtOrEmpty(artist.CoverArtID(), artist.ImageAbsent), } if !artist.ImageAbsent { - a.ArtistImageUrl = publicurl.ImageURL(r, artist.CoverArtID(), 600) + a.ArtistImageUrl = publicurl.ImageURL(r.Context(), artist.CoverArtID(), 600) } if artist.Starred { a.Starred = artist.StarredAt diff --git a/server/subsonic/sharing.go b/server/subsonic/sharing.go index 540ae79d7..36124c40b 100644 --- a/server/subsonic/sharing.go +++ b/server/subsonic/sharing.go @@ -31,7 +31,7 @@ func (api *Router) GetShares(r *http.Request) (*responses.Subsonic, error) { func (api *Router) buildShare(r *http.Request, share model.Share) responses.Share { resp := responses.Share{ ID: share.ID, - Url: public.ShareURL(r, share.ID), + Url: public.ShareURL(r.Context(), share.ID), Description: share.Description, Username: share.Username, Created: share.CreatedAt, From c9385fbb6b2e985d6c03034cf44eb05237c7e417 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Mon, 31 Aug 2026 21:42:10 -0400 Subject: [PATCH 46/78] test(plugins): build test plugins in Go instead of shelling out to make (#6060) * test(plugins): build test plugins in Go instead of shelling out to make The plugins suite built its .ndp test packages by running `make -C plugins/testdata`, which needs make and zip on the PATH. That is the reason the 26 WASM-dependent spec files are tagged //go:build !windows. buildTestPlugins now does the same work in Go: the same mtime check make performed, `GOOS=wasip1 GOARCH=wasm go build` per plugin, and archive/zip for the package. TinyGo was already optional and unused in CI, so nothing is lost there. The first plugin builds on its own so the shared wasip1 stdlib and PDK objects land in the build cache before the rest fan out: on a cold cache that is 2.2s against 3.4s for the sequential make and 7.3s for an unrestrained fan-out. Packaging moved into a writeNdp helper shared with createTestPackage, which was already writing the same two-entry archive. Entries are written in a fixed order, so the .ndp bytes are now reproducible; the loader hashes those bytes, and `zip` also stored file mtimes, so the previous packages differed on every rebuild. The Makefile is unchanged and still works for building the plugins by hand. Removing the !windows tags is a separate step, once CI is green here. * test(plugins): run the WASM plugin specs on Windows With the test plugins now built in Go, nothing in the suite needs a Unix toolchain, so the //go:build !windows tags come off all 25 spec files. The Windows CI job runs `go test ./...`, so it picks the suite up with no workflow change. plugins_suite_windows_test.go existed only to bootstrap the handful of specs that compiled on Windows; plugins_suite_test.go now serves both. * test(plugins): skip the planted-symlink spec where symlinks need privileges os.Symlink needs an elevated token or Developer Mode on Windows, so the unconditional Expect(...).To(Succeed()) would fail for contributors running the suite on an ordinary Windows box. The elevated GitHub runner hides this. The equivalent spec in sandbox_fs_internal_test.go already attempts the symlink and skips on error; this does the same, keeping the pin live everywhere it can run, including Windows CI. * fix(ci): stop the Windows ndpgen test failing silently The ndpgen suite builds its helper binary to %TEMP%\ndpgen-test, and Windows will not exec a file without an executable extension, so the "supports verbose mode" spec has been failing there. Nobody noticed because the Test ndpgen step ran under pwsh, which carries on after a non-zero exit and takes the step's status from the last command, so the job stayed green with a FAIL line in its log. Add the .exe suffix, and run the step under bash like the Linux job does, so a failure in any of its three commands fails the job. --- .github/workflows/pipeline.yml | 6 +- plugins/cmd/ndpgen/integration_test.go | 4 + plugins/host_artwork_test.go | 2 - plugins/host_cache_test.go | 2 - plugins/host_config_test.go | 2 - plugins/host_httpclient_test.go | 2 - plugins/host_kvstore_test.go | 2 - plugins/host_library_test.go | 2 - plugins/host_matcher_test.go | 2 - plugins/host_scheduler_test.go | 2 - plugins/host_scrobbleretriever_test.go | 2 - plugins/host_storage_test.go | 2 - plugins/host_subsonicapi_test.go | 2 - plugins/host_taskqueue_test.go | 2 - plugins/host_users_test.go | 2 - plugins/host_websocket_test.go | 2 - plugins/lyrics_adapter_test.go | 2 - plugins/manager_call_test.go | 2 - plugins/manager_loader_load_test.go | 2 - plugins/manager_readonly_test.go | 2 - plugins/manager_test.go | 2 - plugins/manager_watcher_test.go | 2 - plugins/metadata_agent_test.go | 2 - plugins/package_test.go | 50 +++++----- plugins/plugins_suite_test.go | 12 --- plugins/plugins_suite_windows_test.go | 23 ----- plugins/sandbox_fs_test.go | 6 +- plugins/scrobbler_adapter_test.go | 2 - plugins/sonic_similarity_adapter_test.go | 2 - plugins/testdata_builder_test.go | 119 +++++++++++++++++++++++ 30 files changed, 156 insertions(+), 110 deletions(-) delete mode 100644 plugins/plugins_suite_windows_test.go create mode 100644 plugins/testdata_builder_test.go diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 9da99e864..37b532eb0 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -237,12 +237,12 @@ jobs: run: go test -shuffle=on -tags netgo,sqlite_fts5 ./... -v - name: Test ndpgen - shell: pwsh + shell: bash run: | - cd plugins\cmd\ndpgen + cd plugins/cmd/ndpgen go test -shuffle=on -v go build -o ndpgen.exe . - .\ndpgen.exe --help + ./ndpgen.exe --help js: name: Test JS code diff --git a/plugins/cmd/ndpgen/integration_test.go b/plugins/cmd/ndpgen/integration_test.go index d8bc5859d..bbe886f8d 100644 --- a/plugins/cmd/ndpgen/integration_test.go +++ b/plugins/cmd/ndpgen/integration_test.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" . "github.com/onsi/ginkgo/v2" @@ -32,6 +33,9 @@ var _ = Describe("ndpgen CLI", Ordered, func() { // Build the ndpgen binary ndpgenBin = filepath.Join(os.TempDir(), "ndpgen-test") + if runtime.GOOS == "windows" { + ndpgenBin += ".exe" // Windows refuses to exec a file without it + } cmd := exec.Command("go", "build", "-o", ndpgenBin, ".") cmd.Dir = mustGetWd(GinkgoT()) output, err := cmd.CombinedOutput() diff --git a/plugins/host_artwork_test.go b/plugins/host_artwork_test.go index 50a1a0335..311bf21d5 100644 --- a/plugins/host_artwork_test.go +++ b/plugins/host_artwork_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/host_cache_test.go b/plugins/host_cache_test.go index 5e91b04ec..925856701 100644 --- a/plugins/host_cache_test.go +++ b/plugins/host_cache_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/host_config_test.go b/plugins/host_config_test.go index b296d29fb..80b06efe1 100644 --- a/plugins/host_config_test.go +++ b/plugins/host_config_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/host_httpclient_test.go b/plugins/host_httpclient_test.go index 27e92d59d..4eb34a247 100644 --- a/plugins/host_httpclient_test.go +++ b/plugins/host_httpclient_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/host_kvstore_test.go b/plugins/host_kvstore_test.go index 997409146..9af3fcbcd 100644 --- a/plugins/host_kvstore_test.go +++ b/plugins/host_kvstore_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/host_library_test.go b/plugins/host_library_test.go index eb5b17a02..00a953b24 100644 --- a/plugins/host_library_test.go +++ b/plugins/host_library_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/host_matcher_test.go b/plugins/host_matcher_test.go index 9d44ee581..f0967628c 100644 --- a/plugins/host_matcher_test.go +++ b/plugins/host_matcher_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/host_scheduler_test.go b/plugins/host_scheduler_test.go index ca53aed56..6940d8820 100644 --- a/plugins/host_scheduler_test.go +++ b/plugins/host_scheduler_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/host_scrobbleretriever_test.go b/plugins/host_scrobbleretriever_test.go index ab817c282..aa92c9eb5 100644 --- a/plugins/host_scrobbleretriever_test.go +++ b/plugins/host_scrobbleretriever_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/host_storage_test.go b/plugins/host_storage_test.go index d6f895bff..9fc58c396 100644 --- a/plugins/host_storage_test.go +++ b/plugins/host_storage_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/host_subsonicapi_test.go b/plugins/host_subsonicapi_test.go index c3d9ffe8a..4b941bc43 100644 --- a/plugins/host_subsonicapi_test.go +++ b/plugins/host_subsonicapi_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/host_taskqueue_test.go b/plugins/host_taskqueue_test.go index d459fd69b..72fb4cccd 100644 --- a/plugins/host_taskqueue_test.go +++ b/plugins/host_taskqueue_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/host_users_test.go b/plugins/host_users_test.go index 42f6a3032..1721d3ee2 100644 --- a/plugins/host_users_test.go +++ b/plugins/host_users_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/host_websocket_test.go b/plugins/host_websocket_test.go index 2aa85cd21..9f2d20bef 100644 --- a/plugins/host_websocket_test.go +++ b/plugins/host_websocket_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/lyrics_adapter_test.go b/plugins/lyrics_adapter_test.go index d110665f5..fe15e40b2 100644 --- a/plugins/lyrics_adapter_test.go +++ b/plugins/lyrics_adapter_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/manager_call_test.go b/plugins/manager_call_test.go index 3e64f1cee..5e2c6385e 100644 --- a/plugins/manager_call_test.go +++ b/plugins/manager_call_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/manager_loader_load_test.go b/plugins/manager_loader_load_test.go index 8f35548af..eb004276e 100644 --- a/plugins/manager_loader_load_test.go +++ b/plugins/manager_loader_load_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/manager_readonly_test.go b/plugins/manager_readonly_test.go index 9d16b76e9..019b14fbf 100644 --- a/plugins/manager_readonly_test.go +++ b/plugins/manager_readonly_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/manager_test.go b/plugins/manager_test.go index 9b6f7ea39..6cf90994a 100644 --- a/plugins/manager_test.go +++ b/plugins/manager_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/manager_watcher_test.go b/plugins/manager_watcher_test.go index 5b5ffca02..99326bde1 100644 --- a/plugins/manager_watcher_test.go +++ b/plugins/manager_watcher_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/metadata_agent_test.go b/plugins/metadata_agent_test.go index a7a0aa8b8..0a7b6c1e2 100644 --- a/plugins/metadata_agent_test.go +++ b/plugins/metadata_agent_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/package_test.go b/plugins/package_test.go index 953797750..1539d8df7 100644 --- a/plugins/package_test.go +++ b/plugins/package_test.go @@ -2,8 +2,11 @@ package plugins import ( "archive/zip" + "bytes" "encoding/json" + "errors" "fmt" + "io" "os" "path/filepath" @@ -266,6 +269,14 @@ func (h *testZipHelper) close() error { // createTestPackage creates an .ndp package file from a manifest and wasm bytes. // This is primarily used for testing. func createTestPackage(ndpPath string, manifest *Manifest, wasmBytes []byte) error { + manifestBytes, err := json.Marshal(manifest) + if err != nil { + return fmt.Errorf("marshaling manifest: %w", err) + } + return writeNdp(ndpPath, bytes.NewReader(manifestBytes), bytes.NewReader(wasmBytes)) +} + +func writeNdp(ndpPath string, manifest, wasm io.Reader) error { f, err := os.Create(ndpPath) if err != nil { return fmt.Errorf("creating package file: %w", err) @@ -273,30 +284,23 @@ func createTestPackage(ndpPath string, manifest *Manifest, wasmBytes []byte) err defer f.Close() zw := zip.NewWriter(f) - defer zw.Close() - - // Write manifest.json - manifestBytes, err := json.Marshal(manifest) - if err != nil { - return fmt.Errorf("marshaling manifest: %w", err) + add := func(name string, r io.Reader) error { + w, err := zw.Create(name) + if err != nil { + return fmt.Errorf("creating %s in package: %w", name, err) + } + if _, err := io.Copy(w, r); err != nil { + return fmt.Errorf("writing %s: %w", name, err) + } + return nil } - - mw, err := zw.Create(manifestFileName) - if err != nil { - return fmt.Errorf("creating manifest in zip: %w", err) + // Entry order is fixed: the loader hashes the package bytes, so they must + // be reproducible across rebuilds. + if err := add(manifestFileName, manifest); err != nil { + return err } - if _, err := mw.Write(manifestBytes); err != nil { - return fmt.Errorf("writing manifest: %w", err) + if err := add(wasmFileName, wasm); err != nil { + return err } - - // Write plugin.wasm - ww, err := zw.Create(wasmFileName) - if err != nil { - return fmt.Errorf("creating wasm in zip: %w", err) - } - if _, err := ww.Write(wasmBytes); err != nil { - return fmt.Errorf("writing wasm: %w", err) - } - - return nil + return errors.Join(zw.Close(), f.Close()) } diff --git a/plugins/plugins_suite_test.go b/plugins/plugins_suite_test.go index 2dae65d00..cc1b45f97 100644 --- a/plugins/plugins_suite_test.go +++ b/plugins/plugins_suite_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( @@ -7,15 +5,12 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" - "fmt" "net/http" "os" - "os/exec" "path/filepath" "runtime" "strings" "testing" - "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" @@ -51,13 +46,6 @@ func TestPlugins(t *testing.T) { RunSpecs(t, "Plugins Suite") } -func buildTestPlugins(path string) { - start := time.Now() - out, err := exec.Command("make", "-C", path).CombinedOutput() - fmt.Fprintf(GinkgoWriter, "[BeforeSuite] built test plugins in %s:\n%s", time.Since(start), out) - Expect(err).ToNot(HaveOccurred(), "failed to build test plugins") -} - // createTestManager creates a new plugin Manager with the given plugin config. // It creates a temp directory, copies the test-metadata-agent plugin, and starts the manager. // Returns the manager, temp directory path, and a cleanup function. diff --git a/plugins/plugins_suite_windows_test.go b/plugins/plugins_suite_windows_test.go deleted file mode 100644 index ed43bdcc3..000000000 --- a/plugins/plugins_suite_windows_test.go +++ /dev/null @@ -1,23 +0,0 @@ -//go:build windows - -package plugins - -import ( - "testing" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// Runs the subset of plugin specs compiled on Windows (files without the -// //go:build !windows tag): capabilities, manager_cache, manager_plugin, -// manifest, package. WASM-runtime-dependent specs live in !windows-tagged -// files and aren't reached here. -func TestPlugins(t *testing.T) { - tests.Init(t, false) - log.SetLevel(log.LevelFatal) - RegisterFailHandler(Fail) - RunSpecs(t, "Plugins Suite") -} diff --git a/plugins/sandbox_fs_test.go b/plugins/sandbox_fs_test.go index ea033283f..0dd9d15ab 100644 --- a/plugins/sandbox_fs_test.go +++ b/plugins/sandbox_fs_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( @@ -134,7 +132,9 @@ var _ = Describe("Plugin filesystem sandbox", Ordered, ContinueOnFailure, func() // Accepted residual, pinned so a future tightening can't happen silently It("still follows a symlink planted in the mount by something else", func() { - Expect(os.Symlink(outsideDir, filepath.Join(libraryDir, "planted"))).To(Succeed()) + if err := os.Symlink(outsideDir, filepath.Join(libraryDir, "planted")); err != nil { + Skip("cannot create symlinks here: " + err.Error()) // Windows without privileges + } out := call(sandboxInput{Operation: "write_file", FilePath: "planted/via-symlink.txt", Content: "escaped"}) diff --git a/plugins/scrobbler_adapter_test.go b/plugins/scrobbler_adapter_test.go index 3efd5d1b1..00c9045a4 100644 --- a/plugins/scrobbler_adapter_test.go +++ b/plugins/scrobbler_adapter_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/sonic_similarity_adapter_test.go b/plugins/sonic_similarity_adapter_test.go index cb08b9acd..e08fc124f 100644 --- a/plugins/sonic_similarity_adapter_test.go +++ b/plugins/sonic_similarity_adapter_test.go @@ -1,5 +1,3 @@ -//go:build !windows - package plugins import ( diff --git a/plugins/testdata_builder_test.go b/plugins/testdata_builder_test.go new file mode 100644 index 000000000..746a8af22 --- /dev/null +++ b/plugins/testdata_builder_test.go @@ -0,0 +1,119 @@ +package plugins + +import ( + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "slices" + "sync" + "time" + + "github.com/navidrome/navidrome/utils" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// buildTestPlugins packages every test plugin under dir, replicating +// `make -C plugins/testdata` without needing make or zip on the PATH. +func buildTestPlugins(dir string) { + start := time.Now() + built, err := buildPackages(dir) + fmt.Fprintf(GinkgoWriter, "[BeforeSuite] built test plugins in %s: %v\n", time.Since(start), built) + Expect(err).ToNot(HaveOccurred(), "failed to build test plugins") +} + +func buildPackages(dir string) ([]string, error) { + mods, err := filepath.Glob(filepath.Join(dir, "*", "go.mod")) + if err != nil || len(mods) == 0 { + return nil, err + } + pdkTime, err := newestModTime(filepath.Join(dir, "..", "pdk", "go")) + if err != nil { + return nil, err + } + + built := make([]string, len(mods)) + errs := make([]error, len(mods)) + build := func(i int) { + pluginDir := filepath.Dir(mods[i]) + var rebuilt bool + if rebuilt, errs[i] = buildPackage(pluginDir, pdkTime); rebuilt { + built[i] = filepath.Base(pluginDir) + } + } + + // The first build populates the wasip1 stdlib and PDK objects every plugin + // shares; fanning out before it lands makes each one compile them again. + build(0) + var wg sync.WaitGroup + for i := range mods[1:] { + wg.Go(func() { build(i + 1) }) + } + wg.Wait() + return slices.DeleteFunc(built, func(name string) bool { return name == "" }), errors.Join(errs...) +} + +func buildPackage(dir string, pdkTime time.Time) (bool, error) { + sourceTime, err := newestModTime(dir) + if err != nil { + return false, err + } + pkg := dir + PackageExtension + if info, err := os.Stat(pkg); err == nil && info.ModTime().After(utils.TimeNewest(sourceTime, pdkTime)) { + return false, nil + } + + wasm, err := filepath.Abs(pkg + ".build.wasm") + if err != nil { + return false, err + } + defer os.Remove(wasm) + // -buildvcs=false keeps the bytes stable across commits, so the suite's + // wazero compilation cache still hits after a rebuild. + cmd := exec.Command("go", "build", "-buildvcs=false", "-buildmode=c-shared", "-o", wasm, ".") + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GOOS=wasip1", "GOARCH=wasm") + if out, err := cmd.CombinedOutput(); err != nil { + return false, fmt.Errorf("building %s: %w\n%s", dir, err, out) + } + + tmp := pkg + ".build.ndp" + defer os.Remove(tmp) + if err := packageFiles(tmp, filepath.Join(dir, manifestFileName), wasm); err != nil { + return false, fmt.Errorf("packaging %s: %w", dir, err) + } + return true, os.Rename(tmp, pkg) +} + +func packageFiles(pkg, manifest, wasm string) error { + m, err := os.Open(manifest) + if err != nil { + return err + } + defer m.Close() + w, err := os.Open(wasm) + if err != nil { + return err + } + defer w.Close() + return writeNdp(pkg, m, w) +} + +func newestModTime(root string) (time.Time, error) { + var newest time.Time + err := filepath.WalkDir(root, func(_ string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + info, err := d.Info() + if err != nil { + return err + } + newest = utils.TimeNewest(newest, info.ModTime()) + return nil + }) + return newest, err +} From 4ed7494a3293a9e9e647897ebfb9be327efd981b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Mon, 31 Aug 2026 23:03:09 -0400 Subject: [PATCH 47/78] ci: report Go test coverage on pull requests (#6061) * ci: report Go test coverage on pull requests Adds octocov to the existing 'Test Go code' job. It reads the coverage profile, posts a PR comment with the coverage percentage and the delta against master, and writes the same report to the job summary. The master-branch report is stored as a GitHub Actions artifact, so no external service or secret is needed. * ci: merge the plugins job coverage into the same report The plugins suite runs in its own job, so its coverage was missing from the report. Both jobs now upload their profile as an artifact and a new 'Report coverage' job merges them into a single PR comment. * ci: update the coverage comment in place instead of reposting octocov's default is to collapse the previous comment and create a new one. updatePrevious edits the existing comment instead, so a PR keeps a single coverage comment across pushes. * ci: fix octocov timeout and step-time lookup Storing the report hit the 30s default timeout: scanning this repo's artifacts for the baseline consumed it first. Raise it to 5m. The step-time lookup also matched the Windows job's 'Test' step and waited for a job that was still running, so execution time was dropped from the report. Rename the step to make it unique. * ci: only store the coverage baseline from the default branch * ci: report statement coverage instead of line coverage octocov reports statement coverage for a single profile but switches to line counting when it merges several itself, which made the number disagree with 'go tool cover -func'. Merge the two job profiles into one file first, so the reported number matches what developers see locally. * ci: stop the download-link comment from clobbering the coverage report Both comments are posted by github-actions[bot], and the download-link job updated the first bot comment it found. On a new PR the coverage comment is created first, so it would be overwritten. Match on the body as well, and keep the coverage profiles out of the download list. --- .github/workflows/download-link-on-pr.yml | 11 ++++-- .github/workflows/pipeline.yml | 47 +++++++++++++++++++++-- .octocov.yml | 33 ++++++++++++++++ 3 files changed, 84 insertions(+), 7 deletions(-) create mode 100644 .octocov.yml diff --git a/.github/workflows/download-link-on-pr.yml b/.github/workflows/download-link-on-pr.yml index 5b421331b..80ec18e90 100644 --- a/.github/workflows/download-link-on-pr.yml +++ b/.github/workflows/download-link-on-pr.yml @@ -34,16 +34,19 @@ jobs: } const {data: {artifacts}} = await github.rest.actions.listWorkflowRunArtifacts({owner, repo, run_id}); - if (!artifacts.length) { + const downloadable = artifacts.filter((art) => !art.name.startsWith('octocov-')); + if (!downloadable.length) { return core.error(`No artifacts found`); } - let body = `Download the artifacts for this pull request:\n`; - for (const art of artifacts) { + const header = `Download the artifacts for this pull request:`; + let body = `${header}\n`; + for (const art of downloadable) { body += `\n* [${art.name}.zip](https://nightly.link/${owner}/${repo}/actions/artifacts/${art.id}.zip)`; } const {data: comments} = await github.rest.issues.listComments({repo, owner, issue_number}); - const existing_comment = comments.find((c) => c.user.login === 'github-actions[bot]'); + // Match on the body too: octocov also comments as github-actions[bot]. + const existing_comment = comments.find((c) => c.user.login === 'github-actions[bot]' && c.body.startsWith(header)); if (existing_comment) { core.info(`Updating comment ${existing_comment.id}`); await github.rest.issues.updateComment({repo, owner, comment_id: existing_comment.id, body}); diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 37b532eb0..7625cf410 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -137,8 +137,10 @@ jobs: - name: Download dependencies run: go mod download - - name: Test - run: go test -shuffle=on -tags netgo,sqlite_fts5 -race -v $(go list ./... | grep -v '/plugins$') + # Name must stay unique across the workflow: octocov matches step names + # by name across every job, and waits for each match to finish. + - name: Test with coverage + run: go test -shuffle=on -tags netgo,sqlite_fts5 -race -v -covermode=atomic -coverprofile=coverage.out $(go list ./... | grep -v '/plugins$') - name: Test ndpgen run: | @@ -147,6 +149,13 @@ jobs: go build -o ndpgen . ./ndpgen --help + - name: Upload coverage profile + uses: actions/upload-artifact@v7 + with: + name: octocov-go + path: coverage.out + if-no-files-found: error + go-plugins: name: Test Go plugins runs-on: ubuntu-latest @@ -169,7 +178,39 @@ jobs: restore-keys: wazero-${{ runner.os }}- - name: Test plugins - run: go tool ginkgo -p -race -tags netgo,sqlite_fts5 ./plugins/ + run: go tool ginkgo -p -race -tags netgo,sqlite_fts5 --cover --covermode=atomic --coverprofile=coverage.out --output-dir=. ./plugins/ + + - name: Upload coverage profile + uses: actions/upload-artifact@v7 + with: + name: octocov-plugins + path: coverage.out + if-no-files-found: error + + coverage: + name: Report coverage + runs-on: ubuntu-latest + needs: [go, go-plugins] + permissions: + contents: read + pull-requests: write + actions: write + steps: + - uses: actions/checkout@v7 + + - uses: actions/download-artifact@v8 + with: + pattern: octocov-* + + # Merge here rather than letting octocov do it: octocov reports statement + # coverage for a single profile, but switches to line counting for several. + - name: Merge coverage profiles + run: | + echo "mode: atomic" > coverage.out + awk 'FNR==1 && /^mode:/ {next} {k=$1" "$2; c[k]+=$3} END {for (k in c) print k, c[k]}' \ + octocov-*/coverage.out | sort >> coverage.out + + - uses: k1LoW/octocov-action@v1 go-windows: name: Test Go code (Windows) diff --git a/.octocov.yml b/.octocov.yml new file mode 100644 index 000000000..397b5b364 --- /dev/null +++ b/.octocov.yml @@ -0,0 +1,33 @@ +# Code coverage reporting for pull requests. See https://github.com/k1LoW/octocov +# The 30s default is not enough: scanning this repo's artifacts for the baseline +# eats most of it, leaving none for the report upload. +timeout: 5m +coverage: + # A single pre-merged profile: octocov reports statements for one path, but + # switches to line counting when it merges several itself. + paths: + - coverage.out +codeToTestRatio: + code: + - '**/*.go' + - '!**/*_test.go' + - '!**/*_gen.go' + test: + - '**/*_test.go' +testExecutionTime: + if: true + steps: + - Test with coverage + - Test plugins +diff: + datastores: + - artifact://${GITHUB_REPOSITORY} +comment: + if: is_pull_request + updatePrevious: true +summary: + if: true +report: + if: is_default_branch + datastores: + - artifact://${GITHUB_REPOSITORY} From 09867e5cc18678cb9ae9579c912dc54a597bf651 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Tue, 1 Sep 2026 07:32:28 -0400 Subject: [PATCH 48/78] ci: comment coverage on pull requests from forks (#6065) * ci: comment coverage on pull requests from forks A pull_request run from a fork gets a read-only GITHUB_TOKEN, so octocov could not post its comment: it logged a 403 and exited 0, leaving the job green and the PR silent. The 'permissions:' block cannot grant what the token does not have. The comment now comes from a workflow_run workflow, which runs on the base repository and does get a write token. The pipeline job keeps the job summary and the default-branch baseline, and hands the merged profile and the PR number to it as an artifact. A workflow_run job otherwise looks like a push to the default branch, so octocov is pointed back at the pull request and at the run that produced the profile via its OCTOCOV_ environment overrides. Without the run id override the test execution time would be read from the wrong run; without the ref override a fork's coverage would be stored as the master baseline. The job holds a write token, so it reads .octocov.yml from the base branch rather than from the fork. * ci: stop checking out the fork in the coverage comment workflow CodeQL flagged the pull request checkout as untrusted code in a privileged context (actions/untrusted-checkout/high): the job holds a write token. The checkout existed only so the code-to-test ratio would reflect the pull request, which does not justify the alert. The workflow now checks out just .octocov.yml from the base branch, and the ratio is skipped when reporting from there. Coverage and its delta against master, the metrics that motivated the report, are unaffected: they come from the profile the pipeline uploads. * ci: treat the coverage artifact as untrusted input A pull_request run executes the fork's own copy of pipeline.yml, so every file in the octocov-pr artifact is attacker-controlled. The artifact was extracted into the workspace root, on top of the base-branch checkout, and download-artifact truncates existing files. A fork could therefore replace .octocov.yml before octocov loaded it. That is not only a config swap. config.Load expands ${VAR} from the job environment and the action sets OCTOCOV_GITHUB_TOKEN, so a crafted comment.message posts the privileged job's token into a public comment; a body: section rewrites a pull request description, which pull-requests: write allows. The artifact now lands in a subdirectory and only coverage.out is copied out, after pr_number is checked to be digits and the named pull request's head is confirmed to be the sha that triggered this run. Without that check the artifact could aim the comment at any open pull request, and unvalidated content reached GITHUB_OUTPUT. --- .github/workflows/coverage-on-pr.yml | 60 ++++++++++++++++++++++++++++ .github/workflows/pipeline.yml | 17 +++++++- .octocov.yml | 7 +++- 3 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/coverage-on-pr.yml diff --git a/.github/workflows/coverage-on-pr.yml b/.github/workflows/coverage-on-pr.yml new file mode 100644 index 000000000..03260cacc --- /dev/null +++ b/.github/workflows/coverage-on-pr.yml @@ -0,0 +1,60 @@ +name: Report coverage on PR +on: + workflow_run: + workflows: ['Pipeline: Test, Lint, Build'] + types: [completed] +jobs: + comment: + name: Comment coverage report + if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + permissions: + contents: read + actions: read + pull-requests: write + env: + COVERAGE_COMMENT: 'true' + steps: + # Only the config, from the base branch: this job holds a write token, so + # it must never check out the fork. + - name: Check out the octocov config + uses: actions/checkout@v7 + with: + sparse-checkout: .octocov.yml + sparse-checkout-cone-mode: false + persist-credentials: false + + # Into a subdirectory. A pull_request run executes the fork's own copy of + # pipeline.yml, so every file in here is attacker-controlled. + - uses: actions/download-artifact@v8 + with: + name: octocov-pr + path: untrusted + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + + - name: Verify the artifact and take the coverage profile + id: pr + env: + GH_TOKEN: ${{ github.token }} + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + run: | + number=$(head -c 20 untrusted/pr_number | tr -d '[:space:]') + case "$number" in ''|*[!0-9]*) + echo "::error::artifact pr_number is not a number"; exit 1;; + esac + sha=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$number" --jq .head.sha) + if [ "$sha" != "$HEAD_SHA" ]; then + echo "::error::artifact claims PR #$number, but its head $sha is not $HEAD_SHA"; exit 1 + fi + cp untrusted/coverage.out coverage.out + echo "number=$number" >> "$GITHUB_OUTPUT" + + - uses: k1LoW/octocov-action@v1 + env: + # A workflow_run job looks like a push to the default branch. Point + # octocov back at the pull request and at the run that produced it. + GITHUB_PULL_REQUEST_NUMBER: ${{ steps.pr.outputs.number }} + OCTOCOV_GITHUB_REF: refs/pull/${{ steps.pr.outputs.number }}/merge + OCTOCOV_GITHUB_SHA: ${{ github.event.workflow_run.head_sha }} + OCTOCOV_GITHUB_RUN_ID: ${{ github.event.workflow_run.id }} diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 7625cf410..aa8e29e49 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -193,8 +193,9 @@ jobs: needs: [go, go-plugins] permissions: contents: read - pull-requests: write actions: write + env: + COVERAGE_COMMENT: 'false' steps: - uses: actions/checkout@v7 @@ -212,6 +213,20 @@ jobs: - uses: k1LoW/octocov-action@v1 + - name: Save the PR number for the comment workflow + if: github.event_name == 'pull_request' + run: echo "${{ github.event.pull_request.number }}" > pr_number + + - name: Upload the merged profile for the comment workflow + if: github.event_name == 'pull_request' + uses: actions/upload-artifact@v7 + with: + name: octocov-pr + path: | + coverage.out + pr_number + if-no-files-found: error + go-windows: name: Test Go code (Windows) runs-on: windows-2022 diff --git a/.octocov.yml b/.octocov.yml index 397b5b364..52d4ff9bf 100644 --- a/.octocov.yml +++ b/.octocov.yml @@ -8,6 +8,9 @@ coverage: paths: - coverage.out codeToTestRatio: + # Needs the pull request's own source, which the comment workflow must not + # check out: it holds a write token. + if: env.COVERAGE_COMMENT != 'true' code: - '**/*.go' - '!**/*_test.go' @@ -23,7 +26,9 @@ diff: datastores: - artifact://${GITHUB_REPOSITORY} comment: - if: is_pull_request + # Only the 'Report coverage on PR' workflow sets this: a pull_request run from + # a fork gets a read-only token, so commenting from here 403s. + if: env.COVERAGE_COMMENT == 'true' updatePrevious: true summary: if: true From 3784fd0ea7c0d902a513777bef9f2c8f015ad42e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Tue, 1 Sep 2026 11:07:58 -0400 Subject: [PATCH 49/78] fix(artwork): honor a provider's explicit retry-later delay in the circuit breaker (#6056) An explicit RetryLaterError now opens the agent's breaker immediately for the provider's own delay, instead of counting it as one generic failure that needs five to open and then always probes after a fixed minute. --- core/artwork/gate.go | 19 ++++++++++++++++++- core/artwork/gate_test.go | 13 +++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/core/artwork/gate.go b/core/artwork/gate.go index ebb65bcbe..aa2ad8f2f 100644 --- a/core/artwork/gate.go +++ b/core/artwork/gate.go @@ -1,6 +1,7 @@ package artwork import ( + "cmp" "context" "errors" "io" @@ -95,6 +96,9 @@ type breaker struct { // generation identifies the current open episode, so an answer from a call admitted before // the breaker opened cannot be mistaken for evidence that it has recovered. generation int + // probeAfter overrides the probe delay for the current episode when a provider named its own + // back-off; zero falls back to breakerProbeAfter. + probeAfter time.Duration } func newBreaker() *breaker { return &breaker{} } @@ -107,7 +111,7 @@ func (b *breaker) allow() (bool, int) { if b.failures < breakerThreshold { return true, 0 } - if time.Since(b.openedAt) >= breakerProbeAfter { + if time.Since(b.openedAt) >= cmp.Or(b.probeAfter, breakerProbeAfter) { b.openedAt = time.Now() // start a fresh probe window so only one caller passes return true, b.generation } @@ -121,11 +125,24 @@ func (b *breaker) record(name string, gen int, err error) { } b.mu.Lock() defer b.mu.Unlock() + // An explicit back-off is a definitive "stop for this long", so it opens the breaker at once + // with the provider's own delay instead of waiting for the failure threshold. + if retry, ok := errors.AsType[*agents.RetryLaterError](err); ok && retry.RetryIn > 0 { + b.recoveries = 0 + b.failures = breakerThreshold + b.openedAt = time.Now() + b.probeAfter = retry.RetryIn + b.generation++ + log.Warn("Artwork: Circuit breaker opened for agent, provider asked to back off", "agent", name, + "probeAfter", retry.RetryIn) + return + } if isTransientExternal(err) { b.recoveries = 0 b.failures++ if b.failures == breakerThreshold { b.openedAt = time.Now() + b.probeAfter = 0 b.generation++ log.Warn("Artwork: Circuit breaker opened for agent", "agent", name, "consecutiveFailures", b.failures, "probeAfter", breakerProbeAfter, err) diff --git a/core/artwork/gate_test.go b/core/artwork/gate_test.go index abe723508..a954d4cac 100644 --- a/core/artwork/gate_test.go +++ b/core/artwork/gate_test.go @@ -2,7 +2,9 @@ package artwork import ( "errors" + "time" + "github.com/navidrome/navidrome/core/agents" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -40,4 +42,15 @@ var _ = Describe("breaker", func() { Expect(allowed(b)).To(BeFalse(), "answers from calls admitted before the breaker opened must not close it") }) + + It("opens at once when a provider asks to retry later, honoring its delay", func() { + b := newBreaker() + Expect(allowed(b)).To(BeTrue(), "starts closed") + + // A single explicit back-off opens the breaker without reaching the failure threshold. + b.record("agentA", 0, &agents.RetryLaterError{RetryIn: 5 * time.Second}) + + Expect(allowed(b)).To(BeFalse(), "an explicit back-off opens the breaker immediately") + Expect(b.probeAfter).To(Equal(5*time.Second), "the provider's delay drives the probe interval") + }) }) From 88cd1c39374783e30ac7b5bcd9401789243a6cd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Tue, 1 Sep 2026 17:17:52 -0400 Subject: [PATCH 50/78] fix(deezer): treat an exhausted quota as a throttle, not as a missing artist (#6068) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(deezer): treat an exhausted quota as a throttle, not as a missing artist Deezer reports quota exhaustion in the response body, with HTTP 200 and no rate-limit headers. The client only looked for errors when the status was not 200, so a throttled reply was decoded into an empty result type, and an empty search became ErrNotFound. The agent then compounded it: it tested `errors.Is(err, ErrNotFound) || len(artists) == 0` before testing err, so any failed search — which also returns no artists — reported not-found too. The artwork worker settles an entity as "no image" on agents.ErrNotFound. So being throttled did not make Navidrome back off; it made it record the artist as having no artwork, and move on to do the same to the next one. Errors are now parsed out of the body regardless of status, and the quota code is joined with agents.RetryLaterError so the circuit breaker and the artwork retry budget see a throttle for what it is. The agent checks err before the empty-result case. Last.fm already handles this exact shape (client.go errCodeRateLimit, with a comment noting the 200-with-body-error pattern); this brings Deezer in line with it, including the zero-delay RetryLaterError so both providers share the default cooldown rather than a per-provider number. Measured against the live API to pin the shape: a 120-request burst returned 54 results and 66 quota replies, every one of them HTTP 200 with {"error":{"type":"Exception","message":"Quota limit exceeded","code":4}} and no Retry-After or rate-limit headers. A single request 5s later succeeded, so the window is short and a cooldown fully clears it. * refactor(deezer): fold the error envelope into one type The envelope declared the code and message inline, parseBodyError copied them field by field into a second struct with the same shape, and a zero Code stood in for "no error reported". Making the envelope hold a pointer to the error type removes all three: absent is nil, present is the error itself, and the value returned needs no conversion. searchArtist loses its empty-result branch. searchArtists converts an empty result to errNotFound and returns early on any error, so it never answers with no artists and no error, and the branch could not run. What it left behind was a comment explaining an ordering that only mattered while the branch existed. ErrNotFound is unexported: nothing outside this package referenced it, and it sat three lines from agents.ErrNotFound, which is a different error with the opposite meaning for callers. Throttling now joins agents.ErrRetryLater, the sentinel documented as the zero-delay RetryLaterError, rather than allocating an equivalent value. * refactor(deezer): return agents.ErrNotFound from the client The client raised a package-local sentinel that the agent then translated into agents.ErrNotFound, one call site each. Deezer was the only adapter carrying its own: last.fm and listenbrainz have none. The client already reports throttling with agents.ErrRetryLater, so it already speaks the agent vocabulary; saying "not found" in the same words costs nothing and lets searchArtist drop to plain error propagation. * test(scrobbler): remove a race in the longest-server-delay test newBufferedScrobbler starts its drain goroutine, and run() drains once before it ever waits on the wake signal. The test enqueued user2, then enqueued user1 via Scrobble, so that startup drain could land between the two: it saw only user2, took its 45s delay, and set backingOff. The wake from the second enqueue is then deliberately ignored — a wake during a backoff window must not drain, which is the hammering the window exists to prevent — so user1 was never attempted and the first assertion read 1 instead of 2. Buffering both users before the goroutine exists removes the window. The test no longer goes through Scrobble, which the sibling tests already cover; what this one is about is which delay wins. Reproduced deterministically by forcing the interleaving with a synctest.Wait between the two enqueues, which fails with the same "expected both users drained, got 1 attempts" seen in CI. With both enqueued first, that same forced drain passes. --- adapters/deezer/client.go | 44 +++++++++++++++++------ adapters/deezer/client_test.go | 34 +++++++++++++++++- adapters/deezer/deezer.go | 3 -- adapters/deezer/deezer_test.go | 17 +++++++++ adapters/deezer/responses.go | 8 ++--- adapters/deezer/responses_test.go | 2 +- core/scrobbler/buffered_scrobbler_test.go | 7 ++-- 7 files changed, 90 insertions(+), 25 deletions(-) diff --git a/adapters/deezer/client.go b/adapters/deezer/client.go index d51f65dd9..03f37af19 100644 --- a/adapters/deezer/client.go +++ b/adapters/deezer/client.go @@ -13,15 +13,26 @@ import ( "strings" "github.com/microcosm-cc/bluemonday" + "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/log" ) const apiBaseURL = "https://api.deezer.com" const authBaseURL = "https://auth.deezer.com" -var ( - ErrNotFound = errors.New("deezer: not found") -) +// errCodeQuota is Deezer's "Quota limit exceeded"; it arrives in the body, with HTTP 200 +// and no rate-limit headers, so the body code is the only signal. +const errCodeQuota = 4 + +type deezerError struct { + Type string `json:"type"` + Message string `json:"message"` + Code int `json:"code"` +} + +func (e *deezerError) Error() string { + return fmt.Sprintf("deezer error(%d): %s", e.Code, e.Message) +} type httpDoer interface { Do(req *http.Request) (*http.Response, error) @@ -56,7 +67,7 @@ func (c *client) searchArtists(ctx context.Context, name string, limit int) ([]A } if len(results.Data) == 0 { - return nil, ErrNotFound + return nil, agents.ErrNotFound } return results.Data, nil } @@ -74,20 +85,31 @@ func (c *client) makeRequest(req *http.Request, response any) error { return err } + // Checked before the status: a throttled request still answers 200, and decoding its body + // into a result type yields an empty one, which reads as "nothing found". + if err := parseBodyError(data); err != nil { + return err + } if resp.StatusCode != 200 { - return c.parseError(data) + return fmt.Errorf("deezer http status: (%d)", resp.StatusCode) } return json.Unmarshal(data, response) } -func (c *client) parseError(data []byte) error { - var deezerError Error - err := json.Unmarshal(data, &deezerError) - if err != nil { - return err +// parseBodyError returns the error Deezer reported in the body, or nil when it reported none. +func parseBodyError(data []byte) error { + var body errorResponse + // Discarded: a payload that is not an error object leaves Error nil, which is the "none" answer. + _ = json.Unmarshal(data, &body) + switch { + case body.Error == nil: + return nil + case body.Error.Code == errCodeQuota: + return errors.Join(body.Error, agents.ErrRetryLater) + default: + return body.Error } - return fmt.Errorf("deezer error(%d): %s", deezerError.Error.Code, deezerError.Error.Message) } func (c *client) getRelatedArtists(ctx context.Context, artistID int) ([]Artist, error) { diff --git a/adapters/deezer/client_test.go b/adapters/deezer/client_test.go index 9fa7afdd9..84d981a76 100644 --- a/adapters/deezer/client_test.go +++ b/adapters/deezer/client_test.go @@ -2,12 +2,14 @@ package deezer import ( "bytes" + "errors" "fmt" "io" "net/http" "os" "time" + "github.com/navidrome/navidrome/core/agents" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -41,7 +43,37 @@ var _ = Describe("client", func() { }) _, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20) - Expect(err).To(MatchError(ErrNotFound)) + Expect(err).To(MatchError(agents.ErrNotFound)) + }) + + // Deezer answers 200 with no rate-limit headers when throttling, so this body is the only signal. + It("reports an exhausted quota as a retryable error, not as a missing artist", func() { + httpClient.mock("https://api.deezer.com/search/artist", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString( + `{"error":{"type":"Exception","message":"Quota limit exceeded","code":4}}`)), + }) + + _, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20) + Expect(err).To(HaveOccurred()) + Expect(err).ToNot(MatchError(agents.ErrNotFound), + "a throttled lookup would otherwise settle the artist as having no image") + Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) + Expect(err.Error()).To(ContainSubstring("Quota limit exceeded")) + }) + + It("reports a non-quota body error as a plain error", func() { + httpClient.mock("https://api.deezer.com/search/artist", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString( + `{"error":{"type":"Exception","message":"Invalid query","code":100}}`)), + }) + + _, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20) + Expect(err).To(HaveOccurred()) + Expect(err).ToNot(MatchError(agents.ErrNotFound)) + Expect(errors.Is(err, agents.ErrRetryLater)).To(BeFalse(), + "only a throttle asks the caller to come back later") }) }) diff --git a/adapters/deezer/deezer.go b/adapters/deezer/deezer.go index 1fa10e25c..d3570a29f 100644 --- a/adapters/deezer/deezer.go +++ b/adapters/deezer/deezer.go @@ -91,9 +91,6 @@ func isPlaceholderPicture(url string) bool { 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 { - return nil, agents.ErrNotFound - } if err != nil { return nil, err } diff --git a/adapters/deezer/deezer_test.go b/adapters/deezer/deezer_test.go index 360db1f13..82d02c244 100644 --- a/adapters/deezer/deezer_test.go +++ b/adapters/deezer/deezer_test.go @@ -3,6 +3,7 @@ package deezer import ( "bytes" "context" + "errors" "fmt" "io" "net/http" @@ -80,6 +81,22 @@ var _ = Describe("deezerAgent", func() { Expect(artist.ID).To(Equal(2)) }) + // The artwork worker settles an artist as "no image" on agents.ErrNotFound, so a throttled + // lookup reaching that here would record a permanent absence. + It("surfaces an exhausted quota instead of reporting the artist as not found", func() { + httpClient.mock("https://api.deezer.com/search/artist", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString( + `{"error":{"type":"Exception","message":"Quota limit exceeded","code":4}}`)), + }) + + _, err := agent.searchArtist(ctx, "Queen") + + Expect(err).To(HaveOccurred()) + Expect(err).ToNot(MatchError(agents.ErrNotFound)) + Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) + }) + It("returns ErrNotFound when no result matches the name exactly", func() { httpClient.mock("https://api.deezer.com/search/artist", http.Response{ StatusCode: 200, diff --git a/adapters/deezer/responses.go b/adapters/deezer/responses.go index 266c44c62..6cc95dd4b 100644 --- a/adapters/deezer/responses.go +++ b/adapters/deezer/responses.go @@ -22,12 +22,8 @@ type Artist struct { Type string `json:"type"` } -type Error struct { - Error struct { - Type string `json:"type"` - Message string `json:"message"` - Code int `json:"code"` - } `json:"error"` +type errorResponse struct { + Error *deezerError `json:"error"` } type RelatedArtists struct { diff --git a/adapters/deezer/responses_test.go b/adapters/deezer/responses_test.go index a9de5c5fb..5a3fc7798 100644 --- a/adapters/deezer/responses_test.go +++ b/adapters/deezer/responses_test.go @@ -26,7 +26,7 @@ var _ = Describe("Responses", func() { Describe("Error", func() { It("parses the error response correctly", func() { - var errorResp Error + var errorResp errorResponse body := []byte(`{"error":{"type":"MissingParameterException","message":"Missing parameters: q","code":501}}`) err := json.Unmarshal(body, &errorResp) Expect(err).To(BeNil()) diff --git a/core/scrobbler/buffered_scrobbler_test.go b/core/scrobbler/buffered_scrobbler_test.go index fd972e87d..16172194b 100644 --- a/core/scrobbler/buffered_scrobbler_test.go +++ b/core/scrobbler/buffered_scrobbler_test.go @@ -251,12 +251,13 @@ func TestBufferedScrobblerTakesTheLongestServerDelayAcrossUsers(t *testing.T) { "user1": 10 * time.Second, "user2": 45 * time.Second, }} + // Both are buffered before the drain goroutine exists: it drains once on startup, and + // seeing only one user there would park it on that user's delay, ignoring the other. + _ = buffer.Enqueue("test", "user1", "1", time.Now()) + _ = buffer.Enqueue("test", "user2", "2", time.Now()) bs := newBufferedScrobbler(ds, scr, "test") defer bs.Stop() - // user2 is enqueued directly so both are buffered before the first drain wakes. - _ = buffer.Enqueue("test", "user2", "2", time.Now()) - _ = bs.Scrobble(context.Background(), "user1", Scrobble{MediaFile: model.MediaFile{ID: "1"}, TimeStamp: time.Now()}) synctest.Wait() if got := scr.count.Load(); got != 2 { t.Fatalf("expected both users drained, got %d attempts", got) From b77fb45088e90c9530a17acfc34d30f1670c0314 Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 1 Sep 2026 20:32:25 -0400 Subject: [PATCH 51/78] ci: exclude test helpers and generated code from the coverage report The coverage profile counted the tests/ package and the *_gen.go files, none of which are code under test: tests/ is the mock and helper package, and generated code is never hand-tested. Together they added 2926 uncounted statements at 0%, pulling the reported number down by almost 6 points (70.13% -> 75.98% on the current master profile). octocov's coverage.exclude takes doublestar globs matched against git-root-relative paths. All 26 mock_*.go files live under tests/, so the single 'tests/**' pattern covers them. --- .octocov.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.octocov.yml b/.octocov.yml index 52d4ff9bf..467998b51 100644 --- a/.octocov.yml +++ b/.octocov.yml @@ -7,6 +7,10 @@ coverage: # switches to line counting when it merges several itself. paths: - coverage.out + # Not code under test: tests/ holds the mocks and helpers, *_gen.go is generated. + exclude: + - 'tests/**' + - '**/*_gen.go' codeToTestRatio: # Needs the pull request's own source, which the comment workflow must not # check out: it holds a write token. From 47bc3c00f3a526ba4879307a6026004c241c83d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Tue, 1 Sep 2026 20:48:41 -0400 Subject: [PATCH 52/78] fix(artwork): never retry absent artwork on its own (#6054) An absent artwork state was revisited by an hourly job, by viewing the entity, and by the startup backfill on any artwork config change. On a large library the last one queued tens of thousands of external lookups at once and got the provider to rate-limit us for hours. Nothing revisits an absent state now. Retrying is explicit: `artwork reprocess` on the CLI, or the refresh button in the UI. The config fingerprint survives only as an advisory, warning at startup and naming the command that clears it. Since absent is terminal, `artwork status` splits it into two disjoint columns, and `--source failed` targets only the ones that gave up rather than being answered. Both read through the filter CountBySource and EnqueueBySource already share, so the reported number is the set the command acts on. Also fixes the last_failure default left by 20260819204637, which marked every pre-existing absent row as failed, and removes the code the deleted retry paths orphaned. --- cmd/artwork.go | 123 +++++---- cmd/artwork_test.go | 102 ++++--- cmd/root.go | 30 +-- consts/consts.go | 9 +- core/artwork/artwork.go | 9 +- core/artwork/artwork_test.go | 17 +- core/artwork/housekeeping.go | 125 +++------ core/artwork/housekeeping_test.go | 248 ++---------------- core/artwork/worker.go | 25 +- core/artwork/worker_test.go | 30 ++- ...1225726_normalize_artwork_last_failure.sql | 9 + model/album.go | 1 - model/artist.go | 1 - model/artwork.go | 33 ++- model/mediafile.go | 2 - model/playlist.go | 1 - model/radio.go | 1 - persistence/album_repository.go | 6 +- persistence/album_repository_test.go | 4 +- persistence/artist_repository.go | 6 +- persistence/artist_repository_test.go | 4 +- persistence/artwork_hydration_test.go | 2 +- persistence/artwork_queue_repository.go | 35 +-- persistence/artwork_queue_repository_test.go | 83 +++--- persistence/mediafile_repository.go | 6 +- persistence/playlist_repository.go | 6 +- persistence/playlist_repository_test.go | 4 +- persistence/radio_repository.go | 8 - persistence/radio_repository_test.go | 12 - tests/mock_album_repo.go | 9 - tests/mock_artist_repo.go | 9 - tests/mock_artwork_queue_repo.go | 73 +----- tests/mock_mediafile_repo.go | 8 - tests/mock_playlist_repo.go | 9 - tests/mock_radio_repository.go | 9 - 35 files changed, 341 insertions(+), 718 deletions(-) create mode 100644 db/migrations/20260901225726_normalize_artwork_last_failure.sql diff --git a/cmd/artwork.go b/cmd/artwork.go index 5cd4fc146..9a64cd8c3 100644 --- a/cmd/artwork.go +++ b/cmd/artwork.go @@ -42,9 +42,10 @@ func init() { "stored trace of the last resolution; also initializes plugin agents, which may open "+ "external connections") artworkReprocessCmd.Flags().StringSliceVar(&artworkKinds, "kind", nil, - "kinds to reprocess ("+kindPrefixes(artwork.RecheckKinds)+"); repeatable") + "kinds to reprocess ("+kindPrefixes(artwork.ReprocessKinds)+"); repeatable") artworkReprocessCmd.Flags().StringSliceVar(&artworkSources, "source", nil, - "only items currently resolved from these sources (e.g. folder, external:deezer, absent)") + "only items currently resolved from these sources (e.g. folder, external:deezer, absent, "+ + "or failed for the absent ones that gave up)") artworkReprocessCmd.Flags().BoolVar(&artworkAll, "all", false, "reprocess every kind") artworkReprocessCmd.Flags().BoolVar(&artworkDryRun, "dry-run", false, "report what would be queued and exit without queueing") @@ -113,7 +114,7 @@ var artworkCancelCmd = &cobra.Command{ "Work already picked up is not interrupted, and an item with no artwork yet can be\n" + "queued again by the hourly re-check. The selection is applied again when you confirm,\n" + "so anything queued after the preview is cancelled too. Use it to call off a bulk\n" + - "backfill, not to stop the worker.", + "reprocess, not to stop the worker.", Args: cobra.NoArgs, Run: func(cmd *cobra.Command, args []string) { runCancel(cmd.Context()) @@ -122,7 +123,7 @@ var artworkCancelCmd = &cobra.Command{ var artworkStatusCmd = &cobra.Command{ Use: "status", - Short: "Report the artwork queue, where artwork resolves from, and the backfill state", + Short: "Report the artwork queue, where artwork resolves from, and the config state", Args: cobra.NoArgs, Run: func(cmd *cobra.Command, args []string) { runStatus(cmd.Context()) @@ -146,9 +147,11 @@ type sourceCount struct { count int64 } +// absentCount partitions a kind's absent states: noImage was answered, failed gave up. type absentCount struct { - kind model.Kind - model.ArtworkAbsentStat + kind model.Kind + noImage int64 + failed int64 } type statusReport struct { @@ -170,16 +173,6 @@ func queueTotal(stats []model.ArtworkQueueStat) int64 { return n } -func (r statusReport) backfillQueued() int64 { - var n int64 - for _, s := range r.queue { - if s.Priority == model.ArtworkPriorityBackfill { - n += s.Count - } - } - return n -} - func collectStatus(ctx context.Context, ds model.DataStore) (statusReport, error) { q := ds.ArtworkQueue(ctx) var rep statusReport @@ -188,8 +181,7 @@ func collectStatus(ctx context.Context, ds model.DataStore) (statusReport, error return rep, fmt.Errorf("breaking the artwork queue down by kind: %w", err) } - cutoff := time.Now().Add(-artwork.StaleAbsentAge) - for _, k := range artwork.RecheckKinds { + for _, k := range artwork.ReprocessKinds { sources, err := q.SourcesInUse(k) if err != nil { return rep, fmt.Errorf("listing the sources in use by %s artwork: %w", k, err) @@ -201,12 +193,15 @@ func collectStatus(ctx context.Context, ds model.DataStore) (statusReport, error return rep, fmt.Errorf("counting %s artwork resolved from %s: %w", k, displaySource(s), err) } rep.sources = append(rep.sources, sourceCount{kind: k, source: s, count: n}) + // An absent state is exactly a row with no source, so it needs no second query. + if s == "" { + failed, err := q.CountBySource(k, []string{model.ArtworkSourceFailed}) + if err != nil { + return rep, fmt.Errorf("counting failed %s artwork: %w", k, err) + } + rep.absent = append(rep.absent, absentCount{kind: k, noImage: n - failed, failed: failed}) + } } - stat, err := q.CountAbsent(k, cutoff) - if err != nil { - return rep, fmt.Errorf("counting absent %s artwork: %w", k, err) - } - rep.absent = append(rep.absent, absentCount{kind: k, ArtworkAbsentStat: stat}) } rep.current, rep.inputs = artwork.ConfigFingerprint(), artwork.FingerprintInputs() @@ -234,19 +229,20 @@ func formatStatus(rep statusReport) string { } fmt.Fprintln(w, "\nAbsent (resolved, no image found)") - fmt.Fprintln(w, " KIND\tABSENT\tDUE FOR RECHECK") + fmt.Fprintln(w, " KIND\tNO IMAGE\tFAILED") for _, a := range rep.absent { - fmt.Fprintf(w, " %s\t%d\t%d\n", a.kind, a.Total, a.Stale) + fmt.Fprintf(w, " %s\t%d\t%d\n", a.kind, a.noImage, a.failed) } - fmt.Fprintf(w, " (eligible once the last attempt is older than %gh; re-queued %d per kind per hour, oldest first)\n", - artwork.StaleAbsentAge.Hours(), artwork.StaleAbsentRecheckBatch) + fmt.Fprintln(w, " (nothing retries these; 'artwork reprocess --source absent' retries both columns)") + fmt.Fprintln(w, " (failed = gave up rather than being answered, so the ones most likely to resolve;\n"+ + " 'artwork reprocess --source failed' retries just those)") - fmt.Fprintln(w, "\nBackfill") - fmt.Fprintf(w, " State:\t%s\n", backfillState(rep)) + fmt.Fprintln(w, "\nConfig") + fmt.Fprintf(w, " State:\t%s\n", configState(rep)) fmt.Fprintf(w, " Stored fingerprint:\t%s\n", cmp.Or(rep.stored, "(none)")) fmt.Fprintf(w, " Current fingerprint:\t%s\n", rep.current) if len(rep.inputs) > 0 { - fmt.Fprintln(w, " Fingerprint inputs (changing any of these re-resolves the whole library):") + fmt.Fprintln(w, " Fingerprint inputs (changing any of these makes the stored artwork stale):") for _, in := range rep.inputs { fmt.Fprintf(w, " %s:\t%s\n", in.Name, in.Value) } @@ -256,18 +252,10 @@ func formatStatus(rep statusReport) string { return sb.String() } -// backfillState leads with the queued backlog: by the time anyone runs this, backfill has usually -// already stored the new fingerprint, and "up to date" would bury the flood it is still working through. -func backfillState(rep statusReport) string { - pending := "fingerprint changed — every artist, album, playlist and radio will be re-enqueued on the next startup" - if n := rep.backfillQueued(); n > 0 { - if rep.stored != rep.current { - return fmt.Sprintf("backfill running: %d items queued, and %s", n, pending) - } - return fmt.Sprintf("backfill running: %d items queued (fingerprint up to date)", n) - } +func configState(rep statusReport) string { if rep.stored != rep.current { - return pending + return "fingerprint changed — stored artwork keeps the old resolution; " + + "run 'artwork reprocess --all' to apply it" } return "up to date" } @@ -297,8 +285,8 @@ type artworkPriority struct { var knownPriorities = []artworkPriority{ {"bump", model.ArtworkPriorityBump}, {"scan", model.ArtworkPriorityScan}, - {"backfill", model.ArtworkPriorityBackfill}, {"recheck", model.ArtworkPriorityRecheck}, + {"backfill", model.ArtworkPriorityBackfill}, } // priorityName falls back to the number: a row written by a newer version still has to print. @@ -351,29 +339,41 @@ func runReprocess(ctx context.Context) { func selectedKinds(kinds, sources []string, all bool) ([]model.Kind, error) { // A source filter on its own is already a complete selection, so it does not also need a kind. if all || (len(kinds) == 0 && len(sources) > 0) { - return artwork.RecheckKinds, nil + return artwork.ReprocessKinds, nil } if len(kinds) == 0 { return nil, fmt.Errorf("no selector given: pass --kind, --source or --all") } return parseAll(kinds, func(s string) (model.Kind, error) { - return parseArtworkKind(s, artwork.RecheckKinds) + return parseArtworkKind(s, artwork.ReprocessKinds) }) } -// absentSource is how the stored empty source — resolved, no image — is spelled on the CLI. -const absentSource = "absent" +// absentSource is how the stored empty source — resolved, no image — is spelled on the CLI, and +// failedSource the subset of it that gave up rather than being answered. +const ( + absentSource = "absent" + failedSource = "failed" +) func repositorySources(sources []string) []string { return slice.Map(sources, func(s string) string { - if s == absentSource { + switch s { + case absentSource: return "" + case failedSource: + return model.ArtworkSourceFailed } return s }) } -func displaySource(s string) string { return cmp.Or(s, absentSource) } +func displaySource(s string) string { + if s == model.ArtworkSourceFailed { + return failedSource + } + return cmp.Or(s, absentSource) +} type confirmFunc func(out io.Writer, total, external int64) bool @@ -447,7 +447,7 @@ func validateSources(q model.ArtworkQueueRepository, sources []string) error { return nil } var inUse []string - for _, k := range artwork.RecheckKinds { + for _, k := range artwork.ReprocessKinds { found, err := q.SourcesInUse(k) if err != nil { return fmt.Errorf("listing the sources in use by %s artwork: %w", k, err) @@ -456,14 +456,16 @@ func validateSources(q model.ArtworkQueueRepository, sources []string) error { } var unknown []string for _, s := range sources { - if s != "" && !slices.Contains(inUse, s) { // the reserved absent source is valid even when nothing is absent + // The reserved absent and failed sources are valid even when nothing currently matches them. + if s != "" && s != model.ArtworkSourceFailed && !slices.Contains(inUse, s) { unknown = append(unknown, displaySource(s)) } } if len(unknown) == 0 { return nil } - valid := slice.Map(inUse, displaySource) + // failed is accepted but never stored, so listing only what is in use would hide it. + valid := append(slice.Map(inUse, displaySource), failedSource) slices.Sort(valid) return fmt.Errorf("no artwork resolves from %s; sources in use: %s", strings.Join(unknown, ", "), cmp.Or(strings.Join(valid, ", "), "(none)")) @@ -478,6 +480,18 @@ func reprocessArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kin return err } + // Derived from what actually drives the queries, so a filter added to this signature cannot + // silently keep stamping the fingerprint for a partial run. + markApplied := func() error { + if len(sources) > 0 || len(kinds) < len(artwork.ReprocessKinds) { + return nil + } + if err := artwork.MarkConfigApplied(ctx, ds); err != nil { + return fmt.Errorf("recording the applied artwork config: %w", err) + } + return nil + } + matched := make([]int64, len(kinds)) var total, external int64 for i, k := range kinds { @@ -496,8 +510,9 @@ func reprocessArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kin fmt.Fprintln(out, "\nDry run: nothing was queued.") return nil case total == 0: + // An empty match set still leaves nothing resolved under the old config. fmt.Fprintln(out, "Nothing was queued.") - return nil + return markApplied() case !confirm(out, total, external): fmt.Fprintln(out, "Aborted: nothing was queued.") return nil @@ -519,7 +534,7 @@ func reprocessArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kin if skipped := total - queued; skipped > 0 { fmt.Fprintf(out, "Already queued, left unchanged: %d (priority and retry backoff untouched).\n", skipped) } - return nil + return markApplied() } func runCancel(ctx context.Context) { @@ -546,7 +561,7 @@ func cancelSelection(kinds, priorities []string, all bool) ([]model.Kind, []int, if len(kinds) == 0 && len(priorities) == 0 { return nil, nil, fmt.Errorf("no selector given: pass --kind, --priority or --all") } - // RefreshableKinds, not RecheckKinds: media files are queued, so --kind must reach them. + // RefreshableKinds, not ReprocessKinds: media files are queued, so --kind must reach them. outKinds, err := parseAll(kinds, func(s string) (model.Kind, error) { return parseArtworkKind(s, artwork.RefreshableKinds) }) diff --git a/cmd/artwork_test.go b/cmd/artwork_test.go index a7220d2d5..71b450914 100644 --- a/cmd/artwork_test.go +++ b/cmd/artwork_test.go @@ -3,7 +3,6 @@ package cmd import ( "context" "errors" - "fmt" "io" "strings" "time" @@ -20,20 +19,20 @@ import ( var _ = Describe("parseArtworkKind", func() { It("accepts a supported kind", func() { - k, err := parseArtworkKind("ar", artwork.RecheckKinds) + k, err := parseArtworkKind("ar", artwork.ReprocessKinds) Expect(err).ToNot(HaveOccurred()) Expect(k).To(Equal(model.KindArtistArtwork)) }) It("rejects an unknown kind and lists the valid ones", func() { - _, err := parseArtworkKind("zz", artwork.RecheckKinds) + _, err := parseArtworkKind("zz", artwork.ReprocessKinds) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("ar")) Expect(err.Error()).To(ContainSubstring("al")) }) It("rejects a known kind the command does not accept", func() { - _, err := parseArtworkKind("mf", artwork.RecheckKinds) + _, err := parseArtworkKind("mf", artwork.ReprocessKinds) Expect(err).To(HaveOccurred()) }) @@ -442,13 +441,13 @@ var _ = Describe("artwork reprocess selection", func() { It("returns every kind for --all", func() { ks, err := selectedKinds(nil, nil, true) Expect(err).ToNot(HaveOccurred()) - Expect(ks).To(ConsistOf(artwork.RecheckKinds)) + Expect(ks).To(ConsistOf(artwork.ReprocessKinds)) }) It("returns every kind for a source filter given without a kind", func() { ks, err := selectedKinds(nil, []string{"folder"}, false) Expect(err).ToNot(HaveOccurred()) - Expect(ks).To(ConsistOf(artwork.RecheckKinds), "--source alone is already a complete selection") + Expect(ks).To(ConsistOf(artwork.ReprocessKinds), "--source alone is already a complete selection") }) It("returns only the named kinds", func() { @@ -512,6 +511,11 @@ var _ = Describe("repositorySources", func() { Expect(repositorySources([]string{"absent", "folder"})).To(Equal([]string{"", "folder"})) }) + It("maps the failed name onto the pseudo-source, and back for display", func() { + Expect(repositorySources([]string{failedSource})).To(Equal([]string{model.ArtworkSourceFailed})) + Expect(displaySource(model.ArtworkSourceFailed)).To(Equal(failedSource)) + }) + It("keeps an empty selection empty, meaning every source", func() { Expect(repositorySources(nil)).To(BeEmpty()) }) @@ -607,6 +611,25 @@ var _ = Describe("reprocessArtwork", func() { Expect(queue.Count()).To(BeZero()) }) + DescribeTable("records the applied config only for a run that leaves nothing on the old one", + func(selected []model.Kind, sources []string, dryRun, applied bool) { + Expect(ds.Property(ctx).Put(consts.ArtConfFingerprintPropertyKey, "stale-fingerprint")).To(Succeed()) + + Expect(reprocessArtwork(ctx, ds, selected, sources, imageAgents, dryRun, accept, &out)).To(Succeed()) + + want := "stale-fingerprint" + if applied { + want = artwork.ConfigFingerprint() + } + Expect(ds.Property(ctx).Get(consts.ArtConfFingerprintPropertyKey)).To(Equal(want)) + }, + Entry("every kind, unfiltered", artwork.ReprocessKinds, nil, false, true), + Entry("every kind, but nothing matched", artwork.ReprocessKinds, []string{}, false, true), + Entry("filtered by source", artwork.ReprocessKinds, []string{"external:deezer"}, false, false), + Entry("a subset of kinds", []model.Kind{model.KindAlbumArtwork}, nil, false, false), + Entry("a dry run applies nothing", artwork.ReprocessKinds, nil, true, false), + ) + It("queues the matching items at recheck priority, leaving their artwork state alone", func() { Expect(reprocessArtwork(ctx, ds, kinds, []string{"external:deezer"}, imageAgents, false, accept, &out)).To(Succeed()) @@ -760,6 +783,14 @@ var _ = Describe("reprocessArtwork", func() { imageAgents, true, accept, &out)).ToNot(Succeed(), "a typo must still be rejected") }) + It("names failed among the valid sources when rejecting a typo", func() { + err := reprocessArtwork(ctx, ds, kinds, []string{"faild"}, imageAgents, true, accept, &out) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed"), + "failed is accepted but never stored, so it has to be named explicitly") + }) + It("accepts a source another kind uses, letting the empty selection report itself", func() { Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindArtistArtwork}, []string{"folder"}, imageAgents, false, decline, &out)).To(Succeed()) @@ -792,14 +823,16 @@ var _ = Describe("collectStatus", func() { ImageType: model.ImageTypePrimary, Source: source, Hash: hash, AttemptedAt: attempted})).To(Succeed()) } put(model.KindArtistArtwork, "ar-1", "external:deezer", "h1", time.Now()) - put(model.KindArtistArtwork, "ar-2", "", "", time.Now().Add(-artwork.StaleAbsentAge-time.Hour)) - put(model.KindArtistArtwork, "ar-3", "", "", time.Now()) + put(model.KindArtistArtwork, "ar-2", "", "", time.Now().Add(-24*time.Hour)) + // ar-3 is absent because it gave up, so the two absent artists split across the columns. + Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "ar-3", + ImageType: model.ImageTypePrimary, LastFailure: "[]", AttemptedAt: time.Now()})).To(Succeed()) put(model.KindAlbumArtwork, "al-1", "folder", "h2", time.Now()) Expect(queue.Enqueue(model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-9", ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBackfill})).To(Succeed()) }) - It("reports the queue, the source distribution and the absent ages", func() { + It("reports the queue, the source distribution and the absent totals", func() { rep, err := collectStatus(ctx, ds) Expect(err).ToNot(HaveOccurred()) @@ -810,8 +843,8 @@ var _ = Describe("collectStatus", func() { sourceCount{kind: model.KindArtistArtwork, source: "", count: 2}, sourceCount{kind: model.KindAlbumArtwork, source: "folder", count: 1}, )) - Expect(rep.absent).To(ContainElement(absentCount{kind: model.KindArtistArtwork, - ArtworkAbsentStat: model.ArtworkAbsentStat{Total: 2, Stale: 1}})) + Expect(rep.absent).To(ContainElement(absentCount{kind: model.KindArtistArtwork, noImage: 1, failed: 1}), + "two absent artists, one answered and one that gave up") }) It("compares the stored fingerprint against the current one", func() { @@ -845,7 +878,7 @@ var _ = Describe("formatStatus", func() { {kind: model.KindArtistArtwork, source: "", count: 2}, }, absent: []absentCount{ - {kind: model.KindArtistArtwork, ArtworkAbsentStat: model.ArtworkAbsentStat{Total: 2, Stale: 1}}, + {kind: model.KindArtistArtwork, noImage: 1, failed: 1}, }, inputs: []artwork.FingerprintInput{{Name: "Agents", Value: "deezer,lastfm"}}, stored: "abc123", @@ -878,50 +911,37 @@ var _ = Describe("formatStatus", func() { Expect(sources).To(MatchRegexp(`artist\s+absent\s+2`)) }) - It("prints the absent total and how many are due for recheck", func() { - absent := block(formatStatus(rep), "Absent (resolved, no image found)") - Expect(absent).To(MatchRegexp(`artist\s+2\s+1`)) + It("partitions the absent states into answered and gave up", func() { + out := block(formatStatus(rep), "Absent (resolved, no image found)") + Expect(out).To(ContainSubstring("NO IMAGE")) + Expect(out).To(MatchRegexp(`artist\s+1\s+1`), "1 answered plus 1 failed, summing to 2 absent") + Expect(formatStatus(rep)).To(ContainSubstring("artwork reprocess --source failed")) }) - It("states the recheck window and the drip rate the absent counts are bucketed against", func() { - Expect(formatStatus(rep)).To(ContainSubstring(fmt.Sprintf("%gh", artwork.StaleAbsentAge.Hours()))) - Expect(formatStatus(rep)).To(ContainSubstring("100 per kind per hour")) + It("says absent states are never retried on their own, and names both commands that do", func() { + out := formatStatus(rep) + Expect(out).To(ContainSubstring("nothing retries these")) + Expect(out).To(ContainSubstring("artwork reprocess --source absent")) + Expect(out).To(ContainSubstring("artwork reprocess --source failed")) }) - It("leads with the queued backlog, which is the finding, not with the fingerprint verdict", func() { - out := block(formatStatus(rep), "Backfill") - Expect(out).To(MatchRegexp(`State:\s+backfill running: 2 items queued`), - "an operator scanning for trouble must not read 'up to date' while 2 items churn") - Expect(out).To(ContainSubstring("fingerprint up to date")) - }) - - It("keeps the re-enqueue warning while a backfill is already running", func() { - rep.stored = "older" - - out := block(formatStatus(rep), "Backfill") - Expect(out).To(MatchRegexp(`State:\s+backfill running: 2 items queued`)) - Expect(out).To(ContainSubstring("re-enqueued"), - "the stored fingerprint is still stale, so a second full re-enqueue is pending on top of this one") - }) - - It("reports up to date only once the backfill has drained", func() { - rep.queue = []model.ArtworkQueueStat{{ItemKind: "al", Priority: model.ArtworkPriorityScan, Count: 1}} - - Expect(block(formatStatus(rep), "Backfill")).To(MatchRegexp(`State:\s+up to date`)) + It("reports a matching fingerprint as up to date, whatever else is queued", func() { + Expect(block(formatStatus(rep), "Config")).To(MatchRegexp(`State:\s+up to date`)) }) It("echoes the config inputs a fingerprint change would have come from", func() { - out := block(formatStatus(rep), "Backfill") + out := block(formatStatus(rep), "Config") Expect(out).To(MatchRegexp(`Agents:\s+deezer,lastfm`)) Expect(out).To(ContainSubstring("abc123"), "the fingerprint values themselves must be printed") }) - It("reports a changed fingerprint as a pending re-resolve of everything", func() { + It("reports a changed fingerprint as stale artwork, and names the command that applies it", func() { rep.stored = "older" rep.queue = nil out := formatStatus(rep) Expect(out).To(ContainSubstring("fingerprint changed")) + Expect(out).To(ContainSubstring("artwork reprocess --all")) Expect(out).ToNot(ContainSubstring("up to date")) }) @@ -1015,7 +1035,7 @@ var _ = Describe("needsImageAgents", func() { It("is false once the chains no longer reach an agent", func() { conf.Server.CoverArtPriority = "cover.*" conf.Server.ArtistArtPriority = "artist.*" - Expect(needsImageAgents(artwork.RecheckKinds)).To(BeFalse()) + Expect(needsImageAgents(artwork.ReprocessKinds)).To(BeFalse()) }) }) diff --git a/cmd/root.go b/cmd/root.go index 94f861f40..c4e360010 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -366,21 +366,18 @@ func startArtworkWorker(ctx context.Context, worker *artwork.Worker) func() erro } } -// scheduleArtworkHousekeeping runs the startup fingerprint backfill and registers the -// recurring stale-absent recheck and prune jobs. +// scheduleArtworkHousekeeping registers the recurring missing-state and prune jobs, and +// reports an artwork config change without acting on it. 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 := schedulerInstance.Add(consts.ArtworkEnqueueMissingSchedule, func() { 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) + log.Error(ctx, "Error scheduling artwork missing-state recheck", err) } if _, err := schedulerInstance.Add(consts.ArtworkPruneSchedule, func() { @@ -397,23 +394,8 @@ func scheduleArtworkHousekeeping(ctx context.Context, worker *artwork.Worker) fu 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(): + if err := worker.ReconcileConfig(ctx); err != nil { + log.Error(ctx, "Error checking the artwork config fingerprint", err) } return nil } diff --git a/consts/consts.go b/consts/consts.go index 2934cd968..0f950ef10 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -24,8 +24,8 @@ 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 is the model.PropertyRepository key the artwork config check + // compares against to detect artwork-affecting config changes across restarts. ArtConfFingerprintPropertyKey = "ArtConfFingerprint" UIAuthorizationHeader = "X-ND-Authorization" @@ -39,9 +39,8 @@ const ( DBAnalyzeCheckSchedule = "@every 30m" DBAnalyzeMaxAge = 24 * time.Hour - ArtworkStaleAbsentRecheckSchedule = "@every 1h" - ArtworkPruneSchedule = "@daily" - ArtworkPostBackfillPruneDelay = 10 * time.Minute + ArtworkEnqueueMissingSchedule = "@every 1h" + ArtworkPruneSchedule = "@daily" // DefaultEncryptionKey This is the encryption key used if none is specified in the `PasswordEncryptionKey` option // Never ever change this! Or it will break all Navidrome installations that don't set the config option diff --git a/core/artwork/artwork.go b/core/artwork/artwork.go index e8458a0f9..663d06d25 100644 --- a/core/artwork/artwork.go +++ b/core/artwork/artwork.go @@ -118,10 +118,6 @@ func (s *service) Get(ctx context.Context, artID model.ArtworkID, size int, squa } } -// 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 { @@ -130,10 +126,7 @@ func (s *service) serveEntity(ctx context.Context, artID model.ArtworkID, size i 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) - } + // Settled absent: only an explicit reprocess or refresh retries it. return nil, ErrUnavailable default: return s.serveHash(ctx, artID, ia, size, square) diff --git a/core/artwork/artwork_test.go b/core/artwork/artwork_test.go index 7de5475d6..907b300de 100644 --- a/core/artwork/artwork_test.go +++ b/core/artwork/artwork_test.go @@ -204,28 +204,15 @@ var _ = Describe("Artwork", func() { Expect(err).To(MatchError(ErrUnavailable)) }) - It("does not re-enqueue a recently-attempted absent state", func() { + It("never re-enqueues an absent state on view, however old", func() { Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ - ItemKind: "al", ItemID: "al4", AttemptedAt: time.Now(), + ItemKind: "al", ItemID: "al4", AttemptedAt: time.Now().Add(-365 * 24 * time.Hour), })).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()) - }) }) Describe("provisional read-through", func() { diff --git a/core/artwork/housekeeping.go b/core/artwork/housekeeping.go index a3330d7cf..3ca452bdc 100644 --- a/core/artwork/housekeeping.go +++ b/core/artwork/housekeeping.go @@ -6,26 +6,18 @@ import ( "slices" "strconv" "strings" - "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" "github.com/zeebo/xxh3" ) -// StaleAbsentAge is how long an absent state is trusted before a recheck retries it. -const StaleAbsentAge = 30 * 24 * time.Hour - -// StaleAbsentRecheckBatch caps how many absent states each hourly tick re-queues per kind, -// oldest first, so external agents see a flat drip instead of a daily burst. -const StaleAbsentRecheckBatch = 100 - -// RecheckKinds omits media files: they resolve embedded-only, at scan or on view. -var RecheckKinds = []model.Kind{ +// ReprocessKinds omits media files: they resolve embedded-only, at scan or on view. Artists lead +// so bulk enqueues give the most external-dependent kind a queue headstart. +var ReprocessKinds = []model.Kind{ model.KindArtistArtwork, model.KindAlbumArtwork, model.KindPlaylistArtwork, model.KindRadioArtwork, } @@ -34,14 +26,16 @@ var RecheckKinds = []model.Kind{ func KeepsState(kind model.Kind) bool { return kind != model.KindDiscArtwork } // RefreshableKinds is every kind Refresh can clear and re-queue, so it holds exactly the kinds -// KeepsState admits. Media files are absent from RecheckKinds but belong here: the worker -// resolves them, it just never revisits them on its own. -var RefreshableKinds = append(slices.Clone(RecheckKinds), model.KindMediaFileArtwork) +// KeepsState admits. Media files are absent from ReprocessKinds but belong here: the worker +// resolves them, it just never enumerates them in bulk. +var RefreshableKinds = append(slices.Clone(ReprocessKinds), model.KindMediaFileArtwork) -// hasRecheckPath reports whether a periodic job will revisit this kind, making an absent settle recoverable. -func hasRecheckPath(prefix string) bool { +// settlesAbsentOnGiveUp reports whether an exhausted retry budget records an absent state. Media +// files are excluded because retrying one costs nothing: they resolve embedded-only, from a local +// read, and only a view ever enqueues them. +func settlesAbsentOnGiveUp(prefix string) bool { kind, ok := model.ParseKind(prefix) - return ok && slices.Contains(RecheckKinds, kind) + return ok && KeepsState(kind) && kind != model.KindMediaFileArtwork } // artworkEpoch invalidates all resolution state when bumped; bump it whenever resolution semantics change. @@ -72,93 +66,36 @@ func ConfigFingerprint() string { return fmt.Sprintf("%016x", xxh3.Hash([]byte(raw))) } -// backfillSummary is what a backfill enqueued. MaxExternalLookups is an upper estimate for one -// attempt per item, not a bound: a local hit ends the walk, and a retry asks the agents again. -type backfillSummary struct { - Ran bool - PerKind map[string]int64 - Items int64 - MaxExternalLookups int64 -} - -// backfill enqueues artwork resolution for every entity when the config fingerprint changed. -func backfill(ctx context.Context, ds model.DataStore, agentCount func() ImageAgentCount) (backfillSummary, error) { - start := time.Now() - ctx = auth.WithAdminUser(ctx, ds) +// ReconcileConfigFingerprint warns when the artwork config changed since the library was last +// resolved under it. Nothing re-resolves on its own; applying a change is an explicit reprocess. +func ReconcileConfigFingerprint(ctx context.Context, ds model.DataStore) error { current := ConfigFingerprint() - props := ds.Property(ctx) - stored, err := props.DefaultGet(consts.ArtConfFingerprintPropertyKey, "") + stored, err := ds.Property(ctx).DefaultGet(consts.ArtConfFingerprintPropertyKey, "") if err != nil { - return backfillSummary{}, err + return err } - if stored == current { - return backfillSummary{}, 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() }}, - } - // Counted here, not by the caller: building the agent list constructs every enabled agent, and - // an unchanged fingerprint returns above without ever needing the number. - agents := agentCount() - summary := backfillSummary{Ran: true, PerKind: map[string]int64{}} - for _, k := range kinds { - ids, err := k.fetch() - if err != nil { - return backfillSummary{}, err - } - if err := enqueueBackfillKind(ctx, ds, k.kind, ids); err != nil { - return backfillSummary{}, err - } - n := int64(len(ids)) - summary.PerKind[k.kind.Prefix()] = n - summary.Items += n - summary.MaxExternalLookups += n * ExternalLookupsPerItem(k.kind, agents) - } - - if err := props.Put(consts.ArtConfFingerprintPropertyKey, current); err != nil { - return backfillSummary{}, err - } - log.Info(ctx, "Artwork: Config fingerprint changed, backfill enqueued", "items", summary.Items, - "byKind", summary.PerKind, "maxExternalLookups", summary.MaxExternalLookups, - "elapsed", time.Since(start)) - return summary, 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, StaleAbsentRecheckBatch); err != nil { - return err - } + switch stored { + case current: + case "": + // An unset fingerprint counts as current; the alternative warns every upgrading install once. + return MarkConfigApplied(ctx, ds) + default: + log.Warn(ctx, "Artwork: Config changed since the last full reprocess. Stored artwork keeps "+ + "the old resolution; run 'navidrome artwork reprocess --all' to apply the change", + "stored", stored, "current", current, "inputs", FingerprintInputs()) } return nil } +// MarkConfigApplied records the current fingerprint as the one the library is resolved under. +func MarkConfigApplied(ctx context.Context, ds model.DataStore) error { + return ds.Property(ctx).Put(consts.ArtConfFingerprintPropertyKey, ConfigFingerprint()) +} + // 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 { + for _, kind := range ReprocessKinds { if _, err := queue.EnqueueAllMissing(kind, model.ArtworkPriorityRecheck); err != nil { return err } diff --git a/core/artwork/housekeeping_test.go b/core/artwork/housekeeping_test.go index c9809203a..2027cba9a 100644 --- a/core/artwork/housekeeping_test.go +++ b/core/artwork/housekeeping_test.go @@ -2,59 +2,17 @@ package artwork import ( "context" - "fmt" "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 -} - -func noAgents() ImageAgentCount { return ImageAgentCount{} } - -// 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("RefreshableKinds", func() { // The two are meant to describe the same fact. Nothing but this test stops them from drifting, // and a drift would have `artwork explain` report state for a kind that keeps none. @@ -72,7 +30,7 @@ var _ = Describe("Housekeeping", func() { var ( ctx context.Context ds *tests.MockDataStore - queueRepo *orderTrackingQueueRepo + queueRepo *tests.MockArtworkQueueRepo propRepo *tests.MockedPropertyRepo ) @@ -84,52 +42,24 @@ var _ = Describe("Housekeeping", func() { conf.Server.Agents = "spotify" conf.Server.EnableExternalServices = true - queueRepo = &orderTrackingQueueRepo{MockArtworkQueueRepo: tests.CreateMockArtworkQueueRepo()} + queueRepo = 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 := ConfigFingerprint() - conf.Server.CoverArtPriority = "folder, embedded" - f2 := ConfigFingerprint() - Expect(f1).NotTo(Equal(f2)) - }) + DescribeTable("changes when a fingerprint-affecting config value changes", + func(change func()) { + before := ConfigFingerprint() + change() + Expect(ConfigFingerprint()).NotTo(Equal(before)) + }, + Entry("CoverArtPriority", func() { conf.Server.CoverArtPriority = "folder, embedded" }), + Entry("ArtistImageFolder", func() { conf.Server.ArtistImageFolder = "/after" }), + Entry("EnableM3UExternalAlbumArt", func() { conf.Server.EnableM3UExternalAlbumArt = true }), + ) - It("changes when ArtistImageFolder changes", func() { - conf.Server.ArtistImageFolder = "/before" - f1 := ConfigFingerprint() - conf.Server.ArtistImageFolder = "/after" - Expect(ConfigFingerprint()).NotTo(Equal(f1)) - }) - - It("changes when EnableM3UExternalAlbumArt is toggled", func() { - conf.Server.EnableM3UExternalAlbumArt = false - f1 := ConfigFingerprint() - conf.Server.EnableM3UExternalAlbumArt = true - Expect(ConfigFingerprint()).NotTo(Equal(f1)) - }) - - // Pinned: a changed formula re-resolves every library on upgrade, flooding external providers. + // Pinned: a changed formula tells every existing install its artwork config went stale. It("hashes a given config to a stable value", func() { conf.Server.CoverArtPriority = "cover.*, embedded" conf.Server.ArtistArtPriority = "artist.*, external" @@ -157,145 +87,23 @@ var _ = Describe("Housekeeping", func() { f1 := ConfigFingerprint() consts.Version = original + "-next" Expect(ConfigFingerprint()).To(Equal(f1), - "the version must not invalidate artwork state: it would re-resolve every entity on every build") + "the version must not invalidate artwork state: every build would report a stale config") }) }) - Describe("Backfill", func() { - It("enqueues nothing and returns false when the stored fingerprint matches", func() { - seedEntities() - Expect(propRepo.Put(consts.ArtConfFingerprintPropertyKey, ConfigFingerprint())).To(Succeed()) + Describe("ReconcileConfigFingerprint", func() { + It("records the current fingerprint when none was ever stored", func() { + Expect(ReconcileConfigFingerprint(ctx, ds)).To(Succeed()) - counted := false - s, err := backfill(ctx, ds, func() ImageAgentCount { - counted = true - return ImageAgentCount{Artist: 3, Album: 2} - }) - Expect(err).ToNot(HaveOccurred()) - Expect(s).To(Equal(backfillSummary{})) - Expect(counted).To(BeFalse(), "building the agent list constructs every agent; an unchanged fingerprint must not pay for it") - - count, err := queueRepo.Count() - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(BeZero()) + Expect(propRepo.Get(consts.ArtConfFingerprintPropertyKey)).To(Equal(ConfigFingerprint())) }) - It("runs the backfill when no fingerprint was ever stored", func() { - seedEntities() - - s, err := backfill(ctx, ds, noAgents) - Expect(err).ToNot(HaveOccurred()) - Expect(s.Ran).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(ConfigFingerprint())) - }) - - 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{}, - } - - s, err := backfill(ctx, vds, noAgents) - Expect(err).ToNot(HaveOccurred()) - Expect(s.Ran).To(BeTrue()) - Expect(findQueued(queueRepo.MockArtworkQueueRepo, "pl", "plPrivate")).ToNot(BeNil()) - }) - - It("enqueues artists before albums/playlists/radios, all at Backfill priority", func() { - seedEntities() + It("leaves a stale fingerprint stored, so the warning survives a restart", func() { Expect(propRepo.Put(consts.ArtConfFingerprintPropertyKey, "stale-fingerprint")).To(Succeed()) - s, err := backfill(ctx, ds, noAgents) - Expect(err).ToNot(HaveOccurred()) - Expect(s.Ran).To(BeTrue()) + Expect(ReconcileConfigFingerprint(ctx, ds)).To(Succeed()) - 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")) - } - }) - - It("reports what it enqueued, per kind and as an external-lookup ceiling", func() { - conf.Server.ArtistArtPriority = "artist.*, external" - conf.Server.CoverArtPriority = "cover.*, external" - conf.Server.EnableM3UExternalAlbumArt = false - seedEntities() - - s, err := backfill(ctx, ds, func() ImageAgentCount { return ImageAgentCount{Artist: 3, Album: 2} }) - Expect(err).ToNot(HaveOccurred()) - Expect(s.Ran).To(BeTrue()) - - Expect(s.PerKind).To(Equal(map[string]int64{"ar": 2, "al": 1, "pl": 1, "ra": 1})) - Expect(s.Items).To(Equal(int64(5))) - // 2 artists x 3 agents, 1 album x 2, 1 playlist grid x 2, and radios never fetch. - Expect(s.MaxExternalLookups).To(Equal(int64(6 + 2 + PlaylistGridSamples*2))) - }) - }) - - 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(-StaleAbsentAge - time.Hour) - recent := time.Now().Add(-StaleAbsentAge + 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()) - }) - - It("caps each tick at the recheck batch, oldest attempts first", func() { - for i := range StaleAbsentRecheckBatch + 1 { - id := fmt.Sprintf("ar%d", i) - artRepo.ItemData[id] = model.ItemArtwork{ItemKind: "ar", ItemID: id, ImageType: model.ImageTypePrimary, - Hash: "", AttemptedAt: time.Now().Add(-StaleAbsentAge - time.Duration(i+1)*time.Minute)} - } - - Expect(enqueueStaleAbsentAll(ctx, ds)).To(Succeed()) - - Expect(queueRepo.Data).To(HaveLen(StaleAbsentRecheckBatch)) - // ar0 has the newest attempted_at of the cohort, so it is the one left out. - Expect(findQueued(queueRepo.MockArtworkQueueRepo, "ar", "ar0")).To(BeNil()) + Expect(propRepo.Get(consts.ArtConfFingerprintPropertyKey)).To(Equal("stale-fingerprint")) }) }) @@ -315,8 +123,8 @@ var _ = Describe("Housekeeping", func() { }) 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()} + artRepo.ItemData["al-resolved"] = model.ItemArtwork{ItemKind: "al", ItemID: "al1", ImageType: model.ImageTypePrimary, Hash: "somehash"} + artRepo.ItemData["ar-absent"] = model.ItemArtwork{ItemKind: "ar", ItemID: "ar1", ImageType: model.ImageTypePrimary, Hash: ""} err := enqueueMissingAll(ctx, ds) Expect(err).ToNot(HaveOccurred()) @@ -324,11 +132,11 @@ var _ = Describe("Housekeeping", func() { 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()) + Expect(findQueued(queueRepo, "al", "al2")).ToNot(BeNil()) + Expect(findQueued(queueRepo, "pl", "pl1")).ToNot(BeNil()) + Expect(findQueued(queueRepo, "ra", "ra1")).ToNot(BeNil()) + Expect(findQueued(queueRepo, "al", "al1")).To(BeNil()) + Expect(findQueued(queueRepo, "ar", "ar1")).To(BeNil()) }) }) }) diff --git a/core/artwork/worker.go b/core/artwork/worker.go index bea478aa5..bb09be55e 100644 --- a/core/artwork/worker.go +++ b/core/artwork/worker.go @@ -23,8 +23,8 @@ import ( 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 bounds the retry budget from enqueue; past it the item settles and only an + // explicit reprocess retries it. giveUpAfter = 12 * time.Hour ) @@ -40,7 +40,6 @@ type drainPool struct { // independently, and pruneMu serializes prune against the store-write window. type Worker struct { proc *processor - agents *agents.Agents cache cache.FileCache ffmpeg ffmpeg.FFmpeg broker events.Broker @@ -55,7 +54,6 @@ type Worker struct { 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}, - agents: ag, cache: imgCache, ffmpeg: ffmpeg, broker: broker, @@ -133,17 +131,9 @@ func (w *Worker) RunPrune(ctx context.Context) error { 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 the backfill ran. -func (w *Worker) Backfill(ctx context.Context) (bool, error) { - s, err := backfill(ctx, w.proc.ds, func() ImageAgentCount { return NewImageAgentCount(w.agents) }) - return s.Ran, err -} - -// EnqueueStaleAbsentAll requeues known-absent entries older than StaleAbsentAge, at most -// StaleAbsentRecheckBatch per kind, oldest first. -func (w *Worker) EnqueueStaleAbsentAll(ctx context.Context) error { - return enqueueStaleAbsentAll(ctx, w.proc.ds) +// ReconcileConfig records the artwork config fingerprint, or warns when it changed. +func (w *Worker) ReconcileConfig(ctx context.Context) error { + return ReconcileConfigFingerprint(ctx, w.proc.ds) } // EnqueueMissingAll requeues entities with no artwork state row: the safety net for anything @@ -265,10 +255,9 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc "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. + // Art already being served is kept: exhaustion means unreachable, not removed. settled := "kept previous state" - if out == outcomeFailed && hasRecheckPath(item.ItemKind) && !w.hasResolvedArtwork(ctx, item) { + if out == outcomeFailed && settlesAbsentOnGiveUp(item.ItemKind) && !w.hasResolvedArtwork(ctx, item) { writeAbsent(ctx, w.proc.ds.Artwork(ctx), item) settled = "recorded absent" } diff --git a/core/artwork/worker_test.go b/core/artwork/worker_test.go index b0ef665fc..ebb8de251 100644 --- a/core/artwork/worker_test.go +++ b/core/artwork/worker_test.go @@ -15,6 +15,7 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/tests" "github.com/navidrome/navidrome/utils/cache" @@ -115,6 +116,29 @@ func findQueued(q *tests.MockArtworkQueueRepo, kind, id string) *model.ArtworkQu return nil } +// 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 +} + var _ = Describe("Worker", func() { var ( ctx context.Context @@ -441,9 +465,9 @@ var _ = Describe("Worker", func() { Expect(ia.Hash).To(Equal("cafebabe"), "recording the failure must not disturb the 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() { + // Only a view enqueues a media file, and an absent row is exactly what stops a view from + // doing so: a transient read error would look permanent. + It("does not settle absent on exhaustion for a media file", func() { conf.Server.EnableMediaFileCoverArt = true ds.MockedMediaFile = tests.CreateMockMediaFileRepo() ds.MockedMediaFile.(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ diff --git a/db/migrations/20260901225726_normalize_artwork_last_failure.sql b/db/migrations/20260901225726_normalize_artwork_last_failure.sql new file mode 100644 index 000000000..6de12e757 --- /dev/null +++ b/db/migrations/20260901225726_normalize_artwork_last_failure.sql @@ -0,0 +1,9 @@ +-- +goose Up +-- 20260819204637 added last_failure with DEFAULT '[]', so every row already in the table got a +-- non-empty value. That is how a give-up is now told apart from a definitive "no image", which +-- would report every pre-existing absent row as failed. +UPDATE item_artwork SET last_failure = '' WHERE last_failure = '[]'; + +-- +goose Down +-- Irreversible: a genuine give-up and a backfilled default are indistinguishable once normalized. +SELECT 1; diff --git a/model/album.go b/model/album.go index 5a436fec0..ee24bfa96 100644 --- a/model/album.go +++ b/model/album.go @@ -143,7 +143,6 @@ type AlbumRepository interface { UpdateExternalInfo(*Album) error Get(id string) (*Album, error) GetAll(...QueryOptions) (Albums, error) - GetAllIDs(...QueryOptions) ([]string, error) // GetSoleAlbumArtistIDsInSubtrees returns the sole album artists of the albums with folders in // any of the given library-relative subtrees. GetSoleAlbumArtistIDsInSubtrees(lib Library, paths ...string) ([]string, error) diff --git a/model/artist.go b/model/artist.go index f3704b669..f88b3a974 100644 --- a/model/artist.go +++ b/model/artist.go @@ -90,7 +90,6 @@ 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 index 3c0df209b..8856a35fa 100644 --- a/model/artwork.go +++ b/model/artwork.go @@ -18,6 +18,10 @@ type Artwork struct { const ImageTypePrimary = "primary" +// ArtworkSourceFailed is a pseudo-source selecting absent states that exhausted the retry budget +// rather than being answered. The "!" keeps it from colliding with a stored source value. +const ArtworkSourceFailed = "!failed" + // ItemImage is per-entity artwork state hydrated at query time; never persisted. type ItemImage struct { ImageHash string `structs:"-" json:"imageHash,omitempty"` @@ -88,11 +92,12 @@ func (i ItemArtworkInfo) Image() ItemImage { } 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"` + ItemKind string `structs:"item_kind"` + ItemID string `structs:"item_id"` + ImageType string `structs:"image_type"` + Priority int `structs:"priority"` + Attempts int `structs:"attempts"` + // RetryAt is the earliest time the drain may take this row, not when it will run. RetryAt time.Time `structs:"retry_at"` EnqueuedAt time.Time `structs:"enqueued_at"` // Trace is why the last attempt failed. Only Get reads it; the drain projects it away. @@ -101,7 +106,9 @@ type ArtworkQueueItem struct { // Queue priorities: higher drains first. const ( - ArtworkPriorityRecheck = 0 + ArtworkPriorityRecheck = 0 + // ArtworkPriorityBackfill sits between the hourly sweep and scan-driven work. Nothing enqueues + // it today; it stays named so a row still carrying it can be reported and cancelled. ArtworkPriorityBackfill = 10 ArtworkPriorityScan = 50 ArtworkPriorityBump = 100 @@ -134,15 +141,13 @@ type ArtworkQueueRepository interface { // 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, oldest - // first; limit caps the selection, so already-queued rows use up budget (backpressure when the drain stalls). - EnqueueStaleAbsent(kind Kind, attemptedBefore time.Time, limit int) (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 // CountBySource reports how many items of a kind currently resolve from the given sources. - // An empty sources slice means every source; "" matches absent state. + // An empty sources slice means every source; "" matches absent state, and the pseudo-source + // ArtworkSourceFailed matches the absent states that gave up. CountBySource(kind Kind, sources []string) (int64, error) // SourcesInUse lists the distinct sources items of a kind currently resolve from, "" included. SourcesInUse(kind Kind) ([]string, error) @@ -161,9 +166,6 @@ type ArtworkQueueRepository interface { // CountQueued reports the pending rows matching the kinds and priorities, grouped by both; // an empty filter means every one. CountQueued(kinds []Kind, priorities []int) ([]ArtworkQueueStat, error) - // CountAbsent reports the absent states of a kind, and how many are past the given cutoff, - // eligible for EnqueueStaleAbsent (which drains them limit rows per call). - CountAbsent(kind Kind, attemptedBefore time.Time) (ArtworkAbsentStat, error) // PurgeDangling removes queue rows whose entity no longer exists. PurgeDangling() (int64, error) // PurgeQueued removes pending rows matching the kinds and priorities; an empty filter means every one. @@ -175,8 +177,3 @@ type ArtworkQueueStat struct { Priority int Count int64 } - -type ArtworkAbsentStat struct { - Total int64 - Stale int64 -} diff --git a/model/mediafile.go b/model/mediafile.go index 99aee591e..2669018f3 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -553,8 +553,6 @@ type MediaFileRepository interface { // expression, using the logged user's annotations. Limit and offset are ignored. MatchesCriteria(id string, c criteria.Criteria) (bool, 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) // GetAlbumIDsByFolder returns the distinct IDs of albums with non-missing tracks in the given // folders or their direct children. GetAlbumIDsByFolder(lib Library, folderIDs ...string) ([]string, error) diff --git a/model/playlist.go b/model/playlist.go index 306401271..abd11b8c4 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -150,7 +150,6 @@ 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 466ff48b0..013a24beb 100644 --- a/model/radio.go +++ b/model/radio.go @@ -35,6 +35,5 @@ type RadioRepository interface { 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/persistence/album_repository.go b/persistence/album_repository.go index 7ac875a51..808c880fb 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -259,8 +259,8 @@ func (r *albumRepository) hydrateArtwork(albums model.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) { +// 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") @@ -304,7 +304,7 @@ func (r *albumRepository) GetSoleAlbumArtistIDsInSubtrees(lib model.Library, pat } func (r *albumRepository) GetCursor(options ...model.QueryOptions) (model.AlbumCursor, error) { - ids, err := r.GetAllIDs(options...) + ids, err := r.getAllIDs(options...) if err != nil { return nil, err } diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index 0fb680cff..c3d7f018f 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -152,12 +152,12 @@ var _ = Describe("AlbumRepository", func() { }) }) - Describe("GetAllIDs", 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() + ids, err := albumRepo.getAllIDs() Expect(err).ToNot(HaveOccurred()) Expect(ids).To(ConsistOf(slice.Map(want, func(a model.Album) string { return a.ID }))) }) diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go index 67d0df448..1ff291a0f 100644 --- a/persistence/artist_repository.go +++ b/persistence/artist_repository.go @@ -265,9 +265,9 @@ func (r *artistRepository) GetAll(options ...model.QueryOptions) (model.Artists, return res, err } -// GetAllIDs returns just the artist IDs for the same row set as GetAll, skipping the +// 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) { +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") @@ -284,7 +284,7 @@ func (r *artistRepository) hydrateArtwork(artists model.Artists) { } func (r *artistRepository) GetCursor(options ...model.QueryOptions) (model.ArtistCursor, error) { - ids, err := r.GetAllIDs(options...) + ids, err := r.getAllIDs(options...) if err != nil { return nil, err } diff --git a/persistence/artist_repository_test.go b/persistence/artist_repository_test.go index 25472ffe1..d337b4c22 100644 --- a/persistence/artist_repository_test.go +++ b/persistence/artist_repository_test.go @@ -285,12 +285,12 @@ var _ = Describe("ArtistRepository", func() { }) }) - Describe("GetAllIDs", 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() + ids, err := repo.(*artistRepository).getAllIDs() Expect(err).ToNot(HaveOccurred()) Expect(ids).To(ConsistOf(slice.Map(want, func(a model.Artist) string { return a.ID }))) }) diff --git a/persistence/artwork_hydration_test.go b/persistence/artwork_hydration_test.go index 2b52d7d82..bb9cda04d 100644 --- a/persistence/artwork_hydration_test.go +++ b/persistence/artwork_hydration_test.go @@ -578,7 +578,7 @@ var _ = Describe("Artwork hydration", func() { 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)) + Expect(repo.(*playlistRepository).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)) diff --git a/persistence/artwork_queue_repository.go b/persistence/artwork_queue_repository.go index 1c0077fc7..88b6f6f80 100644 --- a/persistence/artwork_queue_repository.go +++ b/persistence/artwork_queue_repository.go @@ -56,14 +56,6 @@ func (r *artworkQueueRepository) EnqueuePreservingBackoff(items ...model.Artwork priority = MAX(priority, excluded.priority)`, items) } -func (r *artworkQueueRepository) EnqueueStaleAbsent(kind model.Kind, attemptedBefore time.Time, limit int) (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 < ? - ORDER BY attempted_at LIMIT ?`, - model.ArtworkPriorityRecheck, now, now, kind.Prefix(), attemptedBefore, limit) -} - func (r *artworkQueueRepository) EnqueueAllMissing(kind model.Kind, priority int) (int64, error) { entityTable, ok := artworkOwnerTables[kind] if !ok { @@ -110,13 +102,23 @@ func (r *artworkQueueRepository) insertIfNotQueued(with, sql string, args ...any ` (`+strings.Join(enqueueColumns, ", ")+`) `+sql+skipIfQueued, args...)) } -// artworkSourceFilter selects item_artwork rows of a kind; no sources means every source, "" the absent state. +// artworkSourceFilter selects item_artwork rows of a kind; no sources means every source, "" the +// absent state, and ArtworkSourceFailed the absent states that gave up. Several are a union, so +// asking for both absent and failed is just absent. func artworkSourceFilter(kind model.Kind, sources []string) Sqlizer { f := And{Eq{"item_kind": kind.Prefix()}} - if len(sources) > 0 { - f = append(f, Eq{"source": sources}) + if len(sources) == 0 { + return f } - return f + stored := slices.DeleteFunc(slices.Clone(sources), func(s string) bool { return s == model.ArtworkSourceFailed }) + var match Or + if len(stored) > 0 { + match = append(match, Eq{"source": stored}) + } + if len(stored) != len(sources) { + match = append(match, And{Eq{"hash": ""}, NotEq{"last_failure": ""}}) + } + return append(f, match) } func (r *artworkQueueRepository) CountBySource(kind model.Kind, sources []string) (int64, error) { @@ -231,13 +233,4 @@ func (r *artworkQueueRepository) Count() (int64, error) { return res.Count, err } -// CountAbsent matches EnqueueStaleAbsent on hash, so the stale count is the pool a recheck drains from. -func (r *artworkQueueRepository) CountAbsent(kind model.Kind, attemptedBefore time.Time) (model.ArtworkAbsentStat, error) { - var res model.ArtworkAbsentStat - err := r.queryOne(Select("count(*) as total"). - Column(Expr("coalesce(sum(attempted_at < ?), 0) as stale", attemptedBefore)). - From(itemArtworkTable).Where(Eq{"item_kind": kind.Prefix(), "hash": ""}), &res) - return res, err -} - var _ model.ArtworkQueueRepository = (*artworkQueueRepository)(nil) diff --git a/persistence/artwork_queue_repository_test.go b/persistence/artwork_queue_repository_test.go index 1673a5b0e..0481d4193 100644 --- a/persistence/artwork_queue_repository_test.go +++ b/persistence/artwork_queue_repository_test.go @@ -237,41 +237,6 @@ var _ = Describe("ArtworkQueueRepository", func() { 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), 100) - 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 only the oldest stale absent states up to the limit", func() { - awRepo := NewArtworkRepository(context.Background(), GetDBXBuilder()) - now := time.Now() - Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "oldest", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: now.Add(-72 * time.Hour)})).To(Succeed()) - Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "older", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: now.Add(-60 * time.Hour)})).To(Succeed()) - Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "old", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: now.Add(-48 * time.Hour)})).To(Succeed()) - - n, err := repo.EnqueueStaleAbsent(model.KindArtistArtwork, now.Add(-24*time.Hour), 2) - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(Equal(int64(2))) - - items, err := repo.DequeueBatch(10) - Expect(err).ToNot(HaveOccurred()) - ids := slice.Map(items, func(it model.ArtworkQueueItem) string { return it.ItemID }) - Expect(ids).To(ConsistOf("oldest", "older")) - }) - 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()) @@ -435,29 +400,49 @@ var _ = Describe("ArtworkQueueRepository", func() { )) }) - It("reports an empty queue as no rows", func() { - Expect(repo.CountQueued(nil, nil)).To(BeEmpty()) - }) - - It("counts absent states and how many are due for recheck", func() { + It("selects only the absent states that gave up, not those a source answered", func() { awRepo := NewArtworkRepository(context.Background(), GetDBXBuilder()) - old := time.Now().Add(-48 * time.Hour) for _, ia := range []model.ItemArtwork{ - {ItemKind: "ar", ItemID: "stale1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old}, - {ItemKind: "ar", ItemID: "fresh1", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: time.Now()}, - {ItemKind: "ar", ItemID: "found1", ImageType: model.ImageTypePrimary, Hash: "hX", AttemptedAt: old}, - {ItemKind: "al", ItemID: "stale2", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: old}, + {ItemKind: "ar", ItemID: "gaveup", ImageType: model.ImageTypePrimary, LastFailure: "[]"}, + {ItemKind: "ar", ItemID: "toldno", ImageType: model.ImageTypePrimary}, + {ItemKind: "ar", ItemID: "hasart", ImageType: model.ImageTypePrimary, Hash: "hX", LastFailure: "[]"}, } { Expect(awRepo.PutItemArtwork(&ia)).To(Succeed()) } - Expect(repo.CountAbsent(model.KindArtistArtwork, time.Now().Add(-24*time.Hour))). - To(Equal(model.ArtworkAbsentStat{Total: 2, Stale: 1})) + Expect(repo.CountBySource(model.KindArtistArtwork, []string{model.ArtworkSourceFailed})).To(Equal(int64(1)), + "an item still serving art is not absent, however its last attempt went") + + // A later success rewrites the row, clearing the record. + Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "gaveup", + ImageType: model.ImageTypePrimary, Hash: "hZ"})).To(Succeed()) + Expect(repo.CountBySource(model.KindArtistArtwork, []string{model.ArtworkSourceFailed})).To(Equal(int64(0))) }) - It("reports a kind with no absent state as zero, not as an error", func() { - Expect(repo.CountAbsent(model.KindRadioArtwork, time.Now())).To(Equal(model.ArtworkAbsentStat{})) + It("unions the failed pseudo-source with a real one, so absent plus failed is just absent", func() { + awRepo := NewArtworkRepository(context.Background(), GetDBXBuilder()) + for _, ia := range []model.ItemArtwork{ + {ItemKind: "ar", ItemID: "gaveup", ImageType: model.ImageTypePrimary, LastFailure: "[]"}, + {ItemKind: "ar", ItemID: "toldno", ImageType: model.ImageTypePrimary}, + {ItemKind: "ar", ItemID: "folder", ImageType: model.ImageTypePrimary, Hash: "hX", Source: "folder"}, + } { + Expect(awRepo.PutItemArtwork(&ia)).To(Succeed()) + } + failedAndAbsent := []string{model.ArtworkSourceFailed, ""} + Expect(repo.CountBySource(model.KindArtistArtwork, failedAndAbsent)).To(Equal(int64(2)), + "failed is a subset of absent, so asking for both is asking for absent") + Expect(repo.CountBySource(model.KindArtistArtwork, []string{model.ArtworkSourceFailed, "folder"})). + To(Equal(int64(2)), "a pseudo-source and a stored source combine as a union") }) + + It("reports a kind with nothing failed as zero", func() { + Expect(repo.CountBySource(model.KindRadioArtwork, []string{model.ArtworkSourceFailed})).To(Equal(int64(0))) + }) + + It("reports an empty queue as no rows", func() { + Expect(repo.CountQueued(nil, nil)).To(BeEmpty()) + }) + }) Describe("PurgeQueued", func() { diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index ed18333de..da167b1a8 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -308,8 +308,8 @@ 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) { +// 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") @@ -341,7 +341,7 @@ func (r *mediaFileRepository) GetAlbumIDsByFolder(lib model.Library, folderIDs . // 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...) + ids, err := r.getAllIDs(options...) if err != nil { return nil, err } diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index 505f23440..bf8d6d5a8 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -208,8 +208,8 @@ func (r *playlistRepository) GetAll(options ...model.QueryOptions) (model.Playli 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) { +// 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"). @@ -224,7 +224,7 @@ func (r *playlistRepository) GetAllIDs(options ...model.QueryOptions) ([]string, func (r *playlistRepository) GetCursor(options ...model.QueryOptions) (model.PlaylistCursor, error) { // Both passes apply userFilter, so a visibility change between them cannot widen the cursor. - ids, err := r.GetAllIDs(options...) + ids, err := r.getAllIDs(options...) if err != nil { return nil, err } diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go index f60b4e7ca..60263807a 100644 --- a/persistence/playlist_repository_test.go +++ b/persistence/playlist_repository_test.go @@ -74,12 +74,12 @@ var _ = Describe("PlaylistRepository", func() { }) }) - Describe("GetAllIDs", 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() + ids, err := repo.(*playlistRepository).getAllIDs() Expect(err).ToNot(HaveOccurred()) Expect(ids).To(ConsistOf(slice.Map(want, func(p model.Playlist) string { return p.ID }))) }) diff --git a/persistence/radio_repository.go b/persistence/radio_repository.go index 915859559..e042ee6bf 100644 --- a/persistence/radio_repository.go +++ b/persistence/radio_repository.go @@ -79,14 +79,6 @@ func (r *radioRepository) hydrateArtwork(radios model.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 { if !r.isPermitted() { return rest.ErrPermissionDenied diff --git a/persistence/radio_repository_test.go b/persistence/radio_repository_test.go index c35c85ad7..a958f715d 100644 --- a/persistence/radio_repository_test.go +++ b/persistence/radio_repository_test.go @@ -7,7 +7,6 @@ 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" ) @@ -79,17 +78,6 @@ 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{ diff --git a/tests/mock_album_repo.go b/tests/mock_album_repo.go index c63f7c425..1b14f225b 100644 --- a/tests/mock_album_repo.go +++ b/tests/mock_album_repo.go @@ -7,7 +7,6 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" - "github.com/navidrome/navidrome/utils/slice" ) func CreateMockAlbumRepo() *MockAlbumRepo { @@ -85,14 +84,6 @@ 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 af393129e..db7d54d5d 100644 --- a/tests/mock_artist_repo.go +++ b/tests/mock_artist_repo.go @@ -6,7 +6,6 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" - "github.com/navidrome/navidrome/utils/slice" ) func CreateMockArtistRepo() *MockArtistRepo { @@ -117,14 +116,6 @@ 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 index 51ddf4b61..c482e2150 100644 --- a/tests/mock_artwork_queue_repo.go +++ b/tests/mock_artwork_queue_repo.go @@ -16,7 +16,7 @@ type MockArtworkQueueRepo struct { 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, when set, backs the set-difference insert 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 @@ -226,26 +226,6 @@ func (m *MockArtworkQueueRepo) CountQueued(kinds []model.Kind, priorities []int) return res, nil } -// CountAbsent mirrors the SQL predicate: an absent state is one with no hash. -func (m *MockArtworkQueueRepo) CountAbsent(kind model.Kind, attemptedBefore time.Time) (model.ArtworkAbsentStat, error) { - m.mu.Lock() - defer m.mu.Unlock() - var res model.ArtworkAbsentStat - if m.Err != nil || m.ItemArtworkSource == nil { - return res, m.Err - } - for _, ia := range m.ItemArtworkSource.ItemData { - if ia.ItemKind != kind.Prefix() || ia.Hash != "" { - continue - } - res.Total++ - if ia.AttemptedAt.Before(attemptedBefore) { - res.Stale++ - } - } - return res, nil -} - func (m *MockArtworkQueueRepo) EnqueuePreservingBackoff(items ...model.ArtworkQueueItem) error { m.mu.Lock() defer m.mu.Unlock() @@ -272,49 +252,24 @@ func (m *MockArtworkQueueRepo) EnqueuePreservingBackoff(items ...model.ArtworkQu return nil } -func (m *MockArtworkQueueRepo) EnqueueStaleAbsent(kind model.Kind, attemptedBefore time.Time, limit int) (int64, error) { - m.mu.Lock() - defer m.mu.Unlock() - if m.Err != nil || m.ItemArtworkSource == nil { - return 0, m.Err - } - var stale []model.ItemArtwork - for _, ia := range m.ItemArtworkSource.ItemData { - if ia.ItemKind == kind.Prefix() && ia.Hash == "" && ia.AttemptedAt.Before(attemptedBefore) { - stale = append(stale, ia) - } - } - slices.SortFunc(stale, func(a, b model.ItemArtwork) int { return a.AttemptedAt.Compare(b.AttemptedAt) }) - // The limit caps the selection, like the SQL's LIMIT before ON CONFLICT: queued rows use up budget. - stale = stale[:min(limit, len(stale))] - now := time.Now() - var inserted int64 - for _, ia := range stale { - 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 -} - -// matchingSource mirrors the SQL filter: no sources means every source, "" the absent state. +// matchingSource mirrors the SQL filter: no sources means every source, "" the absent state, and +// ArtworkSourceFailed the absent states that gave up. func (m *MockArtworkQueueRepo) matchingSource(kind model.Kind, sources []string) []model.ItemArtwork { if m.ItemArtworkSource == nil { return nil } + matches := func(ia model.ItemArtwork) bool { + if len(sources) == 0 { + return true + } + if slices.Contains(sources, ia.Source) { + return true + } + return slices.Contains(sources, model.ArtworkSourceFailed) && ia.Hash == "" && ia.LastFailure != "" + } var res []model.ItemArtwork for _, ia := range m.ItemArtworkSource.ItemData { - if ia.ItemKind == kind.Prefix() && (len(sources) == 0 || slices.Contains(sources, ia.Source)) { + if ia.ItemKind == kind.Prefix() && matches(ia) { res = append(res, ia) } } @@ -366,7 +321,7 @@ func (m *MockArtworkQueueRepo) EnqueueBySource(kind model.Kind, sources []string return inserted, nil } -// EnqueueMissing mirrors the SQL set-difference insert: ExistingIDs[kind] minus ItemArtworkSource. +// EnqueueAllMissing 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() diff --git a/tests/mock_mediafile_repo.go b/tests/mock_mediafile_repo.go index f18280fd5..7a1a8f926 100644 --- a/tests/mock_mediafile_repo.go +++ b/tests/mock_mediafile_repo.go @@ -130,14 +130,6 @@ func (m *MockMediaFileRepo) GetCursorWithArtwork(qo ...model.QueryOptions) (mode 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 f04ed98c6..0fa9618ae 100644 --- a/tests/mock_playlist_repo.go +++ b/tests/mock_playlist_repo.go @@ -7,7 +7,6 @@ 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 { @@ -54,14 +53,6 @@ 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_radio_repository.go b/tests/mock_radio_repository.go index 2baeadc5c..20f81ec45 100644 --- a/tests/mock_radio_repository.go +++ b/tests/mock_radio_repository.go @@ -5,7 +5,6 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" - "github.com/navidrome/navidrome/utils/slice" ) type MockedRadioRepo struct { @@ -74,14 +73,6 @@ 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") From bd46284087ad0ceed420e1aaf7f54e380637648b Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:16:14 -0700 Subject: [PATCH 53/78] feat: validate all configuration durations (#6002) * chore: ensure that all durations are nonnegative * make sure you actually include the test file * test(conf): use non-zero durations in the valid_duration fixture Zero is the boundary between the accepted and rejected ranges, so it passes even if the guard is off by one. 1s exercises an ordinary value. --------- Co-authored-by: Deluan --- conf/configuration.go | 27 +++++++++++++++-- conf/configuration_test.go | 45 +++++++++++++++++++++++++++++ conf/testdata/invalid_duration.toml | 12 ++++++++ conf/testdata/valid_duration.toml | 12 ++++++++ consts/consts.go | 6 ++++ 5 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 conf/testdata/invalid_duration.toml create mode 100644 conf/testdata/valid_duration.toml diff --git a/conf/configuration.go b/conf/configuration.go index b9c9f027e..ff119417a 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -351,6 +351,13 @@ func LoadFromFile(confFile string) { Load(true) } +func durationNonNegativeOrDefault(val *time.Duration, original time.Duration) { + if val.Nanoseconds() < 0 { + log.Warn("Duration is a negative value. Using default value", "value", *val, "default", original) + *val = original + } +} + func Load(noConfigDump bool) { parseIniFileConfiguration() remapEnvVarKeysFromConfig() @@ -418,6 +425,20 @@ func Load(noConfigDump bool) { log.SetLogSourceLine(Server.DevLogSourceLine) log.SetRedacting(Server.EnableLogRedacting) + durationNonNegativeOrDefault(&Server.SessionTimeout, consts.DefaultSessionTimeout) + durationNonNegativeOrDefault(&Server.SmartPlaylistRefreshDelay, consts.DefaultSmartRefresh) + durationNonNegativeOrDefault(&Server.DefaultShareExpiration, consts.DefaultShareExpiration) + durationNonNegativeOrDefault(&Server.UIPlaybackReportInterval, consts.DefaultUIPlaybackReportInterval) + durationNonNegativeOrDefault(&Server.AuthWindowLength, consts.DefaultAuthWindowLength) + durationNonNegativeOrDefault(&Server.Scanner.WatcherWait, consts.DefaultWatcherWait) + + durationNonNegativeOrDefault(&Server.DevActivityPanelUpdateRate, consts.DefaultActivityPanelUpdateRate) + durationNonNegativeOrDefault(&Server.DevArtworkThrottleBacklogTimeout, consts.RequestThrottleBacklogTimeout) + durationNonNegativeOrDefault(&Server.DevArtistInfoTimeToLive, consts.ArtistInfoTimeToLive) + durationNonNegativeOrDefault(&Server.DevAlbumInfoTimeToLive, consts.AlbumInfoTimeToLive) + durationNonNegativeOrDefault(&Server.DevInsightsInitialDelay, consts.InsightsInitialDelay) + durationNonNegativeOrDefault(&Server.DevPluginCompilationTimeout, consts.DefaultPluginCompilationTimeout) + // Log deprecated, removed and unknown options for _, o := range deprecatedOptions { logDeprecatedOptions(o.name, o.replacement) @@ -967,7 +988,7 @@ func setViperDefaults() { viper.SetDefault("autoimportplaylists", true) viper.SetDefault("defaultplaylistpublicvisibility", false) viper.SetDefault("playlistspath", "") - viper.SetDefault("smartPlaylistRefreshDelay", 5*time.Second) + viper.SetDefault("smartPlaylistRefreshDelay", consts.DefaultSmartRefresh) viper.SetDefault("enabledownloads", true) viper.SetDefault("enableexternalservices", true) viper.SetDefault("enablem3uexternalalbumart", false) @@ -1011,14 +1032,14 @@ func setViperDefaults() { viper.SetDefault("maximagesize", consts.DefaultMaxImageSize) viper.SetDefault("enablesharing", true) viper.SetDefault("shareurl", "") - viper.SetDefault("defaultshareexpiration", 8760*time.Hour) + viper.SetDefault("defaultshareexpiration", consts.DefaultShareExpiration) viper.SetDefault("defaultdownloadableshare", false) viper.SetDefault("gatrackingid", "") viper.SetDefault("enableinsightscollector", true) viper.SetDefault("enablescheduleddbanalyze", true) viper.SetDefault("enablelogredacting", true) viper.SetDefault("authrequestlimit", 5) - viper.SetDefault("authwindowlength", 20*time.Second) + viper.SetDefault("authwindowlength", consts.DefaultAuthWindowLength) viper.SetDefault("passwordencryptionkey", "") viper.SetDefault("extauth.userheader", "Remote-User") viper.SetDefault("extauth.trustedsources", "") diff --git a/conf/configuration_test.go b/conf/configuration_test.go index 344ab3595..2c7f8edaa 100644 --- a/conf/configuration_test.go +++ b/conf/configuration_test.go @@ -6,9 +6,11 @@ import ( "os" "path/filepath" "testing" + "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -453,6 +455,49 @@ var _ = Describe("Configuration", func() { Entry("INI format", "ini"), Entry("JSON format", "json"), ) + + It("should use default values for negative duration fields", func() { + filename := filepath.Join("testdata", "invalid_duration.toml") + conf.InitConfig(filename, false) + conf.Load(true) + + server := conf.Server + Expect(server.SessionTimeout).To(Equal(consts.DefaultSessionTimeout)) + Expect(server.SmartPlaylistRefreshDelay).To(Equal(consts.DefaultSmartRefresh)) + Expect(server.DefaultShareExpiration).To(Equal(consts.DefaultShareExpiration)) + Expect(server.UIPlaybackReportInterval).To(Equal(consts.DefaultUIPlaybackReportInterval)) + Expect(server.AuthWindowLength).To(Equal(consts.DefaultAuthWindowLength)) + Expect(server.Scanner.WatcherWait).To(Equal(consts.DefaultWatcherWait)) + + Expect(server.DevActivityPanelUpdateRate).To(Equal(consts.DefaultActivityPanelUpdateRate)) + Expect(server.DevArtworkThrottleBacklogTimeout).To(Equal(consts.RequestThrottleBacklogTimeout)) + Expect(server.DevArtistInfoTimeToLive).To(Equal(consts.ArtistInfoTimeToLive)) + Expect(server.DevAlbumInfoTimeToLive).To(Equal(consts.AlbumInfoTimeToLive)) + Expect(server.DevInsightsInitialDelay).To(Equal(consts.InsightsInitialDelay)) + Expect(server.DevPluginCompilationTimeout).To(Equal(consts.DefaultPluginCompilationTimeout)) + }) + + It("should use parsed values for duration fields", func() { + conf.InitConfig(filepath.Join("testdata", "valid_duration.toml"), false) + conf.Load(true) + + configured := 1 * time.Second + + server := conf.Server + Expect(server.SessionTimeout).To(Equal(configured)) + Expect(server.SmartPlaylistRefreshDelay).To(Equal(configured)) + Expect(server.DefaultShareExpiration).To(Equal(configured)) + Expect(server.UIPlaybackReportInterval).To(Equal(configured)) + Expect(server.AuthWindowLength).To(Equal(configured)) + Expect(server.Scanner.WatcherWait).To(Equal(configured)) + + Expect(server.DevActivityPanelUpdateRate).To(Equal(configured)) + Expect(server.DevArtworkThrottleBacklogTimeout).To(Equal(configured)) + Expect(server.DevArtistInfoTimeToLive).To(Equal(configured)) + Expect(server.DevAlbumInfoTimeToLive).To(Equal(configured)) + Expect(server.DevInsightsInitialDelay).To(Equal(configured)) + Expect(server.DevPluginCompilationTimeout).To(Equal(configured)) + }) }) var _ = Describe("TLSEnabled", func() { diff --git a/conf/testdata/invalid_duration.toml b/conf/testdata/invalid_duration.toml new file mode 100644 index 000000000..6540fef60 --- /dev/null +++ b/conf/testdata/invalid_duration.toml @@ -0,0 +1,12 @@ +SessionTimeout = "-10s" +SmartPlaylistRefreshDelay = "-10s" +UIPlaybackReportInterval = "-10s" +AuthWindowLength = "-10s" +DefaultShareExpiration = "-10s" +Scanner.WatcherWait = "-10s" +DevActivityPanelUpdateRate = "-10s" +DevArtworkThrottleBacklogTimeout = "-10s" +DevArtistInfoTimeToLive = "-10s" +DevAlbumInfoTimeToLive = "-10s" +DevInsightsInitialDelay = "-10s" +DevPluginCompilationTimeout = "-10s" diff --git a/conf/testdata/valid_duration.toml b/conf/testdata/valid_duration.toml new file mode 100644 index 000000000..6ec4af480 --- /dev/null +++ b/conf/testdata/valid_duration.toml @@ -0,0 +1,12 @@ +SessionTimeout = "1s" +SmartPlaylistRefreshDelay = "1s" +UIPlaybackReportInterval = "1s" +AuthWindowLength = "1s" +DefaultShareExpiration = "1s" +Scanner.WatcherWait = "1s" +DevActivityPanelUpdateRate = "1s" +DevArtworkThrottleBacklogTimeout = "1s" +DevArtistInfoTimeToLive = "1s" +DevAlbumInfoTimeToLive = "1s" +DevInsightsInitialDelay = "1s" +DevPluginCompilationTimeout = "1s" diff --git a/consts/consts.go b/consts/consts.go index 0f950ef10..486ea66bc 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -34,6 +34,8 @@ const ( JWTPublicSecretKey = "JWTPublicSecret" JWTIssuer = "ND" DefaultSessionTimeout = 48 * time.Hour + DefaultSmartRefresh = 5 * time.Second + DefaultShareExpiration = 8760 * time.Hour CookieExpiry = 365 * 24 * 3600 // One year DBAnalyzeCheckSchedule = "@every 30m" @@ -71,6 +73,7 @@ const ( DefaultUILoginBackgroundURLOffline = "data:image/png;base64," + DefaultUILoginBackgroundOffline DefaultMaxSidebarPlaylists = 100 + DefaultAuthWindowLength = 20 * time.Second RequestThrottleBacklogLimit = 100 RequestThrottleBacklogTimeout = time.Minute @@ -106,6 +109,9 @@ const ( DefaultScannerExtractor = "taglib" DefaultWatcherWait = 5 * time.Second Zwsp = string('\u200b') + + DefaultActivityPanelUpdateRate = 300 * time.Millisecond + DefaultPluginCompilationTimeout = time.Minute ) const ( From cb045b8ef3959f0ee225d65857b665dfc1508df4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Tue, 1 Sep 2026 23:02:15 -0400 Subject: [PATCH 54/78] ci: exclude tests/ from the coverage report on pull requests too (#6070) The exclusion only worked on master. Coverage profiles name files by import path; octocov shortens those to repo-relative paths using the checked-out source, but coverage-on-pr.yml sparse-checks-out only .octocov.yml, so the paths stay as github.com/navidrome/navidrome/tests/mock_*.go and 'tests/**' never matched. '**/*_gen.go' matched either way, which is why only the 30 tests/ files leaked. Every pull request since b77fb45 therefore reported ~-3.7% against master: 447 files on the base side, 477 on the pull request side (#6002, #6069). --- .octocov.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.octocov.yml b/.octocov.yml index 467998b51..c4ad10712 100644 --- a/.octocov.yml +++ b/.octocov.yml @@ -8,8 +8,10 @@ coverage: paths: - coverage.out # Not code under test: tests/ holds the mocks and helpers, *_gen.go is generated. + # Both patterns need the '**/' prefix: the comment workflow has no source tree, + # so octocov cannot shorten the profile's import paths to repo-relative ones. exclude: - - 'tests/**' + - '**/tests/**' - '**/*_gen.go' codeToTestRatio: # Needs the pull request's own source, which the comment workflow must not From 8407fe6dda7e2c24a618127fd5cade2608e9f1c0 Mon Sep 17 00:00:00 2001 From: polybjorn Date: Thu, 3 Sep 2026 00:49:50 +0000 Subject: [PATCH 55/78] fix(ui): reload the playlist after rating or loving a track (#6009) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A playlistTrack id is a position in the playlist, not a stable key, so refetching a row by id after the annotation is saved can return a different song: in a smart playlist filtered on that annotation the track is gone and every later row has shifted up. The stale-keyed record then renders as a duplicate of its neighbour. Signed-off-by: Bjørn A. Andersen Co-authored-by: Bjørn A. Andersen Co-authored-by: Deluan Quintão --- ui/src/common/useRating.jsx | 22 ++++++--------- ui/src/common/useRating.test.js | 43 +++++++++++++++-------------- ui/src/common/useToggleLove.jsx | 31 ++++++++++----------- ui/src/common/useToggleLove.test.js | 39 +++++++++++++------------- 4 files changed, 65 insertions(+), 70 deletions(-) diff --git a/ui/src/common/useRating.jsx b/ui/src/common/useRating.jsx index 2eb5d9eca..82e9bfc06 100644 --- a/ui/src/common/useRating.jsx +++ b/ui/src/common/useRating.jsx @@ -1,11 +1,12 @@ import { useState, useCallback, useEffect, useRef } from 'react' -import { useDataProvider, useNotify } from 'react-admin' +import { useDataProvider, useNotify, useRefresh } from 'react-admin' import subsonic from '../subsonic' export const useRating = (resource, record) => { const [loading, setLoading] = useState(false) const notify = useNotify() const dataProvider = useDataProvider() + const refresh = useRefresh() const mountedRef = useRef(false) const rating = record.rating @@ -17,23 +18,18 @@ export const useRating = (resource, record) => { }, []) const refreshRating = useCallback(() => { - // For playlist tracks, refresh both resources to keep data in sync if (record.mediaFileId) { - // This is a playlist track - refresh both the playlist track and the song - const promises = [ - dataProvider.getOne('song', { id: record.mediaFileId }), - dataProvider.getOne('playlistTrack', { - id: record.id, - filter: { playlist_id: record.playlistId }, - }), - ] - - Promise.all(promises) + // A playlistTrack id is a position, not a stable key: rating a song can drop it out + // of a smart playlist, and that position then holds a different track. Refetching + // the row by id would write the neighbour's data under this row, so reload the list. + dataProvider + .getOne('song', { id: record.mediaFileId }) .catch((e) => { // eslint-disable-next-line no-console console.log('Error encountered: ' + e) }) .finally(() => { + refresh() if (mountedRef.current) { setLoading(false) } @@ -52,7 +48,7 @@ export const useRating = (resource, record) => { } }) } - }, [dataProvider, record.id, record.mediaFileId, record.playlistId, resource]) + }, [dataProvider, record.id, record.mediaFileId, refresh, resource]) const rate = (val, id) => { setLoading(true) diff --git a/ui/src/common/useRating.test.js b/ui/src/common/useRating.test.js index b1353512e..ffe9abffd 100644 --- a/ui/src/common/useRating.test.js +++ b/ui/src/common/useRating.test.js @@ -4,6 +4,8 @@ import { useRating } from './useRating' import subsonic from '../subsonic' import { useDataProvider } from 'react-admin' +const mockRefresh = vi.fn() + vi.mock('../subsonic', () => ({ default: { setRating: vi.fn(() => Promise.resolve()), @@ -16,13 +18,16 @@ vi.mock('react-admin', async () => { ...actual, useDataProvider: vi.fn(), useNotify: vi.fn(() => vi.fn()), + useRefresh: vi.fn(() => mockRefresh), } }) describe('useRating', () => { let getOne beforeEach(() => { - getOne = vi.fn(() => Promise.resolve()) + getOne = vi.fn((resource, params) => + Promise.resolve({ data: { id: params.id } }), + ) useDataProvider.mockReturnValue({ getOne }) vi.clearAllMocks() }) @@ -56,9 +61,9 @@ describe('useRating', () => { }) describe('playlist track scenarios', () => { - it('refreshes both playlist track and song for playlist tracks', async () => { + it('refreshes the song and reloads the list for playlist tracks', async () => { const record = { - id: 'pt-1', + id: '1', mediaFileId: 'sg-1', playlistId: 'pl-1', rating: 2, @@ -71,18 +76,21 @@ describe('useRating', () => { // Should rate using the media file ID expect(subsonic.setRating).toHaveBeenCalledWith('sg-1', 5) - // Should refresh both the playlist track and the song - expect(getOne).toHaveBeenCalledTimes(2) - expect(getOne).toHaveBeenCalledWith('playlistTrack', { - id: 'pt-1', - filter: { playlist_id: 'pl-1' }, - }) + // The row is a position in the playlist, so it cannot be refetched by id: + // rating can drop the track out of a smart playlist and shift every row up + expect(getOne).toHaveBeenCalledTimes(1) expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' }) + expect(getOne).not.toHaveBeenCalledWith( + 'playlistTrack', + expect.anything(), + ) + expect(mockRefresh).toHaveBeenCalled() }) - it('includes playlist_id filter when refreshing playlist tracks', async () => { + it('reloads the list even when the song refresh fails', async () => { + getOne.mockImplementation(() => Promise.reject(new Error('boom'))) const record = { - id: 'pt-5', + id: '5', mediaFileId: 'sg-10', playlistId: 'pl-123', rating: 1, @@ -92,16 +100,8 @@ describe('useRating', () => { await result.current[0](3, 'sg-10') }) - // Should rate using the media file ID expect(subsonic.setRating).toHaveBeenCalledWith('sg-10', 3) - - // Should refresh playlist track with correct playlist_id filter - expect(getOne).toHaveBeenCalledWith('playlistTrack', { - id: 'pt-5', - filter: { playlist_id: 'pl-123' }, - }) - // Should also refresh the underlying song - expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-10' }) + expect(mockRefresh).toHaveBeenCalled() }) it('only refreshes original resource when no mediaFileId present', async () => { @@ -111,9 +111,10 @@ describe('useRating', () => { await result.current[0](2, 'sg-1') }) - // Should only refresh the original resource (song) + // Should only refresh the original resource (song), without reloading the list expect(getOne).toHaveBeenCalledTimes(1) expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' }) + expect(mockRefresh).not.toHaveBeenCalled() }) it('does not include playlist_id filter for non-playlist resources', async () => { diff --git a/ui/src/common/useToggleLove.jsx b/ui/src/common/useToggleLove.jsx index 3f98a2e21..22468cbe1 100644 --- a/ui/src/common/useToggleLove.jsx +++ b/ui/src/common/useToggleLove.jsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react' -import { useDataProvider, useNotify } from 'react-admin' +import { useDataProvider, useNotify, useRefresh } from 'react-admin' import subsonic from '../subsonic' export const useToggleLove = (resource, record = {}) => { @@ -15,33 +15,32 @@ export const useToggleLove = (resource, record = {}) => { }, []) const dataProvider = useDataProvider() + const refresh = useRefresh() const refreshRecord = useCallback(() => { - const promises = [] + // A playlistTrack id is a position, not a stable key: loving a song can drop it out of + // a smart playlist, and that position then holds a different track. Refetching the row + // by id would write the neighbour's data under this row, so reload the list instead. + const isPlaylistTrack = !!record.mediaFileId + const target = isPlaylistTrack + ? { resource: 'song', params: { id: record.mediaFileId } } + : { resource, params: { id: record.id } } - // Always refresh the original resource - const params = { id: record.id } - if (record.playlistId) { - params.filter = { playlist_id: record.playlistId } - } - promises.push(dataProvider.getOne(resource, params)) - - // If we have a mediaFileId, also refresh the song - if (record.mediaFileId) { - promises.push(dataProvider.getOne('song', { id: record.mediaFileId })) - } - - Promise.all(promises) + dataProvider + .getOne(target.resource, target.params) .catch((e) => { // eslint-disable-next-line no-console console.log('Error encountered: ' + e) }) .finally(() => { + if (isPlaylistTrack) { + refresh() + } if (mountedRef.current) { setLoading(false) } }) - }, [dataProvider, record.mediaFileId, record.id, record.playlistId, resource]) + }, [dataProvider, record.mediaFileId, record.id, refresh, resource]) const toggleLove = () => { const toggle = record.starred ? subsonic.unstar : subsonic.star diff --git a/ui/src/common/useToggleLove.test.js b/ui/src/common/useToggleLove.test.js index 640e9ff89..10db1b3c3 100644 --- a/ui/src/common/useToggleLove.test.js +++ b/ui/src/common/useToggleLove.test.js @@ -4,6 +4,8 @@ import { useToggleLove } from './useToggleLove' import subsonic from '../subsonic' import { useDataProvider } from 'react-admin' +const mockRefresh = vi.fn() + vi.mock('../subsonic', () => ({ default: { star: vi.fn(() => Promise.resolve()), @@ -17,6 +19,7 @@ vi.mock('react-admin', async () => { ...actual, useDataProvider: vi.fn(), useNotify: vi.fn(() => vi.fn()), + useRefresh: vi.fn(() => mockRefresh), } }) @@ -58,9 +61,9 @@ describe('useToggleLove', () => { }) describe('playlist track scenarios', () => { - it('refreshes both playlist track and song for playlist tracks', async () => { + it('refreshes the song and reloads the list for playlist tracks', async () => { const record = { - id: 'pt-1', + id: '1', mediaFileId: 'sg-1', playlistId: 'pl-1', starred: false, @@ -75,18 +78,21 @@ describe('useToggleLove', () => { // Should star using the media file ID expect(subsonic.star).toHaveBeenCalledWith('sg-1') - // Should refresh both the playlist track and the song - expect(getOne).toHaveBeenCalledTimes(2) - expect(getOne).toHaveBeenCalledWith('playlistTrack', { - id: 'pt-1', - filter: { playlist_id: 'pl-1' }, - }) + // The row is a position in the playlist, so it cannot be refetched by id: + // loving can drop the track out of a smart playlist and shift every row up + expect(getOne).toHaveBeenCalledTimes(1) expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' }) + expect(getOne).not.toHaveBeenCalledWith( + 'playlistTrack', + expect.anything(), + ) + expect(mockRefresh).toHaveBeenCalled() }) - it('includes playlist_id filter when refreshing playlist tracks', async () => { + it('reloads the list even when the song refresh fails', async () => { + getOne.mockImplementation(() => Promise.reject(new Error('boom'))) const record = { - id: 'pt-5', + id: '5', mediaFileId: 'sg-10', playlistId: 'pl-123', starred: true, @@ -98,16 +104,8 @@ describe('useToggleLove', () => { await result.current[0]() }) - // Should unstar using the media file ID expect(subsonic.unstar).toHaveBeenCalledWith('sg-10') - - // Should refresh playlist track with correct playlist_id filter - expect(getOne).toHaveBeenCalledWith('playlistTrack', { - id: 'pt-5', - filter: { playlist_id: 'pl-123' }, - }) - // Should also refresh the underlying song - expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-10' }) + expect(mockRefresh).toHaveBeenCalled() }) it('only refreshes original resource when no mediaFileId present', async () => { @@ -117,9 +115,10 @@ describe('useToggleLove', () => { await result.current[0]() }) - // Should only refresh the original resource (song) + // Should only refresh the original resource (song), without reloading the list expect(getOne).toHaveBeenCalledTimes(1) expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' }) + expect(mockRefresh).not.toHaveBeenCalled() }) it('does not include playlist_id filter for non-playlist resources', async () => { From afb3a2f881a245c28cc7b65888f51ea1a2cbbb3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Wed, 2 Sep 2026 23:29:31 -0400 Subject: [PATCH 56/78] feat(ui): add Refresh Metadata action to the album and artist pages (#6078) The Refresh Metadata action was only reachable from the Album and Artist context menus, so it could not be triggered from AlbumShow or ArtistShow. This adds an icon-only button, with a tooltip, to the action toolbar on both detail pages. Like the menu entry, it is only rendered for admins. The button is built on react-admin's Button rather than a plain IconButton: the surrounding toolbars use the former, so the theme colour and the icon-only swap at the xs breakpoint are inherited instead of restated. The dataProvider call and its two notifications move into a new useRefreshMetadata hook, which ContextMenus now shares, keeping a single copy of that logic. --- ui/src/album/AlbumActions.jsx | 7 +- ui/src/album/AlbumActions.test.jsx | 68 ++++++++++++ ui/src/artist/ArtistActions.jsx | 7 ++ ui/src/artist/ArtistActions.test.jsx | 32 +++++- ui/src/common/ContextMenus.jsx | 8 +- ui/src/common/RefreshMetadataButton.jsx | 62 +++++++++++ ui/src/common/RefreshMetadataButton.test.jsx | 104 +++++++++++++++++++ ui/src/common/index.js | 2 + ui/src/common/useRefreshMetadata.js | 16 +++ 9 files changed, 298 insertions(+), 8 deletions(-) create mode 100644 ui/src/album/AlbumActions.test.jsx create mode 100644 ui/src/common/RefreshMetadataButton.jsx create mode 100644 ui/src/common/RefreshMetadataButton.test.jsx create mode 100644 ui/src/common/useRefreshMetadata.js diff --git a/ui/src/album/AlbumActions.jsx b/ui/src/album/AlbumActions.jsx index 96cfab09a..c3b3a4bc2 100644 --- a/ui/src/album/AlbumActions.jsx +++ b/ui/src/album/AlbumActions.jsx @@ -27,7 +27,7 @@ import { } from '../actions' import { formatBytes } from '../utils' import config from '../config' -import { ToggleFieldsMenu } from '../common' +import { RefreshMetadataButton, ToggleFieldsMenu } from '../common' const useStyles = makeStyles({ toolbar: { display: 'flex', justifyContent: 'space-between', width: '100%' }, @@ -138,6 +138,11 @@ const AlbumActions = ({ )} +
{isNotSmall && }
diff --git a/ui/src/album/AlbumActions.test.jsx b/ui/src/album/AlbumActions.test.jsx new file mode 100644 index 000000000..246a15558 --- /dev/null +++ b/ui/src/album/AlbumActions.test.jsx @@ -0,0 +1,68 @@ +import React from 'react' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { ThemeProvider, createTheme } from '@material-ui/core/styles' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import AlbumActions from './AlbumActions' + +const { mockConfig, mockPermissions } = vi.hoisted(() => ({ + mockConfig: { + enableSharing: false, + enableDownloads: false, + losslessFormats: 'FLAC,WAV', + }, + mockPermissions: { value: 'admin' }, +})) +vi.mock('../config', () => ({ default: mockConfig })) + +vi.mock('react-redux', () => ({ + useDispatch: () => vi.fn(), + useSelector: () => ({}), +})) + +const mockRefreshMetadata = vi.fn() + +vi.mock('react-admin', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useNotify: () => vi.fn(), + useDataProvider: () => ({ refreshMetadata: mockRefreshMetadata }), + usePermissions: () => ({ permissions: mockPermissions.value }), + useTranslate: () => (x) => x, + } +}) + +describe('AlbumActions', () => { + const record = { id: 'al1', name: 'Album', size: 1024 } + const refreshLabel = 'resources.album.actions.refresh' + + beforeEach(() => { + vi.clearAllMocks() + mockPermissions.value = 'admin' + mockRefreshMetadata.mockResolvedValue({ data: { id: 'al1' } }) + }) + + const renderAlbumActions = () => + render( + + + , + ) + + it('refreshes the album metadata for admins', async () => { + renderAlbumActions() + fireEvent.click(screen.getByRole('button', { name: refreshLabel })) + + await waitFor(() => + expect(mockRefreshMetadata).toHaveBeenCalledWith('album', 'al1'), + ) + }) + + it('hides the action for non-admin users', () => { + mockPermissions.value = 'regular' + renderAlbumActions() + expect( + screen.queryByRole('button', { name: refreshLabel }), + ).not.toBeInTheDocument() + }) +}) diff --git a/ui/src/artist/ArtistActions.jsx b/ui/src/artist/ArtistActions.jsx index 2f329a034..c2614c05a 100644 --- a/ui/src/artist/ArtistActions.jsx +++ b/ui/src/artist/ArtistActions.jsx @@ -26,6 +26,7 @@ import { import config from '../config' import { formatBytes } from '../utils' import { artistDownloadSize } from '../common/artist' +import { RefreshMetadataButton } from '../common/RefreshMetadataButton' const useStyles = makeStyles((theme) => ({ toolbar: { @@ -33,6 +34,7 @@ const useStyles = makeStyles((theme) => ({ padding: '0 !important', background: 'transparent', boxShadow: 'none', + alignItems: 'center', '& .MuiToolbar-root': { minHeight: 'auto', padding: '0 !important', @@ -174,6 +176,11 @@ const ArtistActions = ({ className, record, ...rest }) => { icon={} /> )} + ) } diff --git a/ui/src/artist/ArtistActions.test.jsx b/ui/src/artist/ArtistActions.test.jsx index ad25177b3..05f53f835 100644 --- a/ui/src/artist/ArtistActions.test.jsx +++ b/ui/src/artist/ArtistActions.test.jsx @@ -18,20 +18,26 @@ vi.mock('../subsonic', () => ({ default: { getSimilarSongs2: vi.fn(), getTopSongs: vi.fn() }, })) -const { mockConfig } = vi.hoisted(() => ({ +const { mockConfig, mockPermissions } = vi.hoisted(() => ({ mockConfig: { enableSharing: true, enableDownloads: true }, + mockPermissions: { value: 'admin' }, })) vi.mock('../config', () => ({ default: mockConfig })) const mockNotify = vi.fn() const mockGetList = vi.fn().mockResolvedValue({ data: [{ id: 's1' }] }) +const mockRefreshMetadata = vi.fn().mockResolvedValue({ data: { id: 'ar1' } }) vi.mock('react-admin', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useNotify: () => mockNotify, - useDataProvider: () => ({ getList: mockGetList }), + useDataProvider: () => ({ + getList: mockGetList, + refreshMetadata: mockRefreshMetadata, + }), + usePermissions: () => ({ permissions: mockPermissions.value }), useTranslate: () => (x) => x, } }) @@ -64,6 +70,7 @@ describe('ArtistActions', () => { vi.spyOn(console, 'error').mockImplementation(() => {}) mockConfig.enableSharing = true mockConfig.enableDownloads = true + mockPermissions.value = 'admin' const songWithReplayGain = { id: 'rec1', @@ -290,4 +297,25 @@ describe('ArtistActions', () => { expect(screen.queryByText(/ra\.action\.download/)).not.toBeInTheDocument() }) }) + + describe('Refresh metadata action', () => { + const refreshLabel = 'resources.album.actions.refresh' + + it('refreshes the artist metadata for admins', async () => { + renderArtistActions() + fireEvent.click(screen.getByRole('button', { name: refreshLabel })) + + await waitFor(() => + expect(mockRefreshMetadata).toHaveBeenCalledWith('artist', 'ar1'), + ) + }) + + it('hides the action for non-admin users', () => { + mockPermissions.value = 'regular' + renderArtistActions() + expect( + screen.queryByRole('button', { name: refreshLabel }), + ).not.toBeInTheDocument() + }) + }) }) diff --git a/ui/src/common/ContextMenus.jsx b/ui/src/common/ContextMenus.jsx index 606506459..4407392c1 100644 --- a/ui/src/common/ContextMenus.jsx +++ b/ui/src/common/ContextMenus.jsx @@ -30,6 +30,7 @@ import { LoveButton } from './LoveButton' import config from '../config' import { formatBytes } from '../utils' import { artistDownloadSize } from './artist' +import { useRefreshMetadata } from './useRefreshMetadata' const useStyles = makeStyles({ noWrap: { @@ -75,6 +76,7 @@ const ContextMenu = ({ const translate = useTranslate() const notify = useNotify() const { permissions } = usePermissions() + const refreshMetadata = useRefreshMetadata() const [anchorEl, setAnchorEl] = useState(null) const isArtist = resource === 'artist' @@ -139,11 +141,7 @@ const ContextMenu = ({ enabled: permissions === 'admin', needData: false, label: translate('resources.album.actions.refresh'), - action: (record) => - dataProvider - .refreshMetadata(resource, record.id) - .then(() => notify('message.metadataRefreshStarted')) - .catch(() => notify('ra.page.error', 'warning')), + action: (record) => refreshMetadata(resource, record.id), }, ...(!hideInfo && { info: { diff --git a/ui/src/common/RefreshMetadataButton.jsx b/ui/src/common/RefreshMetadataButton.jsx new file mode 100644 index 000000000..aa0533ade --- /dev/null +++ b/ui/src/common/RefreshMetadataButton.jsx @@ -0,0 +1,62 @@ +import React, { useCallback } from 'react' +import PropTypes from 'prop-types' +import Tooltip from '@material-ui/core/Tooltip' +import { makeStyles } from '@material-ui/core/styles' +import { Button, usePermissions, useTranslate } from 'react-admin' +import { MdRefresh } from 'react-icons/md' +import { useRefreshMetadata } from './useRefreshMetadata' + +const useStyles = makeStyles({ + // Tooltip needs a ref-holding child, and react-admin's Button does not forward one. + wrapper: { display: 'inline-flex', verticalAlign: 'middle' }, + button: { minWidth: 'auto' }, +}) + +// react-admin's Button, not an IconButton: the toolbars use it, so colour and the icon-only swap +// at xs match without restating either rule. +export const RefreshMetadataButton = ({ + resource, + record, + className, + size, +}) => { + const translate = useTranslate() + const { permissions } = usePermissions() + const refreshMetadata = useRefreshMetadata() + const classes = useStyles() + + const handleClick = useCallback( + () => refreshMetadata(resource, record?.id), + [refreshMetadata, resource, record], + ) + + if (permissions !== 'admin' || !record?.id) return null + + const label = translate('resources.album.actions.refresh') + return ( + + + + + + ) +} + +RefreshMetadataButton.propTypes = { + resource: PropTypes.oneOf(['album', 'artist']).isRequired, + record: PropTypes.object, + className: PropTypes.string, + size: PropTypes.oneOf(['small', 'medium']), +} + +RefreshMetadataButton.defaultProps = { + size: 'small', +} diff --git a/ui/src/common/RefreshMetadataButton.test.jsx b/ui/src/common/RefreshMetadataButton.test.jsx new file mode 100644 index 000000000..7bda0497c --- /dev/null +++ b/ui/src/common/RefreshMetadataButton.test.jsx @@ -0,0 +1,104 @@ +import React from 'react' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { ThemeProvider, createTheme } from '@material-ui/core/styles' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { RefreshMetadataButton } from './RefreshMetadataButton' + +const mockNotify = vi.fn() +const mockRefreshMetadata = vi.fn() +const { mockPermissions } = vi.hoisted(() => ({ + mockPermissions: { value: 'admin' }, +})) + +vi.mock('react-admin', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useNotify: () => mockNotify, + useDataProvider: () => ({ refreshMetadata: mockRefreshMetadata }), + usePermissions: () => ({ permissions: mockPermissions.value }), + useTranslate: () => (x) => x, + } +}) + +describe('RefreshMetadataButton', () => { + const record = { id: 'al-1', name: 'Album' } + + beforeEach(() => { + vi.clearAllMocks() + mockPermissions.value = 'admin' + mockRefreshMetadata.mockResolvedValue({ data: { id: 'al-1' } }) + }) + + const renderButton = (props = {}) => + render( + + + , + ) + + it('renders an icon-only button labelled by the refresh action', () => { + renderButton() + const button = screen.getByRole('button', { + name: 'resources.album.actions.refresh', + }) + expect(button).toBeInTheDocument() + expect(button).toHaveTextContent('') + }) + + it('shows the label as a tooltip on hover', async () => { + renderButton() + fireEvent.mouseOver( + screen.getByRole('button', { + name: 'resources.album.actions.refresh', + }), + ) + await waitFor(() => + expect(screen.getByRole('tooltip')).toHaveTextContent( + 'resources.album.actions.refresh', + ), + ) + }) + + it('renders nothing for non-admin users', () => { + mockPermissions.value = 'regular' + const { container } = renderButton() + expect(container).toBeEmptyDOMElement() + }) + + it('renders nothing without a record', () => { + const { container } = renderButton({ record: undefined }) + expect(container).toBeEmptyDOMElement() + }) + + it('requests a refresh for the record and notifies success', async () => { + renderButton() + fireEvent.click(screen.getByRole('button')) + + await waitFor(() => + expect(mockRefreshMetadata).toHaveBeenCalledWith('album', 'al-1'), + ) + await waitFor(() => + expect(mockNotify).toHaveBeenCalledWith('message.metadataRefreshStarted'), + ) + }) + + it('passes the artist resource through', async () => { + renderButton({ resource: 'artist', record: { id: 'ar-1' } }) + fireEvent.click(screen.getByRole('button')) + + await waitFor(() => + expect(mockRefreshMetadata).toHaveBeenCalledWith('artist', 'ar-1'), + ) + }) + + it('notifies a warning when the request fails', async () => { + mockRefreshMetadata.mockRejectedValue(new Error('boom')) + renderButton() + fireEvent.click(screen.getByRole('button')) + + await waitFor(() => + expect(mockNotify).toHaveBeenCalledWith('ra.page.error', 'warning'), + ) + }) +}) diff --git a/ui/src/common/index.js b/ui/src/common/index.js index 047f5b3cf..0177df326 100644 --- a/ui/src/common/index.js +++ b/ui/src/common/index.js @@ -36,6 +36,8 @@ export * from './useTraceUpdate' export * from './Writable' export * from './SongSimpleList' export * from './RatingField' +export * from './RefreshMetadataButton' +export * from './useRefreshMetadata' export * from './useRating' export * from './useSelectedFields' export * from './ToggleFieldsMenu' diff --git a/ui/src/common/useRefreshMetadata.js b/ui/src/common/useRefreshMetadata.js new file mode 100644 index 000000000..d34042919 --- /dev/null +++ b/ui/src/common/useRefreshMetadata.js @@ -0,0 +1,16 @@ +import { useCallback } from 'react' +import { useDataProvider, useNotify } from 'react-admin' + +export const useRefreshMetadata = () => { + const dataProvider = useDataProvider() + const notify = useNotify() + + return useCallback( + (resource, id) => + dataProvider + .refreshMetadata(resource, id) + .then(() => notify('message.metadataRefreshStarted')) + .catch(() => notify('ra.page.error', 'warning')), + [dataProvider, notify], + ) +} From a7365e119b4b2fdc3debbff7d8d801c3417f2824 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Thu, 3 Sep 2026 16:07:53 -0400 Subject: [PATCH 57/78] fix(subsonic): update the playlist `changed` timestamp when renaming a smart playlist (#6082) `buildPlaylist` reported `evaluated_at` as `changed` for smart playlists, so a rename or comment edit was invisible to clients until the next evaluation. A never-evaluated smart playlist also reported the current time on every call, which never settled. Report `updated_at` for every playlist. `refreshCounters` now syncs the stamp it writes back onto the model, and `refreshSmartPlaylist` reuses it for `evaluated_at`. `changed` therefore still equals the evaluation time for a just-evaluated playlist, and `validUntil` stays anchored to it. Original Subsonic always bumps `changed` on any playlist update, so this also aligns the behavior with upstream. --- persistence/playlist_repository.go | 4 +++- persistence/smart_playlist_repository.go | 3 ++- persistence/smart_playlist_repository_test.go | 17 +++++++++++++++++ server/subsonic/playlists.go | 11 +---------- server/subsonic/playlists_test.go | 19 +++++++++++++++++-- 5 files changed, 40 insertions(+), 14 deletions(-) diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index bf8d6d5a8..bc6bffd25 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -316,11 +316,12 @@ func (r *playlistRepository) refreshCounters(pls *model.Playlist) error { } // Update playlist's total duration, size and count + now := time.Now() upd := Update("playlist"). Set("duration", res.Duration). Set("size", res.Size). Set("song_count", res.Count). - Set("updated_at", time.Now()). + Set("updated_at", now). Where(Eq{"id": pls.ID}) _, err = r.executeSQL(upd) if err != nil { @@ -329,6 +330,7 @@ func (r *playlistRepository) refreshCounters(pls *model.Playlist) error { pls.SongCount = int(res.Count) pls.Duration = res.Duration pls.Size = int64(res.Size) + pls.UpdatedAt = now return nil } diff --git a/persistence/smart_playlist_repository.go b/persistence/smart_playlist_repository.go index 65ae4656b..9d2ac9590 100644 --- a/persistence/smart_playlist_repository.go +++ b/persistence/smart_playlist_repository.go @@ -58,7 +58,8 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool { return false } - now := time.Now() + // Reuse the stamp refreshCounters just wrote, so evaluated_at and updated_at agree + now := pls.UpdatedAt updSql := Update(r.tableName).Set("evaluated_at", now).Where(Eq{"id": pls.ID}) if _, err = r.executeSQL(updSql); err != nil { log.Error(r.ctx, "Error updating smart playlist", "playlist", pls.Name, "id", pls.ID, err) diff --git a/persistence/smart_playlist_repository_test.go b/persistence/smart_playlist_repository_test.go index ddc155fab..6f8684d5c 100644 --- a/persistence/smart_playlist_repository_test.go +++ b/persistence/smart_playlist_repository_test.go @@ -45,6 +45,23 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { }) }) + Context("after an evaluation", func() { + It("stamps updated_at and evaluated_at with the same instant", func() { + newPls := model.Playlist{Name: "Evaluated", OwnerID: "userid", Rules: rules} + Expect(repo.Put(&newPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(newPls.ID) }) + + refreshed, err := repo.GetWithTracks(newPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + stored, err := repo.Get(newPls.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(stored.EvaluatedAt).ToNot(BeNil()) + Expect(stored.UpdatedAt).To(BeTemporally("==", *stored.EvaluatedAt)) + Expect(refreshed.UpdatedAt).To(BeTemporally("==", stored.UpdatedAt)) + }) + }) + Context("invalid rules", func() { It("fails to Put it in the DB", func() { rules = &criteria.Criteria{ diff --git a/server/subsonic/playlists.go b/server/subsonic/playlists.go index 774a9c430..e64fc9e82 100644 --- a/server/subsonic/playlists.go +++ b/server/subsonic/playlists.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "net/http" - "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" @@ -133,15 +132,7 @@ func (api *Router) buildPlaylist(ctx context.Context, p model.Playlist) response pls.SongCount = int32(p.SongCount) pls.Duration = int32(p.Duration) pls.Created = p.CreatedAt - if p.IsSmartPlaylist() { - if p.EvaluatedAt != nil { - pls.Changed = *p.EvaluatedAt - } else { - pls.Changed = time.Now() - } - } else { - pls.Changed = p.UpdatedAt - } + pls.Changed = p.UpdatedAt player, ok := request.PlayerFrom(ctx) if ok && isClientInList(conf.Server.Subsonic.MinimalClients, player.Client) { diff --git a/server/subsonic/playlists_test.go b/server/subsonic/playlists_test.go index f18f33b47..c7775c0fa 100644 --- a/server/subsonic/playlists_test.go +++ b/server/subsonic/playlists_test.go @@ -220,7 +220,7 @@ var _ = Describe("buildPlaylist", func() { Expect(result.SongCount).To(Equal(int32(10))) Expect(result.Duration).To(Equal(int32(600))) Expect(result.Created).To(Equal(playlist.CreatedAt)) - Expect(result.Changed).To(Equal(evaluatedAt)) + Expect(result.Changed).To(Equal(playlist.UpdatedAt)) // These should not be set Expect(result.Comment).To(BeEmpty()) @@ -245,7 +245,7 @@ var _ = Describe("buildPlaylist", func() { Expect(result.SongCount).To(Equal(int32(10))) Expect(result.Duration).To(Equal(int32(600))) Expect(result.Created).To(Equal(playlist.CreatedAt)) - Expect(result.Changed).To(Equal(*playlist.EvaluatedAt)) + Expect(result.Changed).To(Equal(playlist.UpdatedAt)) Expect(result.Comment).To(Equal("Test comment")) Expect(result.Owner).To(Equal("admin")) Expect(result.Public).To(BeTrue()) @@ -271,6 +271,21 @@ var _ = Describe("buildPlaylist", func() { }) }) + Context("when it was never evaluated", func() { + BeforeEach(func() { + playlist.EvaluatedAt = nil + player := model.Player{Client: "regular-client"} + ctx = request.WithPlayer(ctx, player) + }) + + It("omits validUntil but still reports changed", func() { + result := router.buildPlaylist(ctx, playlist) + + Expect(result.ValidUntil).To(BeNil()) + Expect(result.Changed).To(Equal(playlist.UpdatedAt)) + }) + }) + Context("with a per-playlist refreshDelay", func() { BeforeEach(func() { playlist.Rules.RefreshDelay = 24 * time.Hour From 330da83efff42e67d5f065ae052a31adcb6bb15e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 5 Sep 2026 13:52:07 -0400 Subject: [PATCH 58/78] chore(deps): bump TagLib to 2.3.2 (#6088) See https://github.com/taglib/taglib/releases/tag/v2.3.2 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index cdf8fc699..4339b9c55 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/navidrome/navidrome go 1.27 // Fork to implement raw tags support -replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3 +replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260905051825-df1d035571df require ( github.com/Masterminds/squirrel v1.5.4 diff --git a/go.sum b/go.sum index d852285f1..71d9facfd 100644 --- a/go.sum +++ b/go.sum @@ -29,8 +29,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= -github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3 h1:j7eSXqgtjhlNfwnMEzRdXnJGZTEw4I7J9TeQAll83bU= -github.com/deluan/go-taglib v0.0.0-20260720134629-a133b9719ea3/go.mod h1:QGxQ4Z1IWyY9w56xNEFjYAaWE8uSxA/gneQ7RPcFJrY= +github.com/deluan/go-taglib v0.0.0-20260905051825-df1d035571df h1:LdLQVAWVc6hCzqnrfVIEXOhP+r0iSit+EvsXwZDyL70= +github.com/deluan/go-taglib v0.0.0-20260905051825-df1d035571df/go.mod h1:QGxQ4Z1IWyY9w56xNEFjYAaWE8uSxA/gneQ7RPcFJrY= github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf h1:tb246l2Zmpt/GpF9EcHCKTtwzrd0HGfEmoODFA/qnk4= github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8= github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 h1:wSCnggTs2f2ji6nFwQmfwgINcmSMj0xF0oHnoyRSPe4= From 546302576ab7d19109bc651fd136f595d4e1e92f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 5 Sep 2026 21:44:03 -0400 Subject: [PATCH 59/78] fix(jellyfin): emit TranscodingUrl without the /jellyfin base path (#6089) PlaybackInfo returned a TranscodingUrl prefixed with the /jellyfin mount path. Clients concatenate that value onto a server base URL that already carries the prefix, producing /jellyfin/jellyfin/Audio/{id}/universal and a 404, so playback never started. Jellify, jellyfin-web, jellyfin-vue and Streamyfin all consume the field this way; real Jellyfin emits it server-relative (StreamInfo.ToUrl is called with a nil baseUrl). Emit the path server-relative to match. Finamp is unaffected: it builds its own stream URLs and never reads the field. --- server/jellyfin/e2e/streaming_test.go | 12 +++++------- server/jellyfin/stream.go | 9 +++------ 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/server/jellyfin/e2e/streaming_test.go b/server/jellyfin/e2e/streaming_test.go index 954981910..67208aed3 100644 --- a/server/jellyfin/e2e/streaming_test.go +++ b/server/jellyfin/e2e/streaming_test.go @@ -113,14 +113,12 @@ var _ = Describe("Streaming", func() { var info dto.PlaybackInfoResponse parseInto(get("/Items/"+enc(id)+"/PlaybackInfo"), &info) streamURL := info.MediaSources[0].TranscodingUrl - // The URL includes the /jellyfin mount prefix so a client resolving it as an absolute - // host path hits the mounted router. - Expect(streamURL).To(HavePrefix(consts.URLPathJellyfinAPI + "/Audio/" + enc(id) + "/universal")) + // Server-relative: clients append it to a base URL already carrying /jellyfin. + Expect(streamURL).To(HavePrefix("/Audio/" + enc(id) + "/universal")) + Expect(streamURL).ToNot(HavePrefix(consts.URLPathJellyfinAPI)) Expect(streamURL).To(ContainSubstring("api_key=")) - // The embedded api_key alone must authenticate the stream — no auth header sent. The e2e - // router is mounted at the root, so strip the /jellyfin prefix before replaying. - replayURL := strings.TrimPrefix(streamURL, consts.URLPathJellyfinAPI) - w := rawReq("GET", replayURL, "") + // The embedded api_key alone must authenticate the stream — no auth header sent. + w := rawReq("GET", streamURL, "") Expect(w.Code).To(Equal(http.StatusOK)) Expect(streamerSpy.LastMediaFile.ID).To(Equal(id)) }) diff --git a/server/jellyfin/stream.go b/server/jellyfin/stream.go index 86746f89a..87660056c 100644 --- a/server/jellyfin/stream.go +++ b/server/jellyfin/stream.go @@ -10,7 +10,6 @@ import ( "strings" "github.com/go-chi/chi/v5" - "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -57,13 +56,11 @@ func (api *Router) getPlaybackInfo(w http.ResponseWriter, r *http.Request) { }) } } - // Embed the caller's token in the stream URL: Jellify's native player fetches TranscodingUrl - // verbatim without an auth header, so a non-self-authenticating URL would 401. Direct-play clients - // (Finamp) build their own /File?ApiKey URL and ignore this. Include the /jellyfin mount prefix so - // a client resolving it as an absolute host path still hits the mounted router. + // Self-authenticating: native players fetch this without an auth header. Server-relative: + // clients append it to a base URL already carrying /jellyfin. if token := tokenFromRequest(r); token != "" { src.TranscodingSubProtocol = "http" - src.TranscodingUrl = consts.URLPathJellyfinAPI + "/Audio/" + src.Id + "/universal?static=true&api_key=" + url.QueryEscape(token) + src.TranscodingUrl = "/Audio/" + src.Id + "/universal?static=true&api_key=" + url.QueryEscape(token) } api.ok(w, r, dto.PlaybackInfoResponse{MediaSources: []dto.MediaSourceInfo{src}, PlaySessionId: dto.EncodeID(mf.ID)}) } From c534aedd0c3e00579fd90c7adabed4a5fb742935 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 5 Sep 2026 22:57:36 -0400 Subject: [PATCH 60/78] fix(jellyfin): add the /Items/Latest route, and scope it by ParentId (#6090) Jellify's Discover tab calls GET /Items/Latest and got a 404: we only routed the /Users/{userId}/Items/Latest form, which real Jellyfin marks [Obsolete] and hides from its OpenAPI spec, so SDK-generated clients never see it. Add the current route alongside the legacy one, both served by the same handler. getLatest also ignored ParentId, so browsing a library or an artist returned the newest albums across everything the user can see. It now scopes to the library when ParentId names one, and filters to that artist's albums otherwise, which also makes a stale id return nothing instead of silently widening back to the full library set. A malformed ParentId 404s, matching /Items and the contract decodeFilterParam documents. --- server/jellyfin/api.go | 1 + server/jellyfin/e2e/browsing_test.go | 37 ++++++++++++++++++++++++++++ server/jellyfin/items.go | 13 +++++++++- 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go index d64366a63..6484a3bb4 100644 --- a/server/jellyfin/api.go +++ b/server/jellyfin/api.go @@ -118,6 +118,7 @@ func (api *Router) routes() http.Handler { r.Use(throttleStreams(conf.Server.Jellyfin.MaxConcurrentStreams)) r.Get("/items", api.getItems) r.Get("/users/{userId}/items", api.getItems) + r.Get("/items/latest", api.getLatest) r.Get("/users/{userId}/items/latest", api.getLatest) r.Get("/artists", api.getArtists) r.Get("/artists/albumartists", api.getAlbumArtists) diff --git a/server/jellyfin/e2e/browsing_test.go b/server/jellyfin/e2e/browsing_test.go index 004090de6..832b69f25 100644 --- a/server/jellyfin/e2e/browsing_test.go +++ b/server/jellyfin/e2e/browsing_test.go @@ -563,6 +563,43 @@ var _ = Describe("Browsing", func() { }) }) + Describe("GET /Items/Latest", func() { + // Jellyfin marks the /Users/{userId} form obsolete and hides it from the OpenAPI spec, so + // SDK-generated clients (Jellify) only ever call this one. + It("serves the same response as the legacy /Users/{userId} route", func() { + Expect(get("/Items/Latest?Limit=3").Body.String()). + To(Equal(get("/Users/admin-1/Items/Latest?Limit=3").Body.String())) + }) + + It("scopes to ParentId when it names a library", func() { + var items []dto.BaseItemDto + parseInto(get("/Items/Latest?ParentId="+dto.EncodeLibraryID(1)), &items) + Expect(names(items)).To(ConsistOf("Abbey Road", "Help!", "IV", "Kind of Blue", "Singles")) + }) + + It("scopes to ParentId when it names an artist", func() { + var items []dto.BaseItemDto + parseInto(get("/Items/Latest?ParentId="+enc(artistID("The Beatles"))), &items) + Expect(names(items)).To(ConsistOf("Abbey Road", "Help!")) + }) + + It("returns nothing for a library the user cannot access", func() { + var items []dto.BaseItemDto + parseInto(get("/Items/Latest?ParentId="+dto.EncodeLibraryID(99)), &items) + Expect(items).To(BeEmpty()) + }) + + It("returns nothing for an id that is neither a library nor an artist", func() { + var items []dto.BaseItemDto + parseInto(get("/Items/Latest?ParentId="+enc(testID("does-not-exist"))), &items) + Expect(items).To(BeEmpty()) + }) + + It("404s a malformed ParentId, like every other filtered endpoint", func() { + Expect(get("/Items/Latest?ParentId=not-a-valid-id").Code).To(Equal(http.StatusNotFound)) + }) + }) + Describe("GET /Artists and /Genres", func() { It("lists album artists only on /Artists/AlbumArtists (excludes performer-only artists)", func() { names := names(queryResult(get("/Artists/AlbumArtists")).Items) diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go index eb5cd2a1a..d16c5e57e 100644 --- a/server/jellyfin/items.go +++ b/server/jellyfin/items.go @@ -938,7 +938,18 @@ func (api *Router) getLatest(w http.ResponseWriter, r *http.Request) { fields := dto.ParseFields(p.Strings("fields")...) opts := filter.AlbumsByNewest() opts.Max = p.IntOr("limit", 20) - opts = filter.ApplyLibraryFilter(opts, accessibleLibraryIDs(ctx)) + parentID, ok := decodeFilterParam(p.StringOr("parentid", "")) + if !ok { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + // A ParentId naming neither a library nor an artist (a stale id, an album) narrows to nothing + // rather than widening back to every library. + scopeIDs, isLibrary := resolveLibraryScope(ctx, parentID) + if parentID != "" && !isLibrary { + opts.Filters = squirrel.And{opts.Filters, filter.AlbumsByArtistID(parentID).Filters} + } + opts = filter.ApplyLibraryFilter(opts, scopeIDs) repo := api.ds.Album(ctx) open := streamCursor(func() (func(func(model.Album, error) bool), error) { return repo.GetCursor(opts) From 072331078d7ac999b443ebd66eed2c61820b75d9 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sun, 6 Sep 2026 12:39:53 -0400 Subject: [PATCH 61/78] fix(server): update StoreMusicFolder to skip updates when path is unchanged Signed-off-by: Deluan --- persistence/library_repository.go | 7 +++-- persistence/library_repository_test.go | 42 ++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/persistence/library_repository.go b/persistence/library_repository.go index df5c9a066..2e8feea7a 100644 --- a/persistence/library_repository.go +++ b/persistence/library_repository.go @@ -137,9 +137,10 @@ ON CONFLICT (user_id, library_id) DO NOTHING;`, func (r *libraryRepository) StoreMusicFolder() error { sq := Update(r.tableName).Set("path", conf.Server.MusicFolder). Set("updated_at", time.Now()). - Where(Eq{"id": model.DefaultLibraryID}) - _, err := r.executeSQL(sq) - if err != nil { + Where(Eq{"id": model.DefaultLibraryID}). + Where(NotEq{"path": conf.Server.MusicFolder}) + rowsAffected, err := r.executeSQL(sq) + if err == nil && rowsAffected > 0 { libLock.Lock() defer libLock.Unlock() libCache[model.DefaultLibraryID] = conf.Server.MusicFolder diff --git a/persistence/library_repository_test.go b/persistence/library_repository_test.go index 949dd93c5..6aede8c4e 100644 --- a/persistence/library_repository_test.go +++ b/persistence/library_repository_test.go @@ -5,6 +5,8 @@ import ( "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" @@ -139,6 +141,46 @@ var _ = Describe("LibraryRepository", func() { }) }) + Describe("StoreMusicFolder", func() { + var libBefore *model.Library + + BeforeEach(func() { + var err error + libBefore, err = repo.Get(model.DefaultLibraryID) + Expect(err).ToNot(HaveOccurred()) + + DeferCleanup(configtest.SetupConfig()) + DeferCleanup(func() { + _, _ = conn.NewQuery("update library set path = {:path}, updated_at = {:updated_at} where id = {:id}"). + Bind(dbx.Params{"path": libBefore.Path, "updated_at": libBefore.UpdatedAt, "id": model.DefaultLibraryID}). + Execute() + libLock.Lock() + defer libLock.Unlock() + delete(libCache, model.DefaultLibraryID) + }) + }) + + It("skips updating the default library when the configured path is unchanged", func() { + conf.Server.MusicFolder = libBefore.Path + Expect(repo.StoreMusicFolder()).To(Succeed()) + + libAfter, err := repo.Get(model.DefaultLibraryID) + Expect(err).ToNot(HaveOccurred()) + Expect(libAfter.Path).To(Equal(libBefore.Path)) + Expect(libAfter.UpdatedAt).To(Equal(libBefore.UpdatedAt)) + }) + + It("updates the default library only when the configured path changes", func() { + conf.Server.MusicFolder = libBefore.Path + "-updated" + Expect(repo.StoreMusicFolder()).To(Succeed()) + + libAfter, err := repo.Get(model.DefaultLibraryID) + Expect(err).ToNot(HaveOccurred()) + Expect(libAfter.Path).To(Equal(conf.Server.MusicFolder)) + Expect(libAfter.UpdatedAt).ToNot(Equal(libBefore.UpdatedAt)) + }) + }) + It("refreshes stats", func() { libBefore, err := repo.Get(1) Expect(err).ToNot(HaveOccurred()) From 8568010524f4b44207d8c8c28f55fc079867641f Mon Sep 17 00:00:00 2001 From: Deluan Date: Sun, 6 Sep 2026 13:08:05 -0400 Subject: [PATCH 62/78] refactor(log): replace sort with slices.SortFunc and use atomic for currentLevel Signed-off-by: Deluan --- log/log.go | 40 +++++++++++++++++++++------------------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/log/log.go b/log/log.go index 10cfb17b5..de2f171b2 100644 --- a/log/log.go +++ b/log/log.go @@ -1,6 +1,7 @@ package log import ( + "cmp" "context" "errors" "fmt" @@ -9,9 +10,10 @@ import ( "net/http" "os" "runtime" - "sort" + "slices" "strings" "sync" + "sync/atomic" "time" "github.com/sirupsen/logrus" @@ -76,18 +78,19 @@ type levelPath struct { } var ( - currentLevel Level - loggerMu sync.RWMutex - defaultLogger = logrus.New() - logSourceLine = false - rootPath string - logLevels []levelPath + currentLevel atomic.Uint32 + hasLogLevelOverrides atomic.Bool + loggerMu sync.RWMutex + defaultLogger = logrus.New() + logSourceLine = false + rootPath string + logLevels []levelPath ) // SetLevel sets the global log level used by the simple logger. func SetLevel(l Level) { loggerMu.Lock() - currentLevel = l + currentLevel.Store(uint32(l)) defaultLogger.Level = logrus.TraceLevel loggerMu.Unlock() logrus.SetLevel(logrus.Level(l)) @@ -126,9 +129,10 @@ func SetLogLevels(levels map[string]string) { for k, v := range levels { logLevels = append(logLevels, levelPath{path: k, level: ParseLogLevel(v)}) } - sort.Slice(logLevels, func(i, j int) bool { - return logLevels[i].path > logLevels[j].path + slices.SortFunc(logLevels, func(a, b levelPath) int { + return cmp.Compare(b.path, a.path) }) + hasLogLevelOverrides.Store(len(logLevels) != 0) } func SetLogSourceLine(enabled bool) { @@ -193,9 +197,7 @@ func SetDefaultLogger(l *logrus.Logger) *logrus.Logger { } func CurrentLevel() Level { - loggerMu.RLock() - defer loggerMu.RUnlock() - return currentLevel + return Level(currentLevel.Load()) } // IsGreaterOrEqualTo returns true if the caller's current log level is equal or greater than the provided level. @@ -248,18 +250,18 @@ func Writer() io.Writer { } func shouldLog(requiredLevel Level, skip int) bool { - loggerMu.RLock() - level := currentLevel - levels := logLevels - loggerMu.RUnlock() - + level := Level(currentLevel.Load()) if level >= requiredLevel { return true } - if len(levels) == 0 { + if !hasLogLevelOverrides.Load() { return false } + loggerMu.RLock() + levels := logLevels + loggerMu.RUnlock() + _, file, _, ok := runtime.Caller(skip) if !ok { return false From 8a2135f076dcabe1399147c6c8dfe64a5b383047 Mon Sep 17 00:00:00 2001 From: karigane <169052233+karigane-cha@users.noreply.github.com> Date: Mon, 7 Sep 2026 02:30:25 +0900 Subject: [PATCH 63/78] fix(i18n): Update Japanese translation (#6080) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(i18n): Update Japanese translation * fix(i18n): fix Japanese translation * fix(i18n): fix Japanese translation Update Japanese translations for `recentlyAdded`, `recentlyPlayed`, and `mostPlayed` in album lists --------- Co-authored-by: Deluan Quintão --- resources/i18n/ja.json | 681 +++++++++++++++++++++++------------------ 1 file changed, 390 insertions(+), 291 deletions(-) diff --git a/resources/i18n/ja.json b/resources/i18n/ja.json index 29975b92b..83cd7822b 100644 --- a/resources/i18n/ja.json +++ b/resources/i18n/ja.json @@ -5,48 +5,53 @@ "name": "曲", "fields": { "albumArtist": "アルバムアーティスト", - "duration": "長さ", + "duration": "再生時間", "trackNumber": "#", - "playCount": "再生数", + "playCount": "再生回数", "title": "タイトル", "artist": "アーティスト", + "composer": "作曲", "album": "アルバム", "path": "ファイルパス", + "libraryName": "ライブラリ", "genre": "ジャンル", - "compilation": "Compilation", + "compilation": "コンピレーション", "year": "年", "size": "ファイルサイズ", - "updatedAt": "更新日", + "updatedAt": "更新日時", "bitRate": "ビットレート", + "bitDepth": "ビット深度", + "sampleRate": "サンプリングレート", + "albumGain": "アルバムゲイン", + "trackGain": "トラックゲイン", + "channels": "チャンネル", + "disc": "ディスク %{discNumber}", "discSubtitle": "ディスクサブタイトル", "starred": "お気に入り", "comment": "コメント", - "rating": "レート", - "quality": "品質", + "rating": "評価", + "quality": "形式", "bpm": "BPM", - "playDate": "最後の再生", - "channels": "チャンネル", - "createdAt": "追加日", - "grouping": "グループ分け", + "playDate": "最終再生日時", + "createdAt": "追加日時", + "grouping": "グループ化", "mood": "ムード", - "participants": "追加参加者", + "participants": "参加者", "tags": "追加タグ", - "mappedTags": "マッピング済みタグ", - "rawTags": "未処理タグ", - "bitDepth": "ビット深度", - "sampleRate": "サンプリングレート", - "missing": "不明", - "libraryName": "ライブラリ" + "mappedTags": "マッピングされたタグ", + "rawTags": "未加工タグ", + "missing": "不明" }, "actions": { "addToQueue": "最後に再生", - "playNow": "すぐに再生", + "playNow": "今すぐ再生", "addToPlaylist": "プレイリストに追加", - "shuffleAll": "全曲シャッフル", + "showInPlaylist": "プレイリストで表示", + "shuffleAll": "すべてシャッフル", "download": "ダウンロード", "playNext": "次に再生", "info": "詳細", - "showInPlaylist": "含まれるプレイリスト" + "instantMix": "インスタントミックス" } }, "album": { @@ -54,48 +59,49 @@ "fields": { "albumArtist": "アルバムアーティスト", "artist": "アーティスト", - "duration": "長さ", - "songCount": "曲", - "playCount": "再生数", - "name": "名前", + "duration": "再生時間", + "songCount": "曲数", + "playCount": "再生回数", + "size": "ファイルサイズ", + "name": "アルバム名", + "libraryName": "ライブラリ", "genre": "ジャンル", - "compilation": "Compilation", + "compilation": "コンピレーション", "year": "年", - "updatedAt": "更新日", - "comment": "コメント", - "rating": "レート", - "createdAt": "追加日", - "size": "サイズ", - "originalDate": "オリジナルの日付", - "releaseDate": "リリース日", + "date": "録音日", + "originalDate": "オリジナル発売日", + "releaseDate": "発売日", "releases": "リリース", - "released": "リリース", - "recordLabel": "ラベル", + "released": "リリース済み", + "updatedAt": "更新日時", + "comment": "コメント", + "rating": "評価", + "createdAt": "追加日時", + "recordLabel": "レーベル", "catalogNum": "カタログ番号", - "releaseType": "タイプ", - "grouping": "グループ分け", + "releaseType": "リリースタイプ", + "grouping": "グループ化", "media": "メディア", "mood": "ムード", - "date": "録音日", - "missing": "不明", - "libraryName": "ライブラリ" + "missing": "不明" }, "actions": { - "playAll": "再生", + "playAll": "すべて再生", "playNext": "次に再生", "addToQueue": "最後に再生", + "share": "共有", "shuffle": "シャッフル", - "addToPlaylist": "プレイリストへ追加", + "addToPlaylist": "プレイリストに追加", "download": "ダウンロード", "info": "詳細", - "share": "共有" + "refresh": "メタデータを更新" }, "lists": { - "all": "全て", + "all": "すべて", "random": "ランダム", "recentlyAdded": "最近の追加", "recentlyPlayed": "最近の再生", - "mostPlayed": "最も再生", + "mostPlayed": "最多再生", "starred": "お気に入り", "topRated": "高評価" } @@ -106,33 +112,33 @@ "name": "名前", "albumCount": "アルバム枚数", "songCount": "曲数", - "playCount": "再生数", - "rating": "レート", - "genre": "ジャンル", "size": "サイズ", + "playCount": "再生回数", + "rating": "評価", + "genre": "ジャンル", "role": "役割", "missing": "不明" }, "roles": { "albumartist": "アルバムアーティスト", "artist": "アーティスト", - "composer": "作曲家", - "conductor": "指揮者", - "lyricist": "作詞家", - "arranger": "編曲者", + "composer": "作曲", + "conductor": "指揮", + "lyricist": "作詞", + "arranger": "編曲", "producer": "プロデューサー", - "director": "ディレクター", + "director": "監督", "engineer": "エンジニア", "mixer": "ミキサー", "remixer": "リミキサー", "djmixer": "DJ ミキサー", - "performer": "演奏者", - "maincredit": "アルバムアーティストもしくはアーティスト" + "performer": "演奏", + "maincredit": "アルバムアーティストまたはアーティスト" }, "actions": { + "topSongs": "人気曲", "shuffle": "シャッフル", - "radio": "ラジオ", - "topSongs": "トップソング" + "radio": "ラジオ" } }, "user": { @@ -140,55 +146,58 @@ "fields": { "userName": "ユーザー名", "isAdmin": "管理者", - "lastLoginAt": "最終ログイン", - "updatedAt": "更新日", + "lastLoginAt": "最終ログイン日時", + "lastAccessAt": "最終アクセス日時", + "updatedAt": "更新日時", "name": "名前", "password": "パスワード", - "createdAt": "作成日", + "createdAt": "作成日時", "changePassword": "パスワードを変更しますか?", "currentPassword": "現在のパスワード", "newPassword": "新しいパスワード", "token": "トークン", - "lastAccessAt": "最終アクセス", - "libraries": "ライブラリ" + "libraries": "ライブラリ", + "scrobbleFilter": "Scrobble フィルター" }, "helperTexts": { - "name": "名前の変更は次回ログイン以降反映されます", - "libraries": "このユーザーに対して特定ライブラリを選択するか、デフォルトのライブラリを使用する場合は空欄のままにします" + "name": "ユーザー名の変更は、次回ログイン時に反映されます", + "libraries": "このユーザー用に特定のライブラリを選択するか、デフォルトのライブラリを使用する場合は空のままにしてください", + "scrobbleFilter": "これらのスマートプレイリストのルールに一致する曲は、Last.fm、ListenBrainz、または Scrobble プラグインに送信されません。スマートプレイリストと同じ JSON 構文と動作を使用します。例: {\"all\":[{\"lt\":{\"rating\":4}}]}。すべてを Scrobble する場合は空欄のままにしてください。ローカルでの再生回数には影響しません。" }, "notifications": { "created": "ユーザーが作成されました", "updated": "ユーザーが更新されました", "deleted": "ユーザーが削除されました" }, - "message": { - "listenBrainzToken": "ListenBrainzユーザートークンを入力", - "clickHereForToken": "ここをクリックしトークンを入手", - "selectAllLibraries": "全てのライブラリを選択", - "adminAutoLibraries": "管理者ユーザーは自動的にすべてのライブラリにアクセスできます" - }, "validation": { - "librariesRequired": "管理者以外のユーザーには少なくとも1つのライブラリを選択する必要があります" + "librariesRequired": "管理者以外のユーザーについては、少なくとも 1 つのライブラリを選択する必要があります", + "invalidScrobbleFilter": "有効なスマートプレイリストのルールである必要があります。上限、オフセット、更新遅延はサポートされていません。" + }, + "message": { + "listenBrainzToken": "ListenBrainz のユーザートークンを入力してください。", + "clickHereForToken": "トークンを取得するには、ここをクリックしてください", + "selectAllLibraries": "すべてのライブラリを選択", + "adminAutoLibraries": "管理者ユーザーは、すべてのライブラリに自動的にアクセスできます" } }, "player": { - "name": "プレイヤー", + "name": "プレーヤー", "fields": { "name": "名前", "transcodingId": "トランスコード", "maxBitRate": "最大ビットレート", "client": "クライアント", - "userName": "ユーザ名", - "lastSeen": "最後の利用", + "userName": "ユーザー名", + "lastSeen": "最終アクセス日時", "reportRealPath": "実際のファイルパスを返す", - "scrobbleEnabled": "他のサービスへscrobbleする" + "scrobbleEnabled": "外部サービスに Scrobble する" } }, "transcoding": { "name": "トランスコード", "fields": { "name": "名前", - "targetFormat": "対象フォーマット", + "targetFormat": "ターゲット形式", "defaultBitRate": "デフォルトビットレート", "command": "コマンド" } @@ -197,45 +206,46 @@ "name": "プレイリスト", "fields": { "name": "名前", - "duration": "時間", + "duration": "再生時間", "ownerName": "所有者", "public": "公開", - "updatedAt": "更新日", - "createdAt": "作成日", - "songCount": "曲", + "updatedAt": "更新日時", + "createdAt": "作成日時", + "songCount": "曲数", "comment": "コメント", - "sync": "自動インポート", - "path": "インポート元" + "sync": "同期", + "path": "インポートパス", + "starred": "お気に入り" }, "actions": { - "selectPlaylist": "プレイリストを選択", - "addNewPlaylist": "'%{name}' を作成", + "selectPlaylist": "プレイリストを選択:", + "addNewPlaylist": "\"%{name}\" を作成", "export": "エクスポート", - "makePublic": "公開する", - "makePrivate": "非公開にする", "saveQueue": "キューをプレイリストに保存", - "searchOrCreate": "プレイリストを検索または入力して新規作成...", - "pressEnterToCreate": "Enterキーを押して新しいプレイリストを作成", - "removeFromSelection": "選択から削除" + "makePublic": "公開にする", + "makePrivate": "非公開にする", + "searchOrCreate": "プレイリストを検索するか、入力して新規作成します...", + "pressEnterToCreate": "Enter キーを押して新しいプレイリストを作成します", + "removeFromSelection": "選択から除外" }, "message": { - "duplicate_song": "重複する曲を追加", - "song_exist": "既にプレイリストに存在する曲です。追加しますか?", - "noPlaylistsFound": "プレイリストが見つかりません", - "noPlaylists": "利用可能なプレイリストはありません" + "duplicate_song": "重複している曲を追加", + "song_exist": "プレイリストに重複する曲が追加されようとしています。重複する曲を追加しますか、それともスキップしますか?", + "noPlaylistsFound": "プレイリストが見つかりませんでした", + "noPlaylists": "利用可能なプレイリストがありません" } }, "radio": { "name": "ラジオ", "fields": { "name": "名前", - "streamUrl": "配信URL", - "homePageUrl": "ホームページURL", - "updatedAt": "更新日", - "createdAt": "作成日" + "streamUrl": "ストリーム URL", + "homePageUrl": "ホームページ URL", + "updatedAt": "更新日時", + "createdAt": "作成日時" }, "actions": { - "playNow": "すぐに再生" + "playNow": "今すぐ再生" } }, "share": { @@ -244,55 +254,57 @@ "username": "共有者", "url": "URL", "description": "説明", + "downloadable": "ダウンロードを許可しますか?", "contents": "コンテンツ", - "expiresAt": "期限切れ", - "lastVisitedAt": "最後の訪問", - "visitCount": "訪問回数", - "format": "フォーマット", + "expiresAt": "有効期限", + "lastVisitedAt": "最終閲覧日時", + "visitCount": "閲覧回数", + "format": "形式", "maxBitRate": "最大ビットレート", - "updatedAt": "更新日", - "createdAt": "作成日", - "downloadable": "ダウンロードを許可しますか?" - } + "updatedAt": "更新日時", + "createdAt": "作成日時" + }, + "notifications": {}, + "actions": {} }, "missing": { - "name": "欠落したファイル", + "name": "欠落ファイル", + "empty": "欠落しているファイルはありません", "fields": { - "path": "パス", - "size": "サイズ", - "updatedAt": "欠落日", - "libraryName": "ライブラリ" + "path": "ファイルパス", + "size": "ファイルサイズ", + "libraryName": "ライブラリ名", + "updatedAt": "更新日時" }, "actions": { "remove": "削除", - "remove_all": "全て削除" + "remove_all": "すべて削除" }, "notifications": { "removed": "欠落ファイルが削除されました" - }, - "empty": "ファイルの欠落はありません" + } }, "library": { "name": "ライブラリ", "fields": { "name": "名前", "path": "パス", - "remotePath": "リモートパス", - "lastScanAt": "最終スキャン", + "remotePath": "リモートスキャン", + "lastScanAt": "最終スキャン日時", "songCount": "曲数", - "albumCount": "アルバム数", + "albumCount": "アルバム枚数", "artistCount": "アーティスト数", - "totalSongs": "曲数", - "totalAlbums": "アルバム数", - "totalArtists": "アーティスト数", - "totalFolders": "フォルダー数", - "totalFiles": "ファイル数", - "totalMissingFiles": "欠落したファイル", + "totalSongs": "総曲数", + "totalAlbums": "総アルバム枚数", + "totalArtists": "総アーティスト数", + "totalFolders": "フォルダー総数", + "totalFiles": "ファイル総数", + "totalMissingFiles": "欠落ファイル数", "totalSize": "合計サイズ", - "totalDuration": "合計時間", - "defaultNewUsers": "新規ユーザーに対するデフォルト", - "createdAt": "作成日", - "updatedAt": "更新日" + "totalDuration": "総再生時間", + "defaultNewUsers": "新規ユーザーのデフォルト設定", + "createdAt": "作成日時", + "updatedAt": "更新日時" }, "sections": { "basic": "基本情報", @@ -300,95 +312,172 @@ }, "actions": { "scan": "ライブラリをスキャン", - "manageUsers": "ユーザーアクセス管理", - "viewDetails": "詳細を表示", "quickScan": "クイックスキャン", - "fullScan": "フルスキャン" + "fullScan": "フルスキャン", + "manageUsers": "ユーザーを管理", + "viewDetails": "詳細を表示" }, "notifications": { "created": "ライブラリが正常に作成されました", "updated": "ライブラリが正常に更新されました", "deleted": "ライブラリが正常に削除されました", - "scanStarted": "スキャンを開始しました", - "scanCompleted": "スキャンが完了しました", - "quickScanStarted": "クイックスキャンを開始しました", - "fullScanStarted": "フルスキャンを開始しました", - "scanError": "スキャン開始中にエラーが発生。ログを確認してください" + "scanStarted": "ライブラリのスキャンが開始されました", + "quickScanStarted": "クイックスキャンが開始されました", + "fullScanStarted": "フルスキャンが開始されました", + "scanError": "スキャンを開始できませんでした。ログを確認してください", + "scanCompleted": "ライブラリのスキャンが完了しました" }, "validation": { - "nameRequired": "ライブラリの名前が必要です", - "pathRequired": "ライブラリのパスが必要です", + "nameRequired": "ライブラリ名は必須です", + "pathRequired": "ライブラリパスは必須です", "pathNotDirectory": "ライブラリパスはディレクトリである必要があります", - "pathNotFound": "ライブラリのパスが見つかりません", - "pathNotAccessible": "ライブラリパスへアクセスできません", + "pathNotFound": "ライブラリパスが見つかりません", + "pathNotAccessible": "ライブラリパスにアクセスできません", "pathInvalid": "無効なライブラリパス" }, "messages": { - "deleteConfirm": "このライブラリを削除しますか?関連する全てのデータとユーザーアクセスが削除されます。", + "deleteConfirm": "このライブラリを削除してもよろしいですか?これにより、関連するすべてのデータおよびユーザーのアクセス権が削除されます。", "scanInProgress": "スキャン中...", - "noLibrariesAssigned": "このユーザーに割り当てられているライブラリはありません" + "noLibrariesAssigned": "このユーザーに割り当てられたライブラリはありません" + } + }, + "plugin": { + "name": "プラグイン", + "fields": { + "id": "ID", + "name": "名前", + "description": "説明", + "version": "バージョン", + "author": "作者", + "website": "ウェブサイト", + "permissions": "権限", + "enabled": "有効", + "status": "ステータス", + "path": "パス", + "lastError": "エラー", + "hasError": "エラー", + "updatedAt": "更新日時", + "createdAt": "インストール日時", + "configKey": "キー", + "configValue": "値", + "allUsers": "すべてのユーザーを許可", + "selectedUsers": "選択されたユーザー", + "allLibraries": "すべてのライブラリを許可", + "selectedLibraries": "選択されたライブラリ", + "allowWriteAccess": "書き込み権限を許可" + }, + "sections": { + "status": "ステータス", + "info": "プラグイン情報", + "configuration": "設定", + "manifest": "マニフェスト", + "usersPermission": "ユーザー権限", + "libraryPermission": "ライブラリ権限" + }, + "status": { + "enabled": "有効", + "disabled": "無効" + }, + "actions": { + "enable": "有効化", + "disable": "無効化", + "disabledDueToError": "有効化する前にエラーを修正してください", + "disabledUsersRequired": "有効化する前にユーザーを選択してください", + "disabledLibrariesRequired": "有効化する前にライブラリを選択してください", + "addConfig": "設定を追加", + "rescan": "再スキャン" + }, + "notifications": { + "enabled": "プラグインが有効化されました", + "disabled": "プラグインが無効化されました", + "updated": "プラグインが更新されました", + "error": "プラグインの更新中にエラーが発生しました" + }, + "validation": { + "invalidJson": "設定は有効な JSON である必要があります" + }, + "messages": { + "idHelp": "ファイル名から生成されるプラグイン ID です。エージェントなどの設定オプションでこのプラグインを参照する際に使用します。", + "configHelp": "キーと値のペアを使用してプラグインを設定します。設定が不要な場合は、空のままにしてください。", + "configValidationError": "設定の検証に失敗しました:", + "schemaRenderError": "設定フォームを表示できません。プラグインのスキーマが無効である可能性があります。", + "clickPermissions": "詳細を表示するには、「権限」をクリックしてください", + "noConfig": "設定がありません", + "allUsersHelp": "有効にすると、このプラグインは、今後作成されるユーザーも含め、すべてのユーザーにアクセスできるようになります。", + "noUsers": "ユーザーが選択されていません", + "permissionReason": "理由", + "usersRequired": "このプラグインは、ユーザー情報へのアクセスを必要とします。プラグインがアクセスできるユーザーを選択するか、「すべてのユーザーを許可」を有効にしてください。", + "allLibrariesHelp": "有効にすると、このプラグインは、今後作成されるライブラリも含め、すべてのライブラリにアクセスできるようになります。", + "noLibraries": "ライブラリが選択されていません", + "librariesRequired": "このプラグインは、ライブラリ情報へのアクセスを必要とします。プラグインがアクセスできるライブラリを選択するか、「すべてのライブラリを許可」を有効にしてください。", + "allowWriteAccessHelp": "有効にすると、プラグインはライブラリディレクトリ内のファイルを変更できるようになります。デフォルトでは、プラグインのアクセス権限は読み取り専用に設定されています。", + "requiredHosts": "必須ホスト" + }, + "placeholders": { + "configKey": "キー", + "configValue": "値" } } }, "ra": { "auth": { - "welcome1": "Navidromeをインストールいただきありがとうございます!", - "welcome2": "管理ユーザーを作成して始めましょう", + "welcome1": "Navidrome をインストールしていただき、ありがとうございます!", + "welcome2": "まずは、管理者ユーザーを作成してください", "confirmPassword": "パスワードの確認", - "buttonCreateAdmin": "管理者の作成", - "auth_check_error": "認証に失敗しました。再度ログインしてください", - "user_menu": "プロフィール", + "buttonCreateAdmin": "管理者を作成", + "auth_check_error": "続行するにはログインしてください", + "user_menu": "プロファイル", "username": "ユーザー名", "password": "パスワード", "sign_in": "ログイン", - "sign_in_error": "認証に失敗しました。入力を確認してください", + "sign_in_error": "認証に失敗しました。もう一度お試しください", "logout": "ログアウト", - "insightsCollectionNote": "Navidromeでは、プロジェクトの改善に役立てるため、匿名の利用データを収集しています。詳しくは [here] をクリックしてください。" + "insightsCollectionNote": "Navidrome では、プロジェクトの改善に役立てるため、匿名化された利用データを収集しています。詳細の確認やオプトアウト(収集の停止)を行うには、[こちら]をクリックしてください" }, "validation": { - "invalidChars": "文字と数字のみを使用してください", + "invalidChars": "英数字のみを使用してください", "passwordDoesNotMatch": "パスワードが一致しません", "required": "必須", - "minLength": "%{min}文字以上である必要があります", - "maxLength": "%{max}文字以下である必要があります", - "minValue": "%{min}以上である必要があります", - "maxValue": "%{max}以下である必要があります", + "minLength": "%{min} 文字以上である必要があります", + "maxLength": "%{max} 文字以下である必要があります", + "minValue": "%{min} 桁以上である必要があります", + "maxValue": "%{max} 桁以下である必要があります", "number": "数字である必要があります", - "email": "メールアドレスである必要があります", + "email": "有効なメールアドレスである必要があります", "oneOf": "次のいずれかである必要があります: %{options}", - "regex": "次の正規表現形式にする必要があります: %{pattern}", + "regex": "特定の形式(正規表現)に一致する必要があります: %{pattern}", "unique": "一意である必要があります", - "url": "有効なURLを入力してください" + "url": "有効な URL である必要があります" }, "action": { - "add_filter": "検索条件", + "add_filter": "フィルターを追加", "add": "追加", "back": "戻る", - "bulk_actions": "%{smart_count}件選択", + "bulk_actions": "1 件選択中 |||| %{smart_count} 件選択中", + "bulk_actions_mobile": "1 |||| %{smart_count}", "cancel": "キャンセル", - "clear_input_value": "空にする", + "clear_input_value": "値を消去", "clone": "複製", "confirm": "確認", "create": "作成", "delete": "削除", "edit": "編集", "export": "エクスポート", - "list": "一覧", + "list": "リスト", "refresh": "更新", - "remove_filter": "検索条件を削除", + "remove_filter": "このフィルターを削除", "remove": "削除", "save": "保存", "search": "検索", - "show": "詳細", - "sort": "並び替え", + "show": "表示", + "sort": "並べ替え", "undo": "元に戻す", - "expand": "開く", + "expand": "展開", "close": "閉じる", - "open_menu": "開く", - "close_menu": "閉じる", + "open_menu": "メニューを開く", + "close_menu": "メニューを閉じる", "unselect": "選択解除", "skip": "スキップ", - "bulk_actions_mobile": "1 |||| %{smart_count}", "share": "共有", "download": "ダウンロード" }, @@ -402,71 +491,71 @@ "edit": "%{name} #%{id}", "error": "問題が発生しました", "list": "%{name}", - "loading": "読込中", + "loading": "読み込み中", "not_found": "見つかりませんでした", "show": "%{name} #%{id}", - "empty": "%{name}はありません", - "invite": "作成しますか?" + "empty": "%{name} はまだありません。", + "invite": "追加してもよろしいですか?" }, "input": { "file": { - "upload_several": "アップロードするファイルをドロップ、または選択してください", - "upload_single": "アップロードするファイルをドロップ、または選択してください" + "upload_several": "ファイルをドロップしてアップロードするか、クリックしてファイルを選択してください。", + "upload_single": "ファイルをドロップしてアップロードするか、クリックして選択してください。" }, "image": { - "upload_several": "アップロードする画像をドロップ、または選択してください", - "upload_single": "アップロードする画像をドロップ、または選択してください" + "upload_several": "画像をドロップしてアップロードするか、クリックして画像を選択してください。", + "upload_single": "画像をドロップしてアップロードするか、クリックして画像を選択してください。" }, "references": { - "all_missing": "データが利用できなくなりました", - "many_missing": "選択したデータが利用できなくなりました", - "single_missing": "選択したデータが利用できなくなりました" + "all_missing": "参照データが見つかりませんでした。", + "many_missing": "参照データのうち、少なくとも 1 件が利用できなくなっています。", + "single_missing": "参照データは現在利用できません。" }, "password": { - "toggle_visible": "非表示", - "toggle_hidden": "表示" + "toggle_visible": "パスワードを非表示", + "toggle_hidden": "パスワードを表示" } }, "message": { - "about": "詳細", - "are_you_sure": "本当によろしいですか?", - "bulk_delete_content": "%{name} を削除してよろしいですか? |||| 選択した %{smart_count}件のアイテムを削除してよろしいですか?", - "bulk_delete_title": "%{name} を削除 |||| %{name} %{smart_count}件を削除", - "delete_content": "削除してよろしいですか?", + "about": "概要", + "are_you_sure": "よろしいですか?", + "bulk_delete_content": "%{name} を削除してもよろしいですか? |||| %{smart_count} 件の項目を削除してもよろしいですか?", + "bulk_delete_title": "%{name} を削除 |||| %{smart_count} 件の %{name} を削除", + "delete_content": "この項目を削除してもよろしいですか?", "delete_title": "%{name} #%{id} を削除", "details": "詳細", - "error": "クライアントエラーが発生し、処理を完了できませんでした", - "invalid_form": "入力値に誤りがあります。エラーメッセージを確認してください", - "loading": "読み込み中です。しばらくお待ちください", + "error": "クライアント側でエラーが発生したため、リクエストを完了できませんでした。", + "invalid_form": "フォームが無効です。エラーを確認してください", + "loading": "ページを読み込んでいます。しばらくお待ちください", "no": "いいえ", - "not_found": "間違ったURLを入力したか、間違ったリンクを辿りました", + "not_found": "URL を間違えて入力したか、無効なリンクをクリックした可能性があります。", "yes": "はい", - "unsaved_changes": "行った変更が保存されていません。このページから移動してよろしいですか?" + "unsaved_changes": "変更の一部が保存されませんでした。変更を破棄してもよろしいですか?" }, "navigation": { - "no_results": "結果が見つかりませんでした", - "no_more_results": "ページ番号 %{page} は最大のページ数を超えています。前のページに戻ってください", - "page_out_of_boundaries": "ページ番号 %{page} は最大のページ数を超えています", - "page_out_from_end": "最大のページ数より後に移動できません", - "page_out_from_begin": "1 ページより前に移動できません", + "no_results": "検索結果はありません", + "no_more_results": "ページ番号 %{page} は範囲外です。前のページに戻ってください。", + "page_out_of_boundaries": "ページ番号 %{page} は範囲外です", + "page_out_from_end": "最後のページより先には進めません", + "page_out_from_begin": "ページ 1 より前には戻れません", "page_range_info": "%{offsetBegin}-%{offsetEnd} / %{total}", - "page_rows_per_page": "表示件数:", + "page_rows_per_page": "1 ページあたりの表示件数:", "next": "次", "prev": "前", - "skip_nav": "スキップ" + "skip_nav": "コンテンツへスキップ" }, "notification": { - "updated": "更新しました |||| %{smart_count} 件更新しました", - "created": "作成しました", - "deleted": "削除しました |||| %{smart_count} 件削除しました", - "bad_item": "データが不正です", - "item_doesnt_exist": "データが存在しませんでした", - "http_error": "通信エラーが発生しました", - "data_provider_error": "dataProviderエラー。詳細はコンソールを確認してください", - "i18n_error": "翻訳ファイルが読み込めませんでした", - "canceled": "元に戻しました", - "logged_out": "認証に失敗しました。再度ログインしてください", - "new_version": "新しいバージョンが利用可能です!ページを更新してください。" + "updated": "更新されました |||| %{smart_count} 件の項目が更新されました", + "created": "作成されました", + "deleted": "削除されました |||| %{smart_count} 件の項目が削除されました", + "bad_item": "項目が無効です", + "item_doesnt_exist": "項目が存在しません", + "http_error": "サーバー通信エラーが発生しました", + "data_provider_error": "dataProvider のエラーが発生しました。詳細についてはコンソールを確認してください。", + "i18n_error": "指定された言語の翻訳を読み込めません", + "canceled": "操作がキャンセルされました", + "logged_out": "セッションが終了しました。再接続してください。", + "new_version": "新しいバージョンが利用可能になりました!このウィンドウを更新してください。" }, "toggleFieldsMenu": { "columnsToDisplay": "表示列", @@ -476,45 +565,59 @@ } }, "message": { - "note": "注意", - "transcodingDisabled": "セキュリティ上の理由から、Web インターフェイスからのトランスコード設定は無効になっています。\nこれを設定したい場合、環境変数 %{config} を設定しサーバーを再起動してください。", - "transcodingEnabled": "Navidromeは現在 %{config} の設定で実行されており、WebUIのトランスコード設定からコマンドを実行できます。\nセキュリティ上の問題から、この設定はトランスコード設定を変更する時のみ有効にすることを推奨します。", - "songsAddedToPlaylist": "プレイリストへ1曲追加しました |||| プレイリストへ%{smart_count}曲追加しました", - "noPlaylistsAvailable": "利用不可", - "delete_user_title": "'%{name}' を削除", - "delete_user_content": "このユーザーとその全てのデータ(プレイリストや設定)を削除してもよろしいですか?", - "notifications_blocked": "ブラウザの設定でこのサイトの通知がブロックされています", - "notifications_not_available": "このブラウザはデスクトップ通知をサポートしていません", - "lastfmLinkSuccess": "Last.fmとリンクしscrobbleが有効になりました", - "lastfmLinkFailure": "Last.fmとリンクできませんでした", - "lastfmUnlinkSuccess": "設定が解除され、Last.fmへのscrobbleは無効になっています", - "lastfmUnlinkFailure": "Last.fmとリンクできませんでした", + "uploadCover": "カバーをアップロード", + "removeCover": "カバーを削除", + "coverUploaded": "カバーアートが更新されました", + "coverRemoved": "カバーアートが削除されました", + "coverUploadError": "カバーアートのアップロード中にエラーが発生しました", + "coverRemoveError": "カバーアートの削除中にエラーが発生しました", + "metadataRefreshStarted": "メタデータの更新が開始されました", + "note": "注記", + "transcodingDisabled": "セキュリティ上の理由により、ウェブインターフェース経由でのトランスコード設定の変更は無効になっています。トランスコードのオプションを変更(編集または追加)する場合は、%{config} 設定オプションを指定してサーバーを再起動してください。", + "transcodingEnabled": "Navidrome は現在 %{config} で動作しており、ウェブインターフェースのトランスコード設定からシステムコマンドを実行できる状態になっています。セキュリティ上の理由から、この機能を無効にしておき、トランスコードのオプションを設定する際のみ有効にすることをお勧めします。", + "songsAddedToPlaylist": "1 曲をプレイリストに追加しました |||| %{smart_count} 曲をプレイリストに追加しました", + "noSimilarSongsFound": "類似の曲は見つかりませんでした", + "startingInstantMix": "インスタントミックスを読み込み中...", + "noTopSongsFound": "人気曲は見つかりませんでした", + "noPlaylistsAvailable": "利用可能なプレイリストがありません", + "delete_user_title": "ユーザー「%{name}」を削除", + "delete_user_content": "このユーザーと、そのすべてのデータ(プレイリストや設定を含む)を削除してもよろしいですか?", + "remove_missing_title": "欠落ファイルを削除", + "remove_missing_content": "選択した欠落ファイルをデータベースから削除してもよろしいですか?これにより、再生回数や評価を含む、これらのファイルに関するすべての情報が完全に削除されます。", + "remove_all_missing_title": "すべての欠落ファイルを削除", + "remove_all_missing_content": "すべての欠落ファイルをデータベースから削除してもよろしいですか?これにより、再生回数や評価を含む、これらのファイルに関するすべての情報が完全に削除されます。", + "notifications_blocked": "お使いのブラウザーの設定で、このサイトからの通知をブロックしています", + "notifications_not_available": "このブラウザーはデスクトップ通知に対応していないか、または Navidrome に HTTPS 経由でアクセスしていない可能性があります", + "lastfmLinkSuccess": "Last.fm との連携に成功し、Scrobbling 機能が有効になりました", + "lastfmLinkFailure": "Last.fm との連携に失敗しました", + "lastfmUnlinkSuccess": "Last.fm との連携が解除され、Scrobbling 機能が無効になりました", + "lastfmUnlinkFailure": "Last.fm との連携解除に失敗しました", + "listenBrainzLinkSuccess": "ListenBrainz との連携に成功し、ユーザー %{user} として Scrobbling 機能が有効になりました", + "listenBrainzLinkFailure": "ListenBrainz との連携に失敗しました: %{error}", + "listenBrainzUnlinkSuccess": "ListenBrainz との連携が解除され、Scrobbling 機能が無効になりました", + "listenBrainzUnlinkFailure": "ListenBrainz との連携解除に失敗しました", "openIn": { - "lastfm": "Last.fmで開く", - "musicbrainz": "MusicBrainzで開く" + "lastfm": "Last.fm で開く", + "musicbrainz": "MusicBrainz で開く" }, "lastfmLink": "続きを読む", - "listenBrainzLinkSuccess": "%{user} へのscrobbling設定に成功しました", - "listenBrainzLinkFailure": "ListenBrainzとのリンクに失敗しました: %{error}", - "listenBrainzUnlinkSuccess": "ListenBrainzとのリンクとscrobblingを無効化しました。", - "listenBrainzUnlinkFailure": "ListenBrainzとのリンクを解除できませんでした", - "downloadOriginalFormat": "元のフォーマットでダウンロード", - "shareOriginalFormat": "元のフォーマットで共有", - "shareDialogTitle": "%{resource} '%{name}' を共有", + "shareOriginalFormat": "オリジナルの形式で共有", + "shareDialogTitle": "%{resource}「%{name}」を共有", "shareBatchDialogTitle": "1 %{resource} を共有 |||| %{smart_count} %{resource} を共有", - "shareSuccess": "コピーしました: %{url}", - "shareFailure": "コピーに失敗しました %{url}", - "downloadDialogTitle": "ダウンロード %{resource} '%{name}' (%{size})", - "shareCopyToClipboard": "クリップボードへコピー: Ctrl+C, Enter", - "remove_missing_title": "欠落ファイルを削除", - "remove_missing_content": "選択した欠落ファイルをデータベースから削除してもよろしいですか?これにより、再生数や評価を含むそれらのファイルへの参照が完全に削除されます。", - "remove_all_missing_title": "全ての欠落ファイルを削除", - "remove_all_missing_content": "データベースから欠落ファイルをすべて削除してもよろしいですか?これにより、再生数や評価を含むそれらのファイルへの参照が永久に削除されます。", - "noSimilarSongsFound": "類似の曲が見つかりませんでした", - "noTopSongsFound": "トップソングが見つかりません" + "shareCopyToClipboard": "クリップボードにコピー: Ctrl+C、Enter", + "shareSuccess": "URL をクリップボードにコピーしました: %{url}", + "shareFailure": "URL %{url} をクリップボードにコピーできませんでした", + "downloadDialogTitle": "%{resource}「%{name}」(%{size}) をダウンロード", + "downloadOriginalFormat": "オリジナルの形式でダウンロード" }, "menu": { "library": "ライブラリ", + "librarySelector": { + "allLibraries": "すべてのライブラリ (%{count})", + "multipleLibraries": "%{total} 件のライブラリのうち %{selected} 件", + "selectLibraries": "ライブラリを選択", + "none": "無し" + }, "settings": "設定", "version": "バージョン", "theme": "テーマ", @@ -523,53 +626,48 @@ "options": { "theme": "テーマ", "language": "言語", - "defaultView": "デフォルト画面", + "defaultView": "デフォルトの画面", "desktop_notifications": "デスクトップ通知", - "lastfmScrobbling": "Last.fmへscrobbleする", - "listenBrainzScrobbling": "ListenBrainzへscrobble", - "replaygain": "ReplayGainモード", - "preAmp": "プリアンプ", + "lastfmNotConfigured": "Last.fm API キーが設定されていません。", + "lastfmScrobbling": "Last.fm に Scrobble", + "listenBrainzScrobbling": "ListenBrainz に Scrobble", + "replaygain": "リプレイゲインモード", + "preAmp": "リプレイゲインプリアンプ (dB)", "gain": { "none": "無効", - "album": "アルバムゲインを使う", - "track": "トラックゲインを使う" - }, - "lastfmNotConfigured": "Last.fmのAPIキーが設定されていません" + "album": "アルバムゲインを使用", + "track": "トラックゲインを使用" + } } }, "albumList": "アルバム", - "about": "詳細", "playlists": "プレイリスト", + "onlyFavourites": "お気に入りのみ表示", "sharedPlaylists": "共有プレイリスト", - "librarySelector": { - "allLibraries": "全てのライブラリ( %{count} )", - "multipleLibraries": "%{selected} 個 / %{total} 個のライブラリ", - "selectLibraries": "ライブラリを選択", - "none": "無し" - } + "about": "概要" }, "player": { - "playListsText": "再生リスト", + "playListsText": "再生キュー", "openText": "開く", "closeText": "閉じる", "notContentText": "音楽がありません", "clickToPlayText": "クリックして再生", - "clickToPauseText": "一時停止", + "clickToPauseText": "クリックして一時停止", "nextTrackText": "次の曲", "previousTrackText": "前の曲", - "reloadText": "更新", + "reloadText": "再読み込み", "volumeText": "音量", - "toggleLyricText": "歌詞を切り替え", + "toggleLyricText": "歌詞の表示 / 非表示", "toggleMiniModeText": "最小化", - "destroyText": "削除", + "destroyText": "破棄", "downloadText": "ダウンロード", - "removeAudioListsText": "リストを空にする", - "clickToDeleteText": "クリックして%{name}を削除", + "removeAudioListsText": "オーディオリストを削除", + "clickToDeleteText": "クリックして %{name} を削除", "emptyLyricText": "歌詞がありません", "playModeText": { - "order": "順番に", - "orderLoop": "リピート", - "singleLoop": "一曲リピート", + "order": "順番に再生", + "orderLoop": "繰り返し再生", + "singleLoop": "1 曲のみ繰り返し再生", "shufflePlay": "シャッフル" } }, @@ -578,14 +676,14 @@ "homepage": "ホームページ", "source": "ソースコード", "featureRequests": "機能リクエスト", - "lastInsightsCollection": "最後のデータ収集", + "lastInsightsCollection": "最終データ収集日時", "insights": { "disabled": "無効", "waiting": "待機中" } }, "tabs": { - "about": "詳細", + "about": "概要", "config": "設定" }, "config": { @@ -593,42 +691,43 @@ "environmentVariable": "環境変数", "currentValue": "現在値", "configurationFile": "設定ファイル", - "exportToml": "設定をエクスポート(TOML)", - "exportSuccess": "設定をTOML形式でクリップボードへエクスポートしました", + "exportToml": "設定をエクスポート (TOML)", + "downloadToml": "設定をダウンロード (TOML)", + "exportSuccess": "設定が TOML 形式でクリップボードにエクスポートされました", "exportFailed": "設定のコピーに失敗しました", - "devFlagsHeader": "開発フラグ(変更・削除の可能性あり)", + "devFlagsHeader": "開発用フラグ(変更または削除される可能性があります)", "devFlagsComment": "これらは実験的な設定であり、将来のバージョンで削除される可能性があります" } }, "activity": { - "title": "活動", - "totalScanned": "スキャン済みフォルダー", + "title": "アクティビティ", + "totalScanned": "スキャンされたフォルダーの総数", "quickScan": "クイック", "fullScan": "フル", + "selectiveScan": "選択的", "serverUptime": "サーバー稼働時間", - "serverDown": "サーバーオフライン", + "serverDown": "オフライン", "scanType": "最終スキャン", "status": "スキャンエラー", - "elapsedTime": "経過時間", - "selectiveScan": "選択的スキャン" - }, - "help": { - "title": "ホットキー", - "hotkeys": { - "show_help": "このヘルプを表示", - "toggle_menu": "サイドバーの表示/非表示", - "toggle_play": "再生/停止", - "prev_song": "前の曲", - "next_song": "次の曲", - "vol_up": "音量を上げる", - "vol_down": "音量を下げる", - "toggle_love": "星の付け外し", - "current_song": "現在の曲へ移動" - } + "elapsedTime": "経過時間" }, "nowPlaying": { "title": "再生中", - "empty": "何も再生されていません", - "minutesAgo": "%{smart_count} 分前 |||| %{smart_count} 分前" + "empty": "再生中の曲はありません", + "minutesAgo": "%{smart_count} 分前" + }, + "help": { + "title": "Navidrome ホットキー", + "hotkeys": { + "show_help": "ヘルプを表示", + "toggle_menu": "サイドバーの表示 / 非表示", + "toggle_play": "再生 / 一時停止", + "prev_song": "前の曲", + "next_song": "次の曲", + "current_song": "再生中の曲へ移動", + "vol_up": "音量を上げる", + "vol_down": "音量を下げる", + "toggle_love": "この曲をお気に入り登録 / 登録解除" + } } -} \ No newline at end of file +} From 9198bde34ad1b75060773de32ff3bb111b9d6ab9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 6 Sep 2026 13:40:43 -0400 Subject: [PATCH 64/78] test(scanner): fix Windows flake in the quick-scan artist image spec (#6093) The spec asserted on artistID("Kraftwerk") and intermittently found zero artists on Windows. The scan did import the artist; it was then made invisible. RefreshStats selects touched artists with a strict artist.updated_at > library.last_scan_at (persistence/artist_repository.go:466). Windows' wall clock has ~15ms granularity, so a new artist written by a quick scan can land in the same tick as the previous scan's last_scan_at and be excluded. Its library_artist.stats then stays at the '{}' default and the unscoped cleanup DELETE removes the row, after which selectArtist's INNER JOIN on library_artist hides the artist from GetAll. Backdate last_scan_at before the scan so the comparison is unambiguous, matching the fix already applied to the search_normalized spec below it. --- scanner/scanner_test.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scanner/scanner_test.go b/scanner/scanner_test.go index 00f91699d..8542b3ac6 100644 --- a/scanner/scanner_test.go +++ b/scanner/scanner_test.go @@ -387,6 +387,11 @@ var _ = Describe("Scanner", Ordered, func() { files := fsys.MapFS files["Kraftwerk/Autobahn/01 - Autobahn.mp3"] = kraftwerk(track(1, "Autobahn")) fsys.SetFiles(files) + // Backdate the previous scan so this one's new artists are unambiguously newer: + // RefreshStats picks touched artists with a strict artist.updated_at > + // library.last_scan_at, and Windows' coarse clock can put both in one tick. + _, err := db.Db().ExecContext(ctx, "UPDATE library SET last_scan_at = ?", time.Now().Add(-time.Hour)) + Expect(err).ToNot(HaveOccurred()) Expect(runScanner(ctx, false)).To(Succeed()) resolveQueuedArtwork() From 97e1f73cc85bc35eb1d998e9e6a987b239552537 Mon Sep 17 00:00:00 2001 From: Shxiao Date: Mon, 7 Sep 2026 12:08:49 +0900 Subject: [PATCH 65/78] docs: fix broken links in Jellyfin and plugin documentation (#6097) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: point jftui client link to canonical repository * docs: fix relative path to webhook-rs example in nd-pdk-host README * docs: fix capability schema paths in plugin examples README --------- Co-authored-by: Deluan Quintão --- plugins/examples/README.md | 4 ++-- plugins/pdk/rust/nd-pdk-host/README.md | 2 +- server/jellyfin/README.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/examples/README.md b/plugins/examples/README.md index bce2b6762..8e2dc5a85 100644 --- a/plugins/examples/README.md +++ b/plugins/examples/README.md @@ -108,7 +108,7 @@ Generate boilerplate from a schema: # Install XTP: https://docs.xtp.dylibso.com/docs/cli xtp plugin init \ - --schema-file ../schemas/metadata_agent.yaml \ + --schema-file ../capabilities/metadata_agent.yaml \ --template go \ --path ./my-plugin \ --name my-plugin @@ -119,7 +119,7 @@ xtp plugin build zip -j my-plugin.ndp manifest.json dist/plugin.wasm ``` -Available schemas in [../schemas/](../schemas/): +Available schemas in [../capabilities/](../capabilities/): - `metadata_agent.yaml` – Artist/album metadata - `scrobbler.yaml` – Scrobbling integration - `lifecycle.yaml` – Init callbacks diff --git a/plugins/pdk/rust/nd-pdk-host/README.md b/plugins/pdk/rust/nd-pdk-host/README.md index f722b2e5a..00bcdfc2f 100644 --- a/plugins/pdk/rust/nd-pdk-host/README.md +++ b/plugins/pdk/rust/nd-pdk-host/README.md @@ -84,4 +84,4 @@ Rust plugins must be compiled to WebAssembly: cargo build --target wasm32-wasip1 --release ``` -See the [webhook-rs](../../examples/webhook-rs/) example for a complete plugin implementation. +See the [webhook-rs](../../../examples/webhook-rs/) example for a complete plugin implementation. diff --git a/server/jellyfin/README.md b/server/jellyfin/README.md index 15b56a499..5b9a2dba4 100644 --- a/server/jellyfin/README.md +++ b/server/jellyfin/README.md @@ -3,7 +3,7 @@ This package implements a subset of the [Jellyfin](https://jellyfin.org/) REST API on top of Navidrome's existing library, users, playlists and scrobbling infrastructure. It lets Jellyfin-compatible clients (e.g. [Finamp](https://github.com/jmshrv/finamp), -[jftui](https://github.com/dylanmtaylor/jftui)) browse and stream a Navidrome library without +[jftui](https://github.com/Aanok/jftui)) browse and stream a Navidrome library without requiring a real Jellyfin server. It is **not** a full Jellyfin server implementation: only the endpoints needed to browse a music From 1ceb25c6c1cc0698efcf164010cf8a25092d0024 Mon Sep 17 00:00:00 2001 From: jaxi Date: Sun, 6 Sep 2026 23:28:25 -0400 Subject: [PATCH 66/78] =?UTF-8?q?feat(ui):=20added=20Catppuccin=20Mocha=20?= =?UTF-8?q?and=20Frapp=C3=A9=20themes,=20updated=20Macchiato=20theme=20to?= =?UTF-8?q?=20better=20reflect=20the=20official=20palette=20(#5835)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * added catppuccin mocha theme * added catppuccin mocha theme to index.js * fix syntax * add catppuccin frappé theme * made frappe and mocha themes more consistant with official color palette and added comments for easy verification * same for macchiato, seperate commit in case original is preferred * added comments to .js files --------- Co-authored-by: Deluan Quintão --- ui/src/themes/catppuccinFrappe.css.js | 158 ++++++++++++++++++++ ui/src/themes/catppuccinFrappe.js | 106 +++++++++++++ ui/src/themes/catppuccinLatte.css.js | 54 +++---- ui/src/themes/catppuccinMacchiato.css.js | 180 ++++++++++++----------- ui/src/themes/catppuccinMacchiato.js | 50 +++---- ui/src/themes/catppuccinMocha.css.js | 158 ++++++++++++++++++++ ui/src/themes/catppuccinMocha.js | 106 +++++++++++++ ui/src/themes/index.js | 6 +- 8 files changed, 677 insertions(+), 141 deletions(-) create mode 100644 ui/src/themes/catppuccinFrappe.css.js create mode 100644 ui/src/themes/catppuccinFrappe.js create mode 100644 ui/src/themes/catppuccinMocha.css.js create mode 100644 ui/src/themes/catppuccinMocha.js diff --git a/ui/src/themes/catppuccinFrappe.css.js b/ui/src/themes/catppuccinFrappe.css.js new file mode 100644 index 000000000..f927d3ba1 --- /dev/null +++ b/ui/src/themes/catppuccinFrappe.css.js @@ -0,0 +1,158 @@ +const stylesheet = ` + .react-jinke-music-player-main svg:active, + .react-jinke-music-player-main svg:hover { + color: #949cbb; /* overlay2 */ + } + + .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle, + .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track { + background-color: #a5adce; /* subtext0 */ + } + + .react-jinke-music-player-main ::-webkit-scrollbar-thumb { + background-color: #a5adce; /* subtext0 */ + } + + .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { + box-shadow: 0 0 2px #a5adce; /* subtext0 */ + } + + .react-jinke-music-player-main .audio-item.playing svg { + color: #a5adce; /* subtext0 */ + } + + .react-jinke-music-player-main .audio-item.playing .player-singer { + color: #a5adce !important; + } + + .react-jinke-music-player-main .loading svg { + color: #a5adce !important; + } + + + .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle { + border: hidden; + box-shadow:rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; + } + + + .rc-slider-rail, .rc-slider-track { + height: 6px; + } + + .rc-slider { + padding: 3px 0; + } + + .sound-operation > div:nth-child(4) { + transform: translateX(-50%) translateY(5%) !important; + } + + .sound-operation { + padding: 4px 0; + } + + .react-jinke-music-player-main .music-player-panel { + background-color: #292c3c; /* mantle */ + color: #c6d0f5; /* text */ + box-shadow: 0 0 8px rgba(0, 0, 0, 0.25); + } + + .audio-lists-panel { + background-color: #292c3c; /* mantle */ + bottom: 6.25rem; + box-shadow:rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; + } + + .audio-lists-panel-content .audio-item.playing { + background-color: rgba(0, 0, 0, 0); + } + + .audio-lists-panel-content .audio-item:nth-child(2n+1) { + background-color: rgba(0, 0, 0, 0); + } + + .audio-lists-panel-content .audio-item:active, + .audio-lists-panel-content .audio-item:hover { + background-color:rgba(255, 255, 255, 0.08); + } + + .audio-lists-panel-header { + border-bottom:1px solid #414559; /* surface0 */ + } + + .react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-lists-btn { + background-color:rgba(0,0,0,0); + box-shadow:0 0 0 0; + } + + .audio-lists-panel-content .audio-item { + line-height: 32px; + } + + .react-jinke-music-player-main .music-player-panel .panel-content .img-content { + box-shadow:rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; + } + + .react-jinke-music-player-main .music-player-lyric { + color: #a5adce; /* subtext0 */ + -webkit-text-stroke: 0.5px #303446; /* base */ + font-weight: bolder; + } + + .react-jinke-music-player-main .lyric-btn-active, + .react-jinke-music-player-main .lyric-btn-active svg { + color: #a5adce !important; + } + + .audio-lists-panel-header { + border-bottom:1px solid rgba(0, 0, 0, 0.25); + box-shadow:none; + } + + .audio-lists-panel-content .audio-item.playing, + .audio-lists-panel-content .audio-item.playing svg { + color: #a5adce; /* subtext0 */ + } + + .audio-lists-panel-content .audio-item:active .group:not([class=".player-delete"]) svg, + .audio-lists-panel-content .audio-item:hover .group:not([class=".player-delete"]) svg { + color: #a5adce; /* subtext0 */ + } + + .audio-lists-panel-content .audio-item .player-icons { + scale: 75%; + } + + .audio-lists-panel-content .audio-item:active, + .audio-lists-panel-content .audio-item:hover { + background-color: #51576d; /* surface1 */ + } + +/* Mobile */ + + .react-jinke-music-player-mobile-cover { + border: none; + box-shadow:rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; + } + + .react-jinke-music-player .music-player-controller { + border: none; + box-shadow:rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; + color: #a5adce; /* subtext0 */ + } + + .react-jinke-music-player .music-player-controller .music-player-controller-setting { + color: rgba(122,160,236,.3); + } + + .react-jinke-music-player-mobile-progress .rc-slider-handle, .react-jinke-music-player-mobile-progress .rc-slider-track { + background-color: #a5adce; /* subtext0 */ + } + + .react-jinke-music-player-mobile-progress .rc-slider-handle { + border: none; + } +` + +export default stylesheet diff --git a/ui/src/themes/catppuccinFrappe.js b/ui/src/themes/catppuccinFrappe.js new file mode 100644 index 000000000..87ff6258f --- /dev/null +++ b/ui/src/themes/catppuccinFrappe.js @@ -0,0 +1,106 @@ +import stylesheet from './catppuccinFrappe.css.js' + +export default { + themeName: 'Catppuccin Frappé', + palette: { + primary: { + main: '#ca9ee6', // Mauve + }, + secondary: { + main: '#292c3c', //Mantle + contrastText: '#c6d0f5', // Text + }, + type: 'dark', + background: { + default: '#303446', // Base + }, + }, + overrides: { + MuiPaper: { + root: { + color: '#c6d0f5', // Text + backgroundColor: '#292c3c', // Mantle + MuiSnackbarContent: { + root: { + color: '#c6d0f5', // Text + backgroundColor: '#e78284', // Red + }, + message: { + color: '#c6d0f5', // Text + backgroundColor: '#e78284', // Red + }, + }, + }, + }, + MuiButton: { + textPrimary: { + color: '#8caaee', // Blue + }, + textSecondary: { + color: '#c6d0f5', // Text + }, + }, + MuiChip: { + clickable: { + background: '#292c3c', //Mantle + }, + }, + MuiFormGroup: { + root: { + color: '#c6d0f5', // Text + }, + }, + MuiFormHelperText: { + root: { + Mui: { + error: { + color: '#e78284', // Red + }, + }, + }, + }, + MuiTableHead: { + root: { + color: '#c6d0f5', // Text + background: '#292c3c', //Mantle + }, + }, + MuiTableCell: { + root: { + color: '#c6d0f5', // Text + background: '#292c3c !important', //Mantle + }, + head: { + color: '#c6d0f5', // Text + background: '#292c3c !important', //Mantle + }, + }, + NDLogin: { + systemNameLink: { + color: '#ca9ee6', // Mauve + }, + icon: {}, + welcome: { + color: '#c6d0f5', // Text + }, + card: { + minWidth: 300, + background: '#303446', // Base + }, + avatar: {}, + button: { + boxShadow: '3px 3px 5px #303446', // Base + }, + }, + NDMobileArtistDetails: { + bgContainer: { + background: + 'linear-gradient(to bottom, rgba(52 52 52 / 72%), rgb(48 48 48))!important', + }, + }, + }, + player: { + theme: 'dark', + stylesheet, + }, +} diff --git a/ui/src/themes/catppuccinLatte.css.js b/ui/src/themes/catppuccinLatte.css.js index 84c8d2d7f..006601cd9 100644 --- a/ui/src/themes/catppuccinLatte.css.js +++ b/ui/src/themes/catppuccinLatte.css.js @@ -2,41 +2,41 @@ const stylesheet = ` .react-jinke-music-player-main.light-theme svg, .react-jinke-music-player .music-player-controller, .react-jinke-music-player .audio-circle-process-bar circle[class='stroke'] { - color: #6c6f85; - stroke: #6c6f85; + color: #6c6f85; /* subtext0 */ + stroke: #6c6f85; /* subtext0 */ } .react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover { - color: #7c7f93; + color: #7c7f93; /* overlay2 */ } .react-jinke-music-player-main.light-theme svg:active, .react-jinke-music-player-main.light-theme svg:hover { - color: #7c7f93; + color: #7c7f93; /* overlay2 */ } .react-jinke-music-player-mobile-play-model-tip, .react-jinke-music-player-main.light-theme .play-mode-title { - background-color: #6c6f85; - color: #eff1f5; + background-color: #6c6f85; /* subtext0 */ + color: #eff1f5; /* base */ } .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle, .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track { - background-color: #6c6f85; + background-color: #6c6f85; /* subtext0 */ } .react-jinke-music-player-main ::-webkit-scrollbar-thumb { - background-color: #6c6f85; + background-color: #6c6f85; /* subtext0 */ } .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { - box-shadow: 0 0 2px #6c6f85; + box-shadow: 0 0 2px #6c6f85; /* subtext0 */ } .react-jinke-music-player-main .audio-item.playing svg { - color: #6c6f85; + color: #6c6f85; /* subtext0 */ } .react-jinke-music-player-main .audio-item.playing .player-singer { @@ -65,7 +65,7 @@ const stylesheet = ` .react-jinke-music-player-main.light-theme .rc-switch-checked { background-color: #6c6f85 !important; - border: 1px solid #6c6f85; + border: 1px solid #6c6f85; /* subtext0 */ } .sound-operation > div:nth-child(4) { @@ -77,17 +77,17 @@ const stylesheet = ` } .react-jinke-music-player-main .music-player-panel { - background-color: #e6e9ef; - color: #4c4f69; + background-color: #e6e9ef; /* mantle */ + color: #4c4f69; /* text */ box-shadow: 0 0 8px rgba(76, 79, 105, 0.15); } .react-jinke-music-player-main.light-theme .music-player-panel { - color: #4c4f69; + color: #4c4f69; /* text */ } .audio-lists-panel { - background-color: #e6e9ef; + background-color: #e6e9ef; /* mantle */ bottom: 6.25rem; box-shadow: rgba(76, 79, 105, 0.12) 0px 4px 6px, @@ -108,7 +108,7 @@ const stylesheet = ` } .audio-lists-panel-header { - border-bottom: 1px solid #ccd0da; + border-bottom: 1px solid #ccd0da; /* surface0 */ box-shadow: none; } @@ -118,13 +118,13 @@ const stylesheet = ` } .react-jinke-music-player-main.light-theme .audio-lists-panel-header { - background-color: #e6e9ef; - color: #4c4f69; + background-color: #e6e9ef; /* mantle */ + color: #4c4f69; /* text */ } .audio-lists-panel-content .audio-item { line-height: 32px; - color: #4c4f69; + color: #4c4f69; /* text */ } .react-jinke-music-player-main .music-player-panel .panel-content .img-content { @@ -135,7 +135,7 @@ const stylesheet = ` .react-jinke-music-player-main .music-player-lyric { color: #6c6f85; /* subtext0 */ - -webkit-text-stroke: 0.35px #eff1f5; + -webkit-text-stroke: 0.35px #eff1f5; /* base */ font-weight: bolder; } @@ -146,12 +146,12 @@ const stylesheet = ` .audio-lists-panel-content .audio-item.playing, .audio-lists-panel-content .audio-item.playing svg { - color: #6c6f85; + color: #6c6f85; /* subtext0 */ } .audio-lists-panel-content .audio-item:active .group:not([class=".player-delete"]) svg, .audio-lists-panel-content .audio-item:hover .group:not([class=".player-delete"]) svg { - color: #6c6f85; + color: #6c6f85; /* subtext0 */ } .audio-lists-panel-content .audio-item .player-icons { @@ -173,12 +173,12 @@ const stylesheet = ` .react-jinke-music-player .music-player-controller { border: none; - background-color: #e6e9ef; - border-color: #e6e9ef; + background-color: #e6e9ef; /* mantle */ + border-color: #e6e9ef; /* mantle */ box-shadow: rgba(76, 79, 105, 0.12) 0px 4px 6px, rgba(76, 79, 105, 0.08) 0px 5px 7px; - color: #6c6f85; + color: #6c6f85; /* subtext0 */ } .react-jinke-music-player .music-player-controller.music-player-playing:before { @@ -187,12 +187,12 @@ const stylesheet = ` .react-jinke-music-player .music-player-controller .music-player-controller-setting { background: rgba(108, 111, 133, 0.2); - color: #eff1f5; + color: #eff1f5; /* base */ } .react-jinke-music-player-mobile-progress .rc-slider-handle, .react-jinke-music-player-mobile-progress .rc-slider-track { - background-color: #6c6f85; + background-color: #6c6f85; /* subtext0 */ } .react-jinke-music-player-mobile-progress .rc-slider-handle { diff --git a/ui/src/themes/catppuccinMacchiato.css.js b/ui/src/themes/catppuccinMacchiato.css.js index d303a0364..e415a990b 100644 --- a/ui/src/themes/catppuccinMacchiato.css.js +++ b/ui/src/themes/catppuccinMacchiato.css.js @@ -1,154 +1,158 @@ const stylesheet = ` + .react-jinke-music-player-main svg:active, + .react-jinke-music-player-main svg:hover { + color: #939ab7; /* overlay2 */ + } -.react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover { - color: #00a4dc -} + .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle, + .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track { + background-color: #a5adcb; /* subtext0 */ + } -.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle, .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track { - background-color: #00a4dc -} + .react-jinke-music-player-main ::-webkit-scrollbar-thumb { + background-color: #a5adcb; /* subtext0 */ + } -.react-jinke-music-player-main ::-webkit-scrollbar-thumb { - background-color: #00a4dc; -} + .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { + box-shadow: 0 0 2px #a5adcb; /* subtext0 */ + } -.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { - box-shadow: 0 0 2px #00a4dc -} + .react-jinke-music-player-main .audio-item.playing svg { + color: #a5adcb; /* subtext0 */ + } -.react-jinke-music-player-main .audio-item.playing svg { - color: #00a4dc -} + .react-jinke-music-player-main .audio-item.playing .player-singer { + color: #a5adcb !important; + } -.react-jinke-music-player-main .audio-item.playing .player-singer { - color: #00a4dc !important -} - -.react-jinke-music-player-main .loading svg { - color: #00a4dc !important -} + .react-jinke-music-player-main .loading svg { + color: #a5adcb !important; + } -.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle { + .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle { border: hidden; box-shadow:rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; -} + } -.rc-slider-rail, .rc-slider-track { + .rc-slider-rail, .rc-slider-track { height: 6px; -} + } -.rc-slider { + .rc-slider { padding: 3px 0; -} + } -.sound-operation > div:nth-child(4) { + .sound-operation > div:nth-child(4) { transform: translateX(-50%) translateY(5%) !important; -} + } -.sound-operation { + .sound-operation { padding: 4px 0; -} + } -.react-jinke-music-player-main .music-player-panel { - background-color: #1e2030; - color: #24273a + .react-jinke-music-player-main .music-player-panel { + background-color: #1e2030; /* mantle */ + color: #cad3f5; /* text */ box-shadow: 0 0 8px rgba(0, 0, 0, 0.25); -} + } -.audio-lists-panel { - background-color: #1e2030; + .audio-lists-panel { + background-color: #1e2030; /* mantle */ bottom: 6.25rem; box-shadow:rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; -} + } -.audio-lists-panel-content .audio-item.playing { + .audio-lists-panel-content .audio-item.playing { background-color: rgba(0, 0, 0, 0); -} + } -.audio-lists-panel-content .audio-item:nth-child(2n+1) { + .audio-lists-panel-content .audio-item:nth-child(2n+1) { background-color: rgba(0, 0, 0, 0); -} + } -.audio-lists-panel-content .audio-item:active, -.audio-lists-panel-content .audio-item:hover { + .audio-lists-panel-content .audio-item:active, + .audio-lists-panel-content .audio-item:hover { background-color:rgba(255, 255, 255, 0.08); -} + } -.audio-lists-panel-header { - border-bottom:1px solid #242936; -} + .audio-lists-panel-header { + border-bottom:1px solid #363a4f; /* surface0 */ + } -.react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-lists-btn { + .react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-lists-btn { background-color:rgba(0,0,0,0); box-shadow:0 0 0 0; -} + } -.audio-lists-panel-content .audio-item { + .audio-lists-panel-content .audio-item { line-height: 32px; -} + } -.react-jinke-music-player-main .music-player-panel .panel-content .img-content { + .react-jinke-music-player-main .music-player-panel .panel-content .img-content { box-shadow:rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; -} + } -.react-jinke-music-player-main .music-player-lyric { - color: #acb3d2; - -webkit-text-stroke: 0.5px #2E3440; + .react-jinke-music-player-main .music-player-lyric { + color: #a5adcb; /* subtext0 */ + -webkit-text-stroke: 0.5px #1e1e2e; /* base */ font-weight: bolder; -} + } -.react-jinke-music-player-main .lyric-btn-active, .react-jinke-music-player-main .lyric-btn-active svg { - color: #acb3d2 !important; -} + .react-jinke-music-player-main .lyric-btn-active, + .react-jinke-music-player-main .lyric-btn-active svg { + color: #a5adcb !important; + } -.audio-lists-panel-header { + .audio-lists-panel-header { border-bottom:1px solid rgba(0, 0, 0, 0.25); box-shadow:none; -} + } -.audio-lists-panel-content .audio-item.playing, .audio-lists-panel-content .audio-item.playing svg { - color: #00a4dc -} + .audio-lists-panel-content .audio-item.playing, + .audio-lists-panel-content .audio-item.playing svg { + color: #a5adcb; /* subtext0 */ + } -.audio-lists-panel-content .audio-item:active .group:not([class=".player-delete"]) svg, .audio-lists-panel-content .audio-item:hover .group:not([class=".player-delete"]) svg { - color: #00a4dc -} + .audio-lists-panel-content .audio-item:active .group:not([class=".player-delete"]) svg, + .audio-lists-panel-content .audio-item:hover .group:not([class=".player-delete"]) svg { + color: #a5adcb; /* subtext0 */ + } -.audio-lists-panel-content .audio-item .player-icons { + .audio-lists-panel-content .audio-item .player-icons { scale: 75%; -} + } -.audio-lists-panel-content .audio-item:active, -.audio-lists-panel-content .audio-item:hover { - background-color: #363a4f; -} + .audio-lists-panel-content .audio-item:active, + .audio-lists-panel-content .audio-item:hover { + background-color: #494d64; /* surface1 */ + } /* Mobile */ -.react-jinke-music-player-mobile-cover { + .react-jinke-music-player-mobile-cover { border: none; box-shadow:rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; -} + } -.react-jinke-music-player .music-player-controller { + .react-jinke-music-player .music-player-controller { border: none; box-shadow:rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; - color: #00a4dc; -} + color: #a5adcb; /* subtext0 */ + } -.react-jinke-music-player .music-player-controller .music-player-controller-setting { + .react-jinke-music-player .music-player-controller .music-player-controller-setting { color: rgba(122,160,236,.3); -} + } -.react-jinke-music-player-mobile-progress .rc-slider-handle, .react-jinke-music-player-mobile-progress .rc-slider-track { - background-color: #00a4dc; -} + .react-jinke-music-player-mobile-progress .rc-slider-handle, .react-jinke-music-player-mobile-progress .rc-slider-track { + background-color: #a5adcb; /* subtext0 */ + } -.react-jinke-music-player-mobile-progress .rc-slider-handle { + .react-jinke-music-player-mobile-progress .rc-slider-handle { border: none; -} + } ` export default stylesheet diff --git a/ui/src/themes/catppuccinMacchiato.js b/ui/src/themes/catppuccinMacchiato.js index 63c93ff66..b41a7b2ea 100644 --- a/ui/src/themes/catppuccinMacchiato.js +++ b/ui/src/themes/catppuccinMacchiato.js @@ -4,92 +4,92 @@ export default { themeName: 'Catppuccin Macchiato', palette: { primary: { - main: '#c6a0f6', + main: '#c6a0f6', // Mauve }, secondary: { - main: '#1e2030', - contrastText: '#cad3f5', + main: '#1e2030', // Mantle + contrastText: '#cad3f5', // Text }, type: 'dark', background: { - default: '#24273a', + default: '#24273a', // Base }, }, overrides: { MuiPaper: { root: { - color: '#cad3f5', - backgroundColor: '#1e2030', + color: '#cad3f5', // Text + backgroundColor: '#1e2030', // Mantle MuiSnackbarContent: { root: { - color: '#cad3f5', - backgroundColor: '#ed8796', + color: '#cad3f5', // Text + backgroundColor: '#ed8796', // Red }, message: { - color: '#cad3f5', - backgroundColor: '#ed8796', + color: '#cad3f5', // Text + backgroundColor: '#ed8796', // Red }, }, }, }, MuiButton: { textPrimary: { - color: '#8aadf4', + color: '#8aadf4', // Blue }, textSecondary: { - color: '#cad3f5', + color: '#cad3f5', // Text }, }, MuiChip: { clickable: { - background: '#1e2030', + background: '#1e2030', // Mantle }, }, MuiFormGroup: { root: { - color: '#cad3f5', + color: '#cad3f5', // Text }, }, MuiFormHelperText: { root: { Mui: { error: { - color: '#ed8796', + color: '#ed8796', // Red }, }, }, }, MuiTableHead: { root: { - color: '#cad3f5', - background: '#1e2030', + color: '#cad3f5', // Text + background: '#1e2030', // Mantle }, }, MuiTableCell: { root: { - color: '#cad3f5', - background: '#1e2030 !important', + color: '#cad3f5', // Text + background: '#1e2030 !important', // Mantle }, head: { - color: '#cad3f5', - background: '#1e2030 !important', + color: '#cad3f5', // Text + background: '#1e2030 !important', // Mantle }, }, NDLogin: { systemNameLink: { - color: '#c6a0f6', + color: '#c6a0f6', // Mauve }, icon: {}, welcome: { - color: '#cad3f5', + color: '#cad3f5', // Text }, card: { minWidth: 300, - background: '#24273a', + background: '#24273a', // Base }, avatar: {}, button: { - boxShadow: '3px 3px 5px #24273a', + boxShadow: '3px 3px 5px #24273a', // Base }, }, NDMobileArtistDetails: { diff --git a/ui/src/themes/catppuccinMocha.css.js b/ui/src/themes/catppuccinMocha.css.js new file mode 100644 index 000000000..7a238468a --- /dev/null +++ b/ui/src/themes/catppuccinMocha.css.js @@ -0,0 +1,158 @@ +const stylesheet = ` + .react-jinke-music-player-main svg:active, + .react-jinke-music-player-main svg:hover { + color: #9399b2; /* overlay2 */ + } + + .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle, + .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track { + background-color: #a6adc8; /* subtext0 */ + } + + .react-jinke-music-player-main ::-webkit-scrollbar-thumb { + background-color: #a6adc8; /* subtext0 */ + } + + .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { + box-shadow: 0 0 2px #a6adc8; /* subtext0 */ + } + + .react-jinke-music-player-main .audio-item.playing svg { + color: #a6adc8; /* subtext0 */ + } + + .react-jinke-music-player-main .audio-item.playing .player-singer { + color: #a6adc8 !important; + } + + .react-jinke-music-player-main .loading svg { + color: #a6adc8 !important; + } + + + .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle { + border: hidden; + box-shadow:rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; + } + + + .rc-slider-rail, .rc-slider-track { + height: 6px; + } + + .rc-slider { + padding: 3px 0; + } + + .sound-operation > div:nth-child(4) { + transform: translateX(-50%) translateY(5%) !important; + } + + .sound-operation { + padding: 4px 0; + } + + .react-jinke-music-player-main .music-player-panel { + background-color: #181825; /* mantle */ + color: #cdd6f4; /* text */ + box-shadow: 0 0 8px rgba(0, 0, 0, 0.25); + } + + .audio-lists-panel { + background-color: #181825; /* mantle */ + bottom: 6.25rem; + box-shadow:rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; + } + + .audio-lists-panel-content .audio-item.playing { + background-color: rgba(0, 0, 0, 0); + } + + .audio-lists-panel-content .audio-item:nth-child(2n+1) { + background-color: rgba(0, 0, 0, 0); + } + + .audio-lists-panel-content .audio-item:active, + .audio-lists-panel-content .audio-item:hover { + background-color:rgba(255, 255, 255, 0.08); + } + + .audio-lists-panel-header { + border-bottom:1px solid #313244; /* surface0 */ + } + + .react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-lists-btn { + background-color:rgba(0,0,0,0); + box-shadow:0 0 0 0; + } + + .audio-lists-panel-content .audio-item { + line-height: 32px; + } + + .react-jinke-music-player-main .music-player-panel .panel-content .img-content { + box-shadow:rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; + } + + .react-jinke-music-player-main .music-player-lyric { + color: #a6adc8; /* subtext0 */ + -webkit-text-stroke: 0.5px #1e1e2e; /* base */ + font-weight: bolder; + } + + .react-jinke-music-player-main .lyric-btn-active, + .react-jinke-music-player-main .lyric-btn-active svg { + color: #a6adc8 !important; + } + + .audio-lists-panel-header { + border-bottom:1px solid rgba(0, 0, 0, 0.25); + box-shadow:none; + } + + .audio-lists-panel-content .audio-item.playing, + .audio-lists-panel-content .audio-item.playing svg { + color: #a6adc8; /* subtext0 */ + } + + .audio-lists-panel-content .audio-item:active .group:not([class=".player-delete"]) svg, + .audio-lists-panel-content .audio-item:hover .group:not([class=".player-delete"]) svg { + color: #a6adc8; /* subtext0 */ + } + + .audio-lists-panel-content .audio-item .player-icons { + scale: 75%; + } + + .audio-lists-panel-content .audio-item:active, + .audio-lists-panel-content .audio-item:hover { + background-color: #45475a; /* surface1 */ + } + +/* Mobile */ + + .react-jinke-music-player-mobile-cover { + border: none; + box-shadow:rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; + } + + .react-jinke-music-player .music-player-controller { + border: none; + box-shadow:rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; + color: #a6adc8; /* subtext0 */ + } + + .react-jinke-music-player .music-player-controller .music-player-controller-setting { + color: rgba(122,160,236,.3); + } + + .react-jinke-music-player-mobile-progress .rc-slider-handle, .react-jinke-music-player-mobile-progress .rc-slider-track { + background-color: #a6adc8; /* subtext0 */ + } + + .react-jinke-music-player-mobile-progress .rc-slider-handle { + border: none; + } +` + +export default stylesheet diff --git a/ui/src/themes/catppuccinMocha.js b/ui/src/themes/catppuccinMocha.js new file mode 100644 index 000000000..bdfb33520 --- /dev/null +++ b/ui/src/themes/catppuccinMocha.js @@ -0,0 +1,106 @@ +import stylesheet from './catppuccinMocha.css.js' + +export default { + themeName: 'Catppuccin Mocha', + palette: { + primary: { + main: '#cba6f7', // Mauve + }, + secondary: { + main: '#181825', // Mantle + contrastText: '#cdd6f4', // Text + }, + type: 'dark', + background: { + default: '#1e1e2e', // Base + }, + }, + overrides: { + MuiPaper: { + root: { + color: '#cdd6f4', // Text + backgroundColor: '#181825', // Mantle + MuiSnackbarContent: { + root: { + color: '#cdd6f4', // Text + backgroundColor: '#f38ba8', // Red + }, + message: { + color: '#cdd6f4', // Text + backgroundColor: '#f38ba8', // Red + }, + }, + }, + }, + MuiButton: { + textPrimary: { + color: '#89b4fa', // Blue + }, + textSecondary: { + color: '#cdd6f4', // Text + }, + }, + MuiChip: { + clickable: { + background: '#181825', // Mantle + }, + }, + MuiFormGroup: { + root: { + color: '#cdd6f4', // Text + }, + }, + MuiFormHelperText: { + root: { + Mui: { + error: { + color: '#f38ba8', // Red + }, + }, + }, + }, + MuiTableHead: { + root: { + color: '#cdd6f4', // Text + background: '#181825', // Mantle + }, + }, + MuiTableCell: { + root: { + color: '#cdd6f4', // Text + background: '#181825 !important', // Mantle + }, + head: { + color: '#cdd6f4', // Text + background: '#181825 !important', // Mantle + }, + }, + NDLogin: { + systemNameLink: { + color: '#cba6f7', // Mauve + }, + icon: {}, + welcome: { + color: '#cdd6f4', // Text + }, + card: { + minWidth: 300, + background: '#1e1e2e', // Base + }, + avatar: {}, + button: { + boxShadow: '3px 3px 5px #1e1e2e', // Base + }, + }, + NDMobileArtistDetails: { + bgContainer: { + background: + 'linear-gradient(to bottom, rgba(52 52 52 / 72%), rgb(48 48 48))!important', + }, + }, + }, + player: { + theme: 'dark', + stylesheet, + }, +} diff --git a/ui/src/themes/index.js b/ui/src/themes/index.js index 98705da30..391a97f2c 100644 --- a/ui/src/themes/index.js +++ b/ui/src/themes/index.js @@ -8,7 +8,9 @@ import MonokaiTheme from './monokai' import ElectricPurpleTheme from './electricPurple' import NordTheme from './nord' import GruvboxDarkTheme from './gruvboxDark' +import CatppuccinFrappeTheme from './catppuccinFrappe' import CatppuccinMacchiatoTheme from './catppuccinMacchiato' +import CatppuccinMochaTheme from './catppuccinMocha' import CatppuccinLatteTheme from './catppuccinLatte' import DraculaTheme from './dracula' import NuclearTheme from './nuclear' @@ -31,8 +33,10 @@ export default { // New themes should be added here, in alphabetic order AmusicTheme, - CatppuccinMacchiatoTheme, + CatppuccinFrappeTheme, CatppuccinLatteTheme, + CatppuccinMacchiatoTheme, + CatppuccinMochaTheme, DraculaTheme, ElectricPurpleTheme, ExtraDarkTheme, From 48af781b82524fcb1dd9e4b1a0a3d35c7405428b Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 7 Sep 2026 13:47:45 -0400 Subject: [PATCH 67/78] fix(reflex): exclude .worktrees from the reflex configuration regex --- reflex.conf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reflex.conf b/reflex.conf index 47dd775ab..1cbaa7bb7 100644 --- a/reflex.conf +++ b/reflex.conf @@ -1 +1 @@ --s -r "(\.go$$|\.cpp$$|\.h$$|navidrome.toml|resources|token_received.html)" -R "(^ui|^data|^db/migrations)" -R "_test\.go$$" -- go run -race -tags netgo,sqlite_fts5 . +-s -r "(\.go$$|\.cpp$$|\.h$$|navidrome.toml|resources|token_received.html)" -R "(^ui|^data|^db/migrations)" -R "_test\.go$$" -R "^\.worktrees" -- go run -race -tags netgo,sqlite_fts5 . From 404837799b9e7fc378625a1e8e1834df5c3533a3 Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 7 Sep 2026 13:43:26 -0400 Subject: [PATCH 68/78] fix(subsonic): don't re-encode a source already in the player's forced format When a player has a forced transcoding format, ClientInfo.ForceFormat cleared DirectPlayProfiles unconditionally. A FLAC source on a player configured to transcode to FLAC was therefore re-encoded to FLAC, wasting CPU and bandwidth for no gain. Worse, the transcoder pipes ffmpeg output to stdout, so the resulting FLAC has total_samples=0 and no seek table -- an offline copy of it can never be seeked. Reported against getTranscodeDecision by the Symfonium author. ForceFormat now rebuilds DirectPlayProfiles from the matching transcoding profiles instead of dropping them: a client declaring a transcoding profile for a format is proof it can consume that format, so a source already in it is served as-is. Container and codec come from resolveTargetFormat, so a legacy "oga" target_format yields an ogg/opus profile, and the profile's MaxAudioChannels is carried across. DirectPlayProfile has no bitrate field, so restoring direct play needs a ceiling to keep an over-bitrate source out of it. GetTranscodeDecision now seeds that ceiling from the transcoding row's DefaultBitRate when a format was successfully forced, with the player's own MaxBitRate still taking precedence. This also closes a gap where the new endpoint ignored DefaultBitRate entirely: an mp3 320 source on a player forced to mp3@192 was served at 320, while the legacy /rest/stream path correctly gave 192. Applied via CapBitrate, which only ever lowers, so a client declaring a stricter limit keeps it. The legacy path (applyServerOverride) is untouched -- ForceFormat has no other callers. --- core/stream/decider_test.go | 76 +++++++++++++++++++++++++++++++ core/stream/types.go | 26 +++++++---- core/stream/types_test.go | 32 ++++++++++++- server/subsonic/transcode.go | 25 ++++++---- server/subsonic/transcode_test.go | 45 +++++++++++++++++- 5 files changed, 183 insertions(+), 21 deletions(-) diff --git a/core/stream/decider_test.go b/core/stream/decider_test.go index 577207636..01fef1249 100644 --- a/core/stream/decider_test.go +++ b/core/stream/decider_test.go @@ -1144,6 +1144,82 @@ var _ = Describe("Decider", func() { }) }) + Context("Player-forced format", func() { + symfonium := func() *ClientInfo { + return &ClientInfo{ + Name: "Symfonium", + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"mp3", "flac", "ogg"}, Protocols: []string{ProtocolHTTP}}, + }, + TranscodingProfiles: []Profile{ + {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP}, + {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, + }, + } + } + + It("direct plays a flac source forced to flac", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1026, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) + ci := symfonium() + Expect(ci.ForceFormat("flac")).To(BeTrue()) + + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeTrue()) + }) + + It("still transcodes a 24-bit flac when the client caps bit depth", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 4600, Channels: 2, SampleRate: 96000, BitDepth: new(24)}) + ci := symfonium() + ci.CodecProfiles = []CodecProfile{{ + Type: CodecProfileTypeAudio, Name: "flac", + Limitations: []Limitation{{Name: LimitationAudioBitdepth, Comparison: ComparisonLessThanEqual, Values: []string{"16"}, Required: true}}, + }} + Expect(ci.ForceFormat("flac")).To(BeTrue()) + + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeFalse()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TranscodeStream.BitDepth).To(Equal(16)) + }) + + It("still transcodes a 320 mp3 forced to mp3 at a lower bitrate", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100}) + ci := symfonium() + Expect(ci.ForceFormat("mp3")).To(BeTrue()) + ci.CapBitrate(192) + + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeFalse()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TargetBitrate).To(Equal(192)) + }) + + It("direct plays a 128 mp3 forced to mp3 at a higher bitrate", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 128, Channels: 2, SampleRate: 44100}) + ci := symfonium() + Expect(ci.ForceFormat("mp3")).To(BeTrue()) + ci.CapBitrate(192) + + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeTrue()) + }) + + It("transcodes a flac source forced to mp3", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1026, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) + ci := symfonium() + Expect(ci.ForceFormat("mp3")).To(BeTrue()) + + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeFalse()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TargetFormat).To(Equal("mp3")) + }) + }) }) Describe("ensureProbed", func() { diff --git a/core/stream/types.go b/core/stream/types.go index 19474dd91..300017c01 100644 --- a/core/stream/types.go +++ b/core/stream/types.go @@ -59,28 +59,38 @@ func (ci *ClientInfo) CapBitrate(maxKbps int) bool { return changed } -// ForceFormat narrows the client to transcoding to targetFormat and suppresses -// direct play, but only if the client already declares a profile for that -// format. All matching profiles are kept so negotiation can still pick among -// them (e.g. by protocol). Returns false (no-op) when targetFormat is empty or -// unsupported. +// ForceFormat narrows the client to transcoding to targetFormat, but only if the +// client already declares a profile for it. All matching profiles are kept so +// negotiation can still pick among them (e.g. by protocol). Direct play is rebuilt +// from those profiles rather than dropped, since declaring a transcoding profile +// for a format is proof the client can play it. Returns false when unsupported. func (ci *ClientInfo) ForceFormat(targetFormat string) bool { if targetFormat == "" { return false } var matched []Profile + var directPlay []DirectPlayProfile for i := range ci.TranscodingProfiles { + p := &ci.TranscodingProfiles[i] // matchesContainer is alias-aware, so a forced "oga" (legacy Opus // target_format) still matches a resolved "opus" profile. - if _, format := resolveTargetFormat(&ci.TranscodingProfiles[i]); matchesContainer(format, []string{targetFormat}) { - matched = append(matched, ci.TranscodingProfiles[i]) + container, format := resolveTargetFormat(p) + if !matchesContainer(format, []string{targetFormat}) { + continue } + matched = append(matched, *p) + directPlay = append(directPlay, DirectPlayProfile{ + Containers: []string{container}, + AudioCodecs: []string{format}, + Protocols: []string{ProtocolHTTP}, + MaxAudioChannels: p.MaxAudioChannels, + }) } if len(matched) == 0 { return false } ci.TranscodingProfiles = matched - ci.DirectPlayProfiles = nil + ci.DirectPlayProfiles = directPlay return true } diff --git a/core/stream/types_test.go b/core/stream/types_test.go index eff408362..88ad904a7 100644 --- a/core/stream/types_test.go +++ b/core/stream/types_test.go @@ -58,7 +58,7 @@ var _ = Describe("ClientInfo", func() { }) Describe("ForceFormat", func() { - It("restricts to the forced format and clears direct play when supported", func() { + It("restricts direct play to the forced format when supported", func() { ci := &ClientInfo{ DirectPlayProfiles: []DirectPlayProfile{{Containers: []string{"flac"}, AudioCodecs: []string{"flac"}}}, TranscodingProfiles: []Profile{ @@ -71,7 +71,35 @@ var _ = Describe("ClientInfo", func() { Expect(ok).To(BeTrue()) Expect(ci.TranscodingProfiles).To(HaveLen(1)) Expect(ci.TranscodingProfiles[0].AudioCodec).To(Equal("opus")) - Expect(ci.DirectPlayProfiles).To(BeEmpty()) + Expect(ci.DirectPlayProfiles).To(ConsistOf(DirectPlayProfile{ + Containers: []string{"ogg"}, AudioCodecs: []string{"opus"}, Protocols: []string{ProtocolHTTP}, + })) + }) + + It("keeps direct play for a source already in the forced format", func() { + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{{Containers: []string{"flac"}, AudioCodecs: []string{"flac"}}}, + TranscodingProfiles: []Profile{ + {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP}, + {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, + }, + } + ok := ci.ForceFormat("flac") + Expect(ok).To(BeTrue()) + Expect(ci.DirectPlayProfiles).To(ConsistOf(DirectPlayProfile{ + Containers: []string{"flac"}, AudioCodecs: []string{"flac"}, Protocols: []string{ProtocolHTTP}, + })) + }) + + It("carries the channel limit of the forced profile into direct play", func() { + ci := &ClientInfo{ + TranscodingProfiles: []Profile{ + {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP, MaxAudioChannels: 2}, + }, + } + Expect(ci.ForceFormat("flac")).To(BeTrue()) + Expect(ci.DirectPlayProfiles).To(HaveLen(1)) + Expect(ci.DirectPlayProfiles[0].MaxAudioChannels).To(Equal(2)) }) It("matches a container-only forced format (mp3)", func() { diff --git a/server/subsonic/transcode.go b/server/subsonic/transcode.go index 9eb2af160..d64bce605 100644 --- a/server/subsonic/transcode.go +++ b/server/subsonic/transcode.go @@ -280,12 +280,19 @@ func (api *Router) GetTranscodeDecision(w http.ResponseWriter, r *http.Request) return stream.IsAACCodec(p.Container) }) + player, hasPlayer := request.PlayerFrom(ctx) + // Honor the player's forced transcoding format, falling back to normal // negotiation when the client can't play it (issue #5583). + maxBitRate := 0 if trc, ok := request.TranscodingFrom(ctx); ok && trc.TargetFormat != "" { - if !clientInfo.ForceFormat(trc.TargetFormat) { + if clientInfo.ForceFormat(trc.TargetFormat) { + // DirectPlayProfile carries no bitrate, so this ceiling is the only + // thing keeping an over-bitrate source out of direct play. + maxBitRate = trc.DefaultBitRate + } else { clientName := clientInfo.Name - if player, ok := request.PlayerFrom(ctx); ok && player.Client != "" { + if hasPlayer && player.Client != "" { clientName = player.Client } log.Debug(ctx, "Player forced format not supported by client; falling back to negotiation", @@ -293,13 +300,13 @@ func (api *Router) GetTranscodeDecision(w http.ResponseWriter, r *http.Request) } } - // Apply the player's MaxBitRate as a ceiling on the client's declared - // limits (issue #5583). Both fields are capped because the client sends - // them independently here; capping only MaxAudioBitrate would let an - // independent MaxTranscodingAudioBitrate slip through computeBitrate. - if player, ok := request.PlayerFrom(ctx); ok && clientInfo.CapBitrate(player.MaxBitRate) { - log.Debug(ctx, "Applied player MaxBitRate cap to transcode decision", - "playerMaxBitRate", player.MaxBitRate, "client", clientInfo.Name) + // The player's own MaxBitRate outranks the forced-format default (issue #5583). + if hasPlayer && player.MaxBitRate > 0 { + maxBitRate = player.MaxBitRate + } + if clientInfo.CapBitrate(maxBitRate) { + log.Debug(ctx, "Applied bitrate ceiling to transcode decision", + "maxBitRate", maxBitRate, "client", clientInfo.Name) } // Get media file diff --git a/server/subsonic/transcode_test.go b/server/subsonic/transcode_test.go index 8d5cbb974..0f3c24832 100644 --- a/server/subsonic/transcode_test.go +++ b/server/subsonic/transcode_test.go @@ -369,7 +369,7 @@ var _ = Describe("Transcode endpoints", func() { mockTD.token = "token" }) - It("forces a supported format and clears direct play", func() { + It("forces a supported format and narrows direct play to it", func() { body := `{"directPlayProfiles":[{"containers":["flac"],"audioCodecs":["flac"],"protocols":["http"]}], "transcodingProfiles":[{"container":"ogg","audioCodec":"opus","protocol":"http"}, {"container":"mp3","audioCodec":"mp3","protocol":"http"}]}` @@ -380,7 +380,11 @@ var _ = Describe("Transcode endpoints", func() { Expect(err).ToNot(HaveOccurred()) Expect(mockTD.capturedClient.TranscodingProfiles).To(HaveLen(1)) Expect(mockTD.capturedClient.TranscodingProfiles[0].AudioCodec).To(Equal("opus")) - Expect(mockTD.capturedClient.DirectPlayProfiles).To(BeEmpty()) + Expect(mockTD.capturedClient.DirectPlayProfiles).To(ConsistOf(stream.DirectPlayProfile{ + Containers: []string{"ogg"}, + AudioCodecs: []string{"opus"}, + Protocols: []string{"http"}, + })) }) It("falls back to negotiation when the forced format is unsupported", func() { @@ -416,6 +420,43 @@ var _ = Describe("Transcode endpoints", func() { Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(128)) Expect(mockTD.capturedClient.MaxTranscodingAudioBitrate).To(Equal(128)) }) + + withForcedBitRate := func(r *http.Request, format string, defaultBitRate, playerMaxBitRate int) *http.Request { + ctx := request.WithTranscoding(r.Context(), model.Transcoding{TargetFormat: format, DefaultBitRate: defaultBitRate}) + ctx = request.WithPlayer(ctx, model.Player{Client: "NavidromeUI", MaxBitRate: playerMaxBitRate}) + return r.WithContext(ctx) + } + + It("applies the transcoding default bitrate when the player sets no maxBitRate", func() { + body := `{"transcodingProfiles":[{"container":"mp3","audioCodec":"mp3","protocol":"http"}]}` + r := withForcedBitRate(newJSONPostRequest("mediaId=song-1&mediaType=song", body), "mp3", 192, 0) + + _, err := router.GetTranscodeDecision(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(192)) + Expect(mockTD.capturedClient.MaxTranscodingAudioBitrate).To(Equal(192)) + }) + + It("prefers the player maxBitRate over the transcoding default bitrate", func() { + body := `{"transcodingProfiles":[{"container":"mp3","audioCodec":"mp3","protocol":"http"}]}` + r := withForcedBitRate(newJSONPostRequest("mediaId=song-1&mediaType=song", body), "mp3", 192, 320) + + _, err := router.GetTranscodeDecision(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(320)) + }) + + It("ignores the transcoding default bitrate when the forced format is unsupported", func() { + body := `{"transcodingProfiles":[{"container":"mp3","audioCodec":"mp3","protocol":"http"}]}` + r := withForcedBitRate(newJSONPostRequest("mediaId=song-1&mediaType=song", body), "opus", 192, 0) + + _, err := router.GetTranscodeDecision(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(mockTD.capturedClient.MaxAudioBitrate).To(BeZero()) + }) }) }) From 89026012ab3d9aeddcd7bbdf965fa9d6e0806b62 Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 7 Sep 2026 14:19:45 -0400 Subject: [PATCH 69/78] fix(transcoding): make piped FLAC transcodes seekable The FLAC muxer writes STREAMINFO before it knows the stream length, then rewinds at the end to fill total_samples in. Navidrome pipes ffmpeg's stdout (-f flac -), which is not seekable, so ffmpeg logs "unable to rewrite FLAC header" and the field stays 0. A decoder needs total_samples to turn a timestamp into a byte offset, so it reports an unknown duration and refuses to seek. Online playback hides this because the client re-requests with a new offset each time, but an offline copy is permanently unseekable, the symptom reported against Symfonium where seeking a downloaded track jumps back to the start. Transcode now wraps its own output and rewrites total_samples as the first bytes flow past. This lives in core/ffmpeg because the unseekable pipe is that package's doing: buildDynamicArgs is what appends the trailing '-'. core/stream only learns a target format and hands back an io.ReadCloser, so compensating there leaked a transcoder implementation detail one layer up. TranscodeOptions grows a Duration field alongside the existing Offset, which also puts the duration-minus-offset arithmetic in the same function that emits -ss. The wrapper runs on every transcode rather than only FLAC targets: the format on a transcoding row is a declared target that nothing validates against the command's actual -f, so a custom command can emit FLAC under any target_format. The magic-byte check inside the wrapper is the authoritative test and costs a 26-byte peek. The output sample rate is read back out of the header ffmpeg just wrote rather than taken from the transcode options, so a resampled (-ar) output still gets the right count. Anything that is not a FLAC stream with an unset total_samples passes through byte for byte. Measured on a 177s source: before, total_samples=0 and ffprobe reported duration N/A; after, total_samples=7807023 and duration 177.03s, with the audio payload byte-identical. This affects every piped FLAC regardless of the source format; only FLAC stores an authoritative "unknown", which is why mp3, opus and aac survive the same pipe. No SEEKTABLE is synthesised and the MD5 is left zero: both are optional, and decoders binary-search using total_samples alone. --- core/ffmpeg/ffmpeg.go | 17 ++-- core/ffmpeg/ffmpeg_test.go | 35 +++++++ core/ffmpeg/flac_streaminfo.go | 66 +++++++++++++ core/ffmpeg/flac_streaminfo_test.go | 142 ++++++++++++++++++++++++++++ core/stream/media_streamer.go | 1 + 5 files changed, 255 insertions(+), 6 deletions(-) create mode 100644 core/ffmpeg/flac_streaminfo.go create mode 100644 core/ffmpeg/flac_streaminfo_test.go diff --git a/core/ffmpeg/ffmpeg.go b/core/ffmpeg/ffmpeg.go index af2dab647..cc38dd9de 100644 --- a/core/ffmpeg/ffmpeg.go +++ b/core/ffmpeg/ffmpeg.go @@ -27,11 +27,12 @@ type TranscodeOptions struct { Command string // DB command template (used to detect custom vs default) Format string // Target format (mp3, opus, aac, flac) FilePath string - BitRate int // kbps, 0 = codec default - SampleRate int // 0 = no constraint - Channels int // 0 = no constraint - BitDepth int // 0 = no constraint; valid values: 16, 24, 32 - Offset int // seconds + BitRate int // kbps, 0 = codec default + SampleRate int // 0 = no constraint + Channels int // 0 = no constraint + BitDepth int // 0 = no constraint; valid values: 16, 24, 32 + Offset int // seconds + Duration float32 // seconds; 0 = unknown. Only used to repair a piped FLAC header. } // AudioProbeResult contains authoritative audio stream properties from ffprobe. @@ -86,7 +87,11 @@ func (e *ffmpeg) Transcode(ctx context.Context, opts TranscodeOptions) (io.ReadC } else { args = buildTemplateArgs(opts) } - return e.start(ctx, args) + out, err := e.start(ctx, args) + if err != nil { + return nil, err + } + return patchFLACDuration(out, opts.Duration-float32(opts.Offset)), nil } func (e *ffmpeg) ConvertAnimatedImage(ctx context.Context, reader io.Reader, maxSize int, quality int) (io.ReadCloser, error) { diff --git a/core/ffmpeg/ffmpeg_test.go b/core/ffmpeg/ffmpeg_test.go index 0fa3de111..dbc8fa3c8 100644 --- a/core/ffmpeg/ffmpeg_test.go +++ b/core/ffmpeg/ffmpeg_test.go @@ -3,6 +3,7 @@ package ffmpeg import ( "context" "errors" + "io" "os" "os/exec" "path/filepath" @@ -684,6 +685,40 @@ var _ = Describe("ffmpeg", func() { }) Expect(err).To(MatchError(context.Canceled)) }) + + It("fills in total_samples on a piped FLAC transcode", func() { + stream, err := ff.Transcode(GinkgoT().Context(), TranscodeOptions{ + Command: "ffmpeg -i %s -map 0:a:0 -v 0 -c:a flac -f flac -", + Format: "flac", + FilePath: "tests/fixtures/test.flac", + Duration: 1, // the fixture is exactly 1s at 44100Hz + }) + Expect(err).ToNot(HaveOccurred()) + defer stream.Close() + + out, err := io.ReadAll(stream) + Expect(err).ToNot(HaveOccurred()) + Expect(string(out[:4])).To(Equal("fLaC")) + Expect(readTotalSamples(out)).To(Equal(uint64(44100))) + }) + + It("patches the duration net of the requested offset", func() { + // The command has no %t, so ffmpeg still emits the whole fixture. + // What is under test is the header arithmetic, not the audio. + stream, err := ff.Transcode(GinkgoT().Context(), TranscodeOptions{ + Command: "ffmpeg -i %s -map 0:a:0 -v 0 -c:a flac -f flac -", + Format: "flac", + FilePath: "tests/fixtures/test.flac", + Duration: 3, + Offset: 1, + }) + Expect(err).ToNot(HaveOccurred()) + defer stream.Close() + + out, err := io.ReadAll(stream) + Expect(err).ToNot(HaveOccurred()) + Expect(readTotalSamples(out)).To(Equal(uint64(2 * 44100))) + }) }) Context("stderr capture", func() { diff --git a/core/ffmpeg/flac_streaminfo.go b/core/ffmpeg/flac_streaminfo.go new file mode 100644 index 000000000..878c28718 --- /dev/null +++ b/core/ffmpeg/flac_streaminfo.go @@ -0,0 +1,66 @@ +package ffmpeg + +import ( + "bytes" + "encoding/binary" + "errors" + "io" + "math" +) + +const ( + flacPrefixLen = 26 // through the last total_samples byte + flacMaxTotalSamples = 1<<36 - 1 +) + +// patchFLACDuration fills in the STREAMINFO total_samples that ffmpeg leaves at 0 +// when writing to a pipe, since a decoder cannot seek a cached FLAC without it. +func patchFLACDuration(r io.ReadCloser, duration float32) io.ReadCloser { + if duration <= 0 { + return r + } + return &flacPatcher{ReadCloser: r, duration: duration} +} + +type flacPatcher struct { + io.ReadCloser + duration float32 + // Peeking here rather than in the constructor keeps Transcode from blocking + // until ffmpeg has emitted its first bytes. + stream io.Reader +} + +func (f *flacPatcher) Read(p []byte) (int, error) { + if f.stream == nil { + prefix := make([]byte, flacPrefixLen) + n, err := io.ReadFull(f.ReadCloser, prefix) + if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) { + return 0, err + } + prefix = prefix[:n] + if err == nil { + setFLACTotalSamples(prefix, f.duration) + } + f.stream = io.MultiReader(bytes.NewReader(prefix), f.ReadCloser) + } + return f.stream.Read(p) +} + +// setFLACTotalSamples takes the rate from the header rather than the transcode +// options, so a resampled (-ar) output still gets the right count. +func setFLACTotalSamples(prefix []byte, duration float32) { + if string(prefix[:4]) != "fLaC" || prefix[4]&0x7F != 0 { + return + } + // 20-bit rate | 3-bit channels | 5-bit depth | 36-bit total_samples + info := binary.BigEndian.Uint64(prefix[18:]) + rate := info >> 44 + if rate == 0 || info&flacMaxTotalSamples != 0 { + return + } + total := math.Round(float64(duration) * float64(rate)) + if total > flacMaxTotalSamples { + return + } + binary.BigEndian.PutUint64(prefix[18:], info|uint64(total)) +} diff --git a/core/ffmpeg/flac_streaminfo_test.go b/core/ffmpeg/flac_streaminfo_test.go new file mode 100644 index 000000000..6bf3503d7 --- /dev/null +++ b/core/ffmpeg/flac_streaminfo_test.go @@ -0,0 +1,142 @@ +package ffmpeg + +import ( + "bytes" + "errors" + "io" + "os" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Decoded independently so the specs do not mirror the production bit-twiddling. +func readSampleRate(b []byte) int { + return int(b[18])<<12 | int(b[19])<<4 | int(b[20])>>4 +} + +func readTotalSamples(b []byte) uint64 { + return uint64(b[21]&0x0F)<<32 | uint64(b[22])<<24 | uint64(b[23])<<16 | uint64(b[24])<<8 | uint64(b[25]) +} + +var _ = Describe("patchFLACDuration", func() { + var fileFLAC []byte + + // Zeroing total_samples reproduces what a piped transcode emits. + pipedFLAC := func() []byte { + b := bytes.Clone(fileFLAC) + b[21] &= 0xF0 + clear(b[22:26]) + return b + } + + readAll := func(in []byte, duration float32) []byte { + out, err := io.ReadAll(patchFLACDuration(io.NopCloser(bytes.NewReader(in)), duration)) + Expect(err).ToNot(HaveOccurred()) + return out + } + + BeforeEach(func() { + var err error + fileFLAC, err = os.ReadFile("tests/fixtures/test.flac") + Expect(err).ToNot(HaveOccurred()) + Expect(readSampleRate(fileFLAC)).To(Equal(44100)) // specs below hard-code this rate + }) + + It("fills in total_samples from the duration", func() { + out := readAll(pipedFLAC(), 1.0) + Expect(readTotalSamples(out)).To(Equal(uint64(44100))) + }) + + It("takes the sample rate from the header, not from the source file", func() { + in := pipedFLAC() + // Rewrite the header's rate to 48000, as -ar would. + in[18], in[19] = 0x0B, 0xB8 + in[20] &= 0x0F + + out := readAll(in, 2.0) + + Expect(readSampleRate(out)).To(Equal(48000)) + Expect(readTotalSamples(out)).To(Equal(uint64(96000))) + }) + + It("rounds to the nearest sample rather than truncating", func() { + // float32(0.7)*44100 is 30869.9995, so truncation would lose a sample. + out := readAll(pipedFLAC(), 0.7) + Expect(readTotalSamples(out)).To(Equal(uint64(30870))) + }) + + It("passes through when the duration overflows the 36-bit field", func() { + in := pipedFLAC() + Expect(readAll(in, 2e6)).To(Equal(in)) + }) + + It("leaves everything after the header untouched", func() { + in := pipedFLAC() + out := readAll(in, 1.0) + Expect(out).To(HaveLen(len(in))) + Expect(out[26:]).To(Equal(in[26:])) + Expect(out[:18]).To(Equal(in[:18])) + }) + + It("leaves an already-populated total_samples alone", func() { + out := readAll(fileFLAC, 99.0) + Expect(out).To(Equal(fileFLAC)) + }) + + It("passes through a stream that is not FLAC", func() { + in := []byte("ID3\x04\x00\x00\x00\x00\x00\x00 not a flac stream at all, just bytes") + Expect(readAll(in, 1.0)).To(Equal(in)) + }) + + It("passes through when the first metadata block is not STREAMINFO", func() { + in := pipedFLAC() + in[4] = 0x04 // VORBIS_COMMENT + Expect(readAll(in, 1.0)).To(Equal(in)) + }) + + It("passes through a stream shorter than the STREAMINFO fields it patches", func() { + in := pipedFLAC()[:20] + Expect(readAll(in, 1.0)).To(Equal(in)) + }) + + It("passes through an empty stream", func() { + Expect(readAll(nil, 1.0)).To(BeEmpty()) + }) + + It("passes through when the duration is zero or negative", func() { + in := pipedFLAC() + Expect(readAll(in, 0)).To(Equal(in)) + Expect(readAll(in, -5)).To(Equal(in)) + }) + + It("passes through when the header declares no sample rate", func() { + in := pipedFLAC() + in[18], in[19] = 0, 0 + in[20] &= 0x0F + Expect(readAll(in, 1.0)).To(Equal(in)) + }) + + It("propagates a read error from the underlying stream", func() { + _, err := io.ReadAll(patchFLACDuration(io.NopCloser(io.MultiReader( + bytes.NewReader(pipedFLAC()[:10]), &errReader{})), 1.0)) + Expect(err).To(MatchError("boom")) + }) + + It("closes the underlying stream", func() { + c := &closeSpy{Reader: bytes.NewReader(pipedFLAC())} + Expect(patchFLACDuration(c, 1.0).Close()).To(Succeed()) + Expect(c.closed).To(BeTrue()) + }) +}) + +type errReader struct{} + +func (e *errReader) Read([]byte) (int, error) { return 0, errors.New("boom") } + +type closeSpy struct { + io.Reader + closed bool +} + +func (c *closeSpy) Close() error { c.closed = true; return nil } diff --git a/core/stream/media_streamer.go b/core/stream/media_streamer.go index aaa3126b4..6db2f6338 100644 --- a/core/stream/media_streamer.go +++ b/core/stream/media_streamer.go @@ -268,6 +268,7 @@ func NewTranscodingCache() TranscodingCache { BitDepth: job.bitDepth, Channels: job.channels, Offset: job.offset, + Duration: job.mf.Duration, }) if err != nil { release() From bea9715001abc956c712f76bf87cd93fd41bd6f1 Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 8 Sep 2026 18:51:49 -0400 Subject: [PATCH 70/78] refactor(ui): replace icons in LibraryScanButton with react-icons --- ui/src/library/LibraryScanButton.jsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ui/src/library/LibraryScanButton.jsx b/ui/src/library/LibraryScanButton.jsx index 50d90e615..b793844fd 100644 --- a/ui/src/library/LibraryScanButton.jsx +++ b/ui/src/library/LibraryScanButton.jsx @@ -8,8 +8,8 @@ import { useUnselectAll, } from 'react-admin' import { useSelector } from 'react-redux' -import SyncIcon from '@material-ui/icons/Sync' -import CachedIcon from '@material-ui/icons/Cached' +import { GiMagnifyingGlass } from 'react-icons/gi' +import { VscSync } from 'react-icons/vsc' import subsonic from '../subsonic' const LibraryScanButton = ({ fullScan, selectedIds, className }) => { @@ -54,7 +54,7 @@ const LibraryScanButton = ({ fullScan, selectedIds, className }) => { ? translate('resources.library.actions.fullScan') : translate('resources.library.actions.quickScan') - const icon = fullScan ? : + const icon = fullScan ? : return (