5233 Commits

Author SHA1 Message Date
Deluan Quintão
e8228e1475
Merge branch 'master' into artwork-blurhash 2026-08-08 21:58:20 -04:00
Deluan Quintão
5a5311a9c0
fix(plugins): make generated mock stubs nil-safe for nilable returns (#5909)
The ndpgen mock accessors asserted return types unconditionally, so a plugin
test using Return(nil, ...) — the natural way to model an empty result or a
pagination terminal page — panicked on the untyped-nil type assertion instead
of returning the zero value. Guard pointer, slice, map and any returns in both
the host-service client stubs and the PDK stub, and regenerate.
2026-08-08 21:57:44 -04:00
ant
a0bf78cdea
fix(contrib): added missing hyphen in OpenRC script that caused crashes on startup (#5906)
Signed-off-by: Ant <marxguey@proton.me>
2026-08-08 15:40:05 -04:00
Deluan Quintão
dcb5d69681
Merge branch 'master' into artwork-blurhash 2026-08-07 22:41:08 -04:00
Deluan Quintão
0f4c9b8212
fix(ui): don't start playback when closing the disc cover lightbox (#5901)
The Lightbox renders through a React portal (react-modal), and React
propagates synthetic events up the React tree rather than the DOM tree, so
clicking its close button or backdrop bubbled into the ancestor TableRow and
triggered playSubset.

The existing timestamp guard could not catch this: react-image-lightbox defers
onCloseRequest by animationDuration via setTimeout, so lightboxClosedAt was
only stamped 200ms after the click had already propagated. The guard is kept
because it still covers the separate mobile ghost-click path, where the
synthesized click lands on the row after the overlay unmounts.

Stopping propagation in onCloseRequest — the approach used by ContextMenus,
whose MUI onClose fires synchronously — does not work for the same reason.
Instead, wrap the Lightbox in an element that stops propagation at the React
tree boundary the portal bubbles through, which also covers the lightbox
controls we don't own (zoom buttons, caption, image drag).
2026-08-07 09:36:52 -04:00
Deluan Quintão
dce48ff650
feat(smartplaylist): add album-level fields for sorting and filtering (#5899)
Smart playlists could only sort by the track-level `dateadded`, which scatters
an album's tracks because every track has its own timestamp. There was no way
to express "newest albums first, tracks in album order".

Adds five fields backed by the album table: albumdateadded, albumdatemodified,
albumduration, albumsongcount and albumsize, so `"sort": "-albumdateadded,
tracknumber"` now works. They filter as well as sort, which also enables rules
like "everything from albums added this month" or "skip singles and EPs".

These need the album table rather than the album_annotation table the existing
album* fields join, so they get their own bit in the join mask. The bitmask
already unions joins from both the expression and the sort fields, so a
sort-only reference pulls in the join for the main query while correctly
staying out of the percentage-limit count query.

No COALESCE default is used. That mechanism exists because an album_annotation
row is genuinely often absent; the album row always exists and song_count,
duration and size are NOT NULL, so leaving the columns bare keeps filters
index-friendly.

albumdatemodified follows the existing track-level `datemodified` naming rather
than the `albumdateupdated` spelling used in the request.

albumreleasedate was considered and rejected: album.release_date is
allOrNothing() over the tracks, so it equals the track-level releasedate when
they agree and collapses to empty when they disagree.

Closes https://github.com/navidrome/navidrome/discussions/5347
2026-08-06 10:59:50 -04:00
Deluan
d764a1e9d7 fix(log): change debug log level to trace to reduce log noise from cron
Signed-off-by: Deluan <deluan@navidrome.org>
2026-08-06 00:40:58 -04:00
Deluan
f35c84a507 fix(ui): fade cover art consistently, and shorten the fade to 150ms
The <img> mounts with its blob URL already set, so onLoad often fires in the
same frame the element is inserted. The opacity:0 start state never gets
painted, the transition has no value to animate from, and the cover pops in
instead of fading. Only tiles whose decode happened to straddle a paint
boundary actually faded, which made a grid load look ragged: 4 to 6 of 18
covers faded, varying between runs.

Deferring the decoded flag by two animation frames guarantees the hidden state
is painted first. Measured on a live grid afterwards, 17 of 18 covers fade, the
18th having a cached blob and taking the intentional instant path, and the tail
after the last byte arrives drops from ~440ms to ~163ms.

The duration also drops from 500ms to 150ms. That 500ms mirrored master's fade,
but master paints no placeholder behind the image and needs a slow blend to
cover a blank tile. Here a thumbhash already fills the gap, so a long crossfade
only delays the grid settling, measured at 145-190ms slower to full opacity
than master.
2026-08-06 00:22:44 -04:00
Deluan
b3f58184b9 Merge remote-tracking branch 'origin/master' into artwork-blurhash
# Conflicts:
#	ui/src/album/AlbumList.jsx
2026-08-04 22:10:38 -04:00
Deluan Quintão
50633f839d
fix(ui): restore each page's scroll position when navigating back (#5892)
* fix(ui): anchor detail pages to the top when opened from a list

React Router keeps the previous page's scroll offset, so opening an album from
a scrolled list started the detail page mid-song-list. Artist pages had the
same bug; it just shows less because the artist list is rarely long enough to
scroll far.

Keyed on the record id rather than mount, so detail-to-detail navigation (an
album's artist link) resets too, and so the scroll waits for the record instead
of firing against an empty page.

* feat(ui): remember each route's scroll offset

A hook that saves window.scrollY per history entry and, on navigation, tops a
pushed route or returns a popped one to where it was. Keyed on location.key so
back and forward each restore their own offset, and bounded because history
entries are not.

Callers pass their own readiness: restoring before the rows render leaves the
document too short, so the browser clamps to the top and the offset is lost with
no error to show for it.

* refactor(ui): fold scroll-to-top into route scroll restoration

useScrollToTop only ever forced the top, so it fixed opening a detail page from a
scrolled list and did nothing for the return trip: coming back to the list landed
wherever it happened to be. The new hook covers both, and its readiness argument
carries over the reason the old one keyed on the record id - waiting for the
record rather than firing against an empty page.

* feat(ui): restore the album list's position when returning to it

The list is the page worth remembering: scrolling a long way to find an album and
landing back at the top is the annoyance the detail-page scroll fix could not
reach.

Mounted as a pass-through around the List's child rather than beside it, because
React Admin calls Children.only on List children and clones that one child with
the list props - which is also how this reads loaded/total to wait for rows.

* fix(ui): key scroll restoration on the route, not location.key

Hash history never assigns location.key - history.js calls createLocation with an
undefined key and warns that it cannot carry state - so every route collapsed
into one slot. The detail page's own scroll listener then overwrote the list's
saved offset with 0, and going back restored that 0. Only the scroll-to-top half
worked, which is what made it look fine.

Keyed on pathname+search instead. The spec that would have caught this asserts
two routes keep separate offsets; the previous ones mocked the router and so
only ever confirmed the assumption.

* refactor(ui): tidy up scroll restoration after review

Four cleanups, none changing behaviour:

ScrollRestorer read loaded/total off whatever props React Admin happened to clone
in. useListContext is the contract for those values and is already used three
times elsewhere, including further down this same file; the clone payload is an
implementation detail that would fail silently if it ever changed.

The restore runs in a layout effect now. A passive effect is flushed after paint,
so an image-heavy grid was painted at the old offset and then jumped.

MAX_ENTRIES matches the naming every other module-level cap in common/ uses.

The per-instance guard against re-restoring was deletable with a green suite, so
it now has a spec: readiness flickering on one route must not scroll twice.

* fix(ui): resolve media queries on the first render

MUI defaults useMediaQuery and withWidth to SSR-safe two-pass rendering: the first
render reports the wrong breakpoint and a layout effect corrects it. Navidrome is
client-only, so every mount paints a layout it immediately reflows. Album tiles
render without their artist line and grow 14px when it arrives, and react-admin's
own buttons paint labelled before collapsing to icons, which together grew the
album list 79px right after a scroll restore and dragged the position with it.

Setting noSsr on the theme reaches react-admin's internals too. withWidth needs its
own option because it gates on a mount flag rather than on the media query.

* fix(ui): keep restoring the scroll offset until it sticks

A single scrollTo assumes the page is already at its final height. It often is not:
the artist page reports ready as soon as its cached header record renders, while the
albums below are still loading, so the browser clamps the offset to the top of a
viewport-tall document and it is lost with no error.

The restore now retries until the offset lands, yielding as soon as the position moves
somewhere we did not put it. Keying that on input events instead breaks the trackpad
back-swipe, whose wheel momentum keeps firing through the restore window.

Offsets are also committed when leaving the page rather than on every scroll. Tearing
the page down collapses the document and snaps to the top, and a live listener recorded
that clamp over the offset being left behind.

* refactor(ui): tidy up scroll restoration after review

Folds the offset commit into the scroll tracker's own cleanup now that the tracker
ignores the collapse snap, so one effect does what two did. Skips scheduling the retry
loop when the first scrollTo already landed, and only pays for the scrollHeight read on
scroll events that reach the top, since that is the only case the guard can fire.

The theme hook's own prefers-color-scheme query runs above the ThemeProvider carrying
the new prop, so it needs noSsr passed directly or the auto theme still renders dark
first and flips.

Also lifts the repeated fake-timer fixture in the specs into one helper and restores
real timers from afterEach, so a failing assertion no longer leaves them installed for
every later spec.

* fix(ui): bank the scroll offset before the next page tops itself

Committing the offset from the scroll listener's passive cleanup ran too late. The
incoming page scrolls to the top in its own layout effect, and the outgoing page's
listener is still attached at that moment, so it recorded that 0 over the offset being
left behind. Whether it did depended on when the browser dispatched the scroll event
relative to React's passive flush, which made returning to a detail page lose its
position intermittently.

Moving the commit back into a layout cleanup makes it deterministic: React runs those
in the mutation phase, before the incoming page's layout effects. The scrollHeight
guard in the listener went with it, since it only covered the case where the incoming
page was shorter than the viewport and the ordering covers all of them.
2026-08-04 22:07:16 -04:00
Deluan
c1e4f1c9ae revert(ui): move detail-page scroll handling to its own branch
Reverts 40153bd43. Scroll position carrying over between pages is stock React
Router behaviour rather than something this branch introduced, so the fix does
not belong in an artwork PR that is otherwise ready for review.

The work continues on fix/scroll-restoration, off master, where it grew into
per-route restoration: a new page starts at the top and going back returns to
where you were, which the scroll-to-top could not do.
2026-08-04 09:39:00 -04:00
Deluan Quintão
0c06d3fcef
Merge branch 'master' into artwork-blurhash 2026-08-03 21:27:33 -04:00
Deluan
2ab7925c7a fix(artwork): ignore image candidates that cannot be fetched
bestImageURL only rejected URLs that url.Parse itself refuses, which is almost
nothing: relative paths and any scheme parse cleanly. A candidate like
images/big.jpg or ftp://host/big.jpg therefore won on size, and since the
function returns a single URL whose failure ends that agent's turn, the agent's
valid smaller images were never tried.

Two details made it easy to hit rather than theoretical. Size is frequently 0 for
every candidate, and only a strictly larger one replaces the first, so an
unfetchable entry in first position sticks. And these strings come straight from
plugins, where a relative path is an ordinary mistake.

Accept only absolute http/https URLs with a host.
2026-08-03 14:51:50 -04:00
Deluan
e1b89050df chore: exclude .worktrees from golangci-lint checks 2026-08-03 14:34:53 -04:00
Kendall Garner
54fe6c254e
feat(plugins): implement plugin specific storage (#5839)
* implement storage api/hooks

* fine. if the error messages are different, just match error

* rename storageMount

* add independent plugin test

* round 2

* .-.

* one more copypasta fail

* docs(plugins): document the Storage host service

The README is the plugin author reference and every other host service has a
section there, but Storage had none, so the /storage guest path contract only
existed in code.

Documents the mount point and its backing directory, the manifest permission,
the host function, and the two behaviours an author would otherwise discover
the hard way: there is no size limit, and the directory outlives an uninstall.

* docs(plugins): correct the library filesystem security notes

The README stated in three places that library filesystem access is read-only,
which stopped being true when AllowWriteAccess was added: an administrator can
grant a plugin write access to libraries.

Corrects those and gives the library and storage mounts the same wording for
what the sandbox guarantees, since both now go through the same jail.

* docs(plugins): describe symlink handling in mounts accurately

The security notes claimed paths resolving outside a mount are rejected, which
overstates the jail. Only lexical escapes are: '..' and absolute paths. Creating
symlinks is denied, but symlinks already present are followed and do reach
outside the mount, which is what lets music libraries link folders in from
elsewhere. Both behaviours are pinned by tests in the plugins package.

---------

Co-authored-by: Deluan Quintão <deluan@navidrome.org>
2026-08-03 14:23:42 -04:00
Deluan
0ba4f0bf8f feat(artwork): capture each image's dominant colour
A flat colour is the one placeholder a client can paint with no decoding at all,
so it can cover the frames before a thumbhash canvas even renders. Extract it
from the same 100px thumbnail the two hash encoders already share, and expose it
as dominantColor on the native API.

Dominant means presence, not salience: the largest cluster wins, so a white
sleeve reports white. That is what a placeholder needs. An accent colour is the
opposite question and is deliberately not answered here - it depends on the
client's theme, and by the time a client needs one it has the cover pixels and
can compute whatever suits it.

Bins at 4 bits per channel, then merges perceptually-near bins in Oklab before
picking the winner: a gradient splits across adjacent bins and would otherwise
lose to a smaller flat region. Measured at 246us per image on the weakest target
hardware we care about (Celeron N5105), against ~15.8 images/s for the pipeline
around it.

The column goes into the feature's original migration rather than a new one:
computing it later would mean re-decoding every image in the library.
2026-08-03 13:14:24 -04:00
Deluan Quintão
77726af59c
fix(plugins): reject plugin IDs that are unusable as directory names (#5886)
The plugin ID is derived from the package filename and used verbatim as a
directory name under DataFolder/plugins by the kvstore and taskqueue host
services. A package installed as '..ndp' yields the ID '.', whose data
directory resolves to the parent of every other plugin's directory, so it
overlaps their private data. On Windows, trailing dots and spaces are dropped
during path normalization, so 'foo..ndp' and 'foo.ndp' yield distinct IDs that
resolve to the same directory and would share the same SQLite files.

Discovery and the file watcher now derive the ID through pluginIDFromPath,
which rejects '.', '..', empty names, separators, trailing dots or spaces, and
anything filepath.IsLocal refuses (Windows reserved names, drive-relative
paths). The loader repeats the check, since a sync failure is non-fatal and
could otherwise leave a stale row reaching the host services.
2026-08-03 13:09:53 -04:00
Deluan Quintão
279ff98e0d
fix(ui): stop precaching index.html so logins can't show the wrong user (#5882)
* fix(ui): stop precaching index.html so logins can't show the wrong user

index.html is rendered per-user by the server: it carries __APP_CONFIG__,
including the auth payload (user id, name, role, Subsonic token) when
authentication comes from a reverse-proxy header. Workbox precached it, and
because precacheAndRoute registers its route before the NetworkOnly
NavigationRoute, every navigation to /app/ was answered from Cache Storage
with a frozen copy of whoever installed the service worker.

With ExtAuth, signing out and back in as a different user therefore kept
showing the previous user, along with their Subsonic token. Cache Storage
ignores the no-store header serve_index.go already sets, and ignores the
browser's "disable cache", so only clearing site data recovered.

Excluding index.html from the precache manifest lets the NetworkOnly
navigation strategy do the job it was written for. Existing poisoned caches
heal themselves: the entry is dropped when the new manifest activates.

The same stale document caused the create-admin dialog to reappear (#3613),
patched then by calling removeHomeCache() after login. That helper only ran
on password login and token refresh, so it never covered the ExtAuth path,
and a refetch re-poisoned the cache anyway. Fixing the cause makes it dead
code, so it is removed.

Also serve the offline page on 5xx: it arrives as a normal response, so the
existing catch never saw it, and without a precached shell a restarting
server would surface a raw gateway error. The offline copy is reworded to
fit both causes.

* test(ui): cover the service worker navigation fallback rules

The handler lived inside sw.js, which only loads in a service worker where
workbox arrives via importScripts, so none of it was reachable from vitest.
Moving the decision into its own module pins the rules that matter: a 5xx
falls back to the offline page like a thrown network error does, while 4xx
and 304 still pass through to the app.
2026-08-02 19:46:05 -04:00
Deluan
a4e78d949f fix(ui): keep the placeholder for a cover whose fetch failed
useImageUrl caches a failed fetch as an entry with no blob, so treating every
cached entry as instantly painted suppressed the placeholder on remount: the
cover became an empty box instead of falling back to its thumbhash. Only a
cached blob counts as instant.

Caught by Codex on the previous commit, which introduced fromCache with the
looser predicate.
2026-08-02 14:21:35 -04:00
Deluan
a4ab19d605 Merge remote-tracking branch 'origin/master' into artwork-blurhash
# Conflicts:
#	core/artwork/e2e/helpers_test.go
#	core/external/provider.go
2026-08-02 13:07:10 -04:00
Deluan Quintão
f853ca604a
refactor(db): migrate all ids to a uniform canonical 128-bit base62 encoding (#5824)
* refactor(model): extract canonical 128-bit base62 id codec

* feat(model): generate random ids as canonical 128-bit base62 values

* feat(scanner): emit legacy PIDs in canonical base62 encoding

* feat(db): add id canonicalization transform for the uniform-ids migration

* feat(db): migrate all ids to canonical 128-bit base62 encoding

* fix(db): canonicalize ids in junction tables and JSON columns

* chore(jellyfin): update id-family notes for uniform canonical ids

* test(ids): harden codec input contract and migration edge coverage

* refactor(model): use log.Fatal for Encode128 contract guard per project convention

* fix(db): force full rescan after id migration for legacy PID configs

* test(db): guard id-column inventory against schema drift

* refactor(ids): compile-time Encode128 contract and unified column rewrite helper

* refactor(db): apply review feedback to id migration

Filter empty strings in collectColumn's SQL, reuse a prepared statement
for rewriteColumn updates, and clarify the legacy ID functions' comment
now that they emit the canonical encoding.

* feat(auth): split session and public-link JWT secrets, rotating sessions on id migration

* test(subsonic): initialize public token secret in helpers suite

The suite sets auth.TokenAuth directly instead of calling auth.Init, so the
new PublicTokenAuth was nil whenever Ginkgo's spec order ran a helpers spec
before any spec that calls auth.Init, panicking in publicurl.ImageURL.

* refactor(db): inline canonicalID into its only consumer, the uniform-ids migration

* refactor(model): rename Encode128/Decode128 to Encode/Decode

With every id now exactly 128 bits, the width suffix is redundant; the
package-qualified id.Encode/id.Decode carries the same information.

* test(db): make the id-columns guard classify JSON columns too

The guard only inspected columns named id/pid/*_id, so it could not see ids
embedded in JSON. Widen it to *_ids and to every JSON column, and drive the
"covered" set from a new embeddedIDColumns list instead of the inline calls
in the migration.

Every JSON column the schema has now carries a verdict. The four denormalized
caches -- media_file/album.participants, media_file/album.tags,
album.folder_ids and artist.similar_artists -- hold only artist, tag and
folder ids. Those all come from id.NewHash, whose 22-char base62 encoding of
a 128-bit MD5 is already in canonical range, so canonicalID is the identity
on them and the migration correctly leaves them alone. A new codec test pins
that invariant, since the exemptions depend on it.

Verified on a copy of a 727MB/96k-track production database: canonicalizing
those four columns changed zero rows, and artist, tag and folder ids were
themselves unchanged by the migration (only media_file ids moved, 95108 of
96666).
2026-08-02 12:58:53 -04:00
Kendall Garner
b40b41584a
feat(subsonic): Implement OpenSubsonic topSongsByArtistId extension (#5853)
* implement topSongsByArtistId extension

* do not look up by artist name if empty

---------

Co-authored-by: Deluan Quintão <deluan@navidrome.org>
2026-08-02 12:39:51 -04:00
Deluan Quintão
810b14ed57
fix(plugins): confine plugin filesystem mounts to their root (#5881)
* fix(plugins): confine plugin filesystem mounts to their root

A plugin granted read-write filesystem access could escape its mount by
creating a relative symlink inside it and then writing through that link,
reaching any path the server process can write, including navidrome.db.

wazero resolves guest paths by concatenating them onto the host root. Its
WASI layer validates every path argument except the symlink target, which
path_symlink forwards unvalidated by design, and fs.ValidPath splits on
"/" only, so on Windows a "..\" path escapes the mount as well.

Mounts now go through a jailedFS wrapper that denies symlink creation and
rejects any path that is not filepath.IsLocal. That requires bypassing
extism's AllowedPaths, which discards any FSConfig passed alongside it, so
the mounts are built directly and applied per instance instead. Following
symlinks that already exist in a mount is unchanged: music libraries rely
on it, and read-only mounts already reject creating new ones.

* test(plugins): guard against setting extism AllowedPaths

Extracts the extism manifest construction so a test can assert AllowedPaths
is never set. Setting it makes extism build its own FSConfig and discard the
jailed mounts, silently restoring the symlink escape.

Verified by simulating the regression: with AllowedPaths populated for plugins
holding the filesystem permission, the new spec fails, as do two of the
end-to-end sandbox specs.
2026-08-02 12:27:08 -04:00
Deluan
233818659f fix(ui): show the placeholder when a refresh swaps in an uncached cover
A cover whose hash changed under a mounted grid went blank for the whole fetch,
instead of falling back to its thumbhash. Artwork decided 'this blob was already
cached, skip the placeholder' once at mount and never revisited it, so every
later url for that record inherited the answer.

The fix has to live in useImageUrl. imgUrl is state cleared in an effect, so on
the render where url changes it still holds the previous url's blob - any caller
inspecting it reads the new url as already cached. Only the hook can see
cache.get(url) for the url actually being rendered, so it now reports fromCache
and resyncs its state during render rather than one render later.

Two shapes were tried and rejected against a live server before this one: a ref
recomputed per url, which a render-phase resync discards without rolling back the
ref, and the same tracking in Artwork state, which still samples imgUrl before
React re-runs the component. Verified on a running instance with cover-art
responses held open: the thumbhash now covers the whole window and stays under
the image until the fade ends.
2026-08-02 10:39:51 -04:00
Deluan
362941fb5c refactor(artwork): drop the orphaned store remover, aggregate sweep failures
Review follow-ups to the prune rework.

ImageStore.Remove lost its only production caller when the sweep took over
orphan-file reclamation. It carried its own copy of the mtime guard that now
lives solely in Sweep, so the two could drift, and it stood as an invitation to
grow a second reclaim path. Its specs either duplicated Sweep's own coverage or
tested nothing else, so they go with it; the one unrelated test that used it to
delete a file calls os.Remove directly.

Sweep warned once per file it could not remove, which is fine for a stray
permission bit and useless for a store mounted read-only - a large library would
emit one line per aged file on every prune, and prune would still report success.
Count the failures instead and warn once, with the count and the last error, so a
systemic failure is legible without drowning the log.

Also: the orphan log line counts rows, not files, and now says so; and the
blocked-removal spec asserts its two fixtures really do land in different shards,
since the assertion is vacuous if they collide.
2026-08-01 21:28:01 -04:00
Deluan
d704587eb5 refactor(artwork): split the repository's deletes from its purges by name
Six removal methods across the two artwork repositories used Delete and Purge
interchangeably, so neither prefix told you anything. There is a real distinction
underneath: Delete methods are caller-directed and take the rows to remove, while
Purge methods are garbage collection - the repository finds rows whose referent
is gone by joining against the owning table, and returns a count because the
caller cannot know one. The signatures already followed that split; only the
names did not.

DeleteOrphans was the odd one out, taking a grace cutoff rather than a target and
returning a count, so it becomes PurgeOrphans. PurgeDanglingItemArtwork loses the
Artwork it repeats from its own interface and keeps the Items that says which of
that interface's two tables it means.

DeleteForItem was DeleteForItems with one id - squirrel emits = for a scalar and
IN for a slice against the same predicate - so its one caller passes a slice
instead. Dangling and Orphan stay distinct: dangling is a row whose entity is
gone, orphan is an image no state row references.
2026-08-01 21:10:01 -04:00
Deluan
cdb9f44735 refactor(model): name the artwork mime lookup for what it is keyed by
GetAllMimes reads as a collection of mime types; it returns a hash -> mime index
over every stored artwork. GetMimeByHash names the key, matching the convention
the other map-returning repository methods already follow (CountBySuffix,
CountByClient).
2026-08-01 21:04:26 -04:00
Deluan
3be5fbd222 refactor(artwork): let the sweep reclaim orphan files instead of prune
Deleting unreferenced artwork rows took five round trips: snapshot the orphan
hashes, fetch their mimes, delete, re-fetch to see which rows actually went, then
remove each file. The last four exist only to work out which files to delete -
but the sweep that runs moments later already derives exactly that from the
database, since GetAllMimes is read after the delete and Sweep removes any file
whose hash has no row, under the same mtime guard. The sweep is also the more
thorough of the two: it reclaims stale mime variants of an orphan hash, which the
per-hash removal never looked at.

Delete the rows in one predicate-only statement and let the sweep follow. The
orphan predicate now runs once per prune instead of once for the snapshot plus
once per 200-hash delete chunk, and with no hash list to bind there is nothing
left to chunk.

GetOrphanHashes and GetImages had no other caller and are gone. Dropping them
also removes the mock's OrphanHashes lever, which let a spec declare a hash
orphaned while item_artwork still referenced it - a state the SQL repository
could never produce, since both predicates were always identical.
2026-08-01 20:59:33 -04:00
Deluan
1344e6e660 fix(artwork): keep sweeping past a store file that cannot be removed
Sweep returned the os.Remove error straight out of WalkDir, so a single
unremovable file abandoned the rest of the walk. Every later orphan, stale mime
variant and abandoned temp file then survived until the next prune, which would
abort at the same place.

Warn and carry on instead. The orphan-file loop in prune already had this
resilience; Sweep is where it belongs, since it is the only step that reaches
stray files and superseded variants.
2026-08-01 20:54:13 -04:00
Deluan
a48bdefdc7 refactor(persistence): simplify the artwork dangling-row purge helper
purgeDangling took a bound executeSQL method value and a table name only because
its two callers live on unrelated types. Neither parameter was load-bearing:
executeSQL is a value receiver on sqlRepository that never reads tableName, and
both callers already hold an sqlRepository whose tableName is the table being
purged. Taking that struct collapses both parameters into the receiver.

itemArtworkSQL wrapped sqlRepository without adding a single method, so the items
field becomes a plain sqlRepository.

danglingItemArtworkKinds is also read by EnqueueAllMissing, which enqueues
entities that have no artwork row yet - neither a purge nor anything specific to
item_artwork. Renamed to artworkOwnerTables to describe both of its uses.
2026-08-01 20:43:08 -04:00
Deluan Quintão
9c20c83617
Merge branch 'master' into artwork-blurhash 2026-07-31 19:20:51 -04:00
Deluan
8f82a8deaa refactor(model): keep the blurhash off native JSON and fold its specs
Nothing on the native API consumes blurHash: the web UI reads thumbHash, and
Jellyfin's mappers take the Go field directly rather than through this
serialization. It was shipping ~40 unused bytes on every row of every list
response, on a surface that accretes clients once published.

The JSON specs were five marshal-and-check-keys blocks over the same struct;
they collapse to one populated case, one bare case, and a guard that the
blurhash stays off the wire.
2026-07-31 18:42:27 -04:00
Deluan
df301ae1a8 test(thumbhash): assert against reference goldens, drop the Go port
The reference port existed to be a differential oracle, but its packing half was
a verbatim copy of production pack(), so it was not as independent as it looked.
Only the 500-image randomised spec needed it; the six PNG fixtures cannot reach
random sizes, aspects, or both coefficient layouts.

gen_generated.mjs now emits 300 vectors from evanw's actual JS. Their pixels are
a pure function of their index, so Go rebuilds them byte-for-byte and only the
hashes are committed (16KB). The oracle is now the reference itself rather than
a hand transcription of it.

Also from the cleanup pass:
- maxCX/maxCY scanned every term to recover a bound that is just the widest
  coefficient region
- tests.GradientImage duplicated generateGradientImage three files away
- BenchmarkHashEncodersAtInputSize was also BenchmarkHashEncoders/*/100x100
- processor had two internal test files with no rule for which gets a new spec
- three blurhash specs differing only by alpha became a DescribeTable
- the shared mock's GetInfoForItems projection had drifted from the real query
2026-07-31 18:29:15 -04:00
Deluan
b13e8d7057 test(thumbhash): drop the shipped-vs-reference benchmark
The two-pass rewrite's advantage over the naive port is already recorded in its
commit message; carrying the benchmark to re-derive it has no ongoing use.

reference_test.go stays: besides the benchmark baseline it holds the
differential oracle the golden-vector and randomised specs assert against.
2026-07-31 18:10:56 -04:00
Deluan
9084685fbc test(artwork): benchmark both hash encoders from one file
benchImage and BenchmarkEncodeAtInputSize existed in both encoder packages, and
blurhash's copy had drifted into a third inline duplicate once both benches
moved to NRGBA input.

The head-to-head now lives in core/artwork, which already imports both encoders
and can pin the input to the real thumbnailSize constant. The gradient builder
moves to tests, which both packages already import via their suite bootstrap.

thumbhash keeps a shipped-vs-reference benchmark, since referenceEncode is
test-only and cannot be reached from core/artwork.
2026-07-31 17:58:55 -04:00
Deluan
ecf12b269f test(artwork): drop the decodeArtwork benchmark
It existed to capture the before/after baseline for the 128->100 thumbnail
change, which it has served. As an ongoing guard it is misleading: a 1000x1000
JPEG decode is ~97% of the 15ms it measures, so the two encoders it would be
reached for are ~2.6% of the signal. It reported a phantom 2.8% regression for
the NRGBA thumbnail change that isolating the scaler disproved.

The per-encoder benchmarks in blurhash/ and thumbhash/ cover the part that
actually changes.
2026-07-31 17:49:04 -04:00
Deluan
a34be43042 perf(artwork): hand both hash encoders one NRGBA thumbnail
makeThumbnail emitted a premultiplied *image.RGBA, so thumbhash allocated and
un-premultiplied a full copy on every image while blurhash paid nothing. It now
emits *image.NRGBA, which thumbhash wants as-is, and blurhash reads that type
directly, premultiplying per pixel to keep its output identical.

thumbhash.Encode at the pipeline's 100x100 input:
  134200 -> 95300 ns/op, 56188 -> 15163 B/op, 37 -> 35 allocs/op
decodeArtwork on a 1000x1000 JPEG:
  5014796 -> 4973800 B/op, exactly the conversion that is gone

The scaler costs the same into either destination (7.33ms vs 7.34ms measured),
so no time is traded for this.
2026-07-31 17:34:14 -04:00
Deluan
9bba4abc07 feat(ui): use thumbhash for the cover loading placeholder
Adds a local decoder rather than the thumbhash npm package, matching the local
blurhash decoder it replaces. Pixels are pinned against evanw/thumbhash's
reference decoder via the vendored copy already in the encoder's testdata.

Unlike a blurhash, a thumbhash carries its own approximate aspect, so a record
with no dimensions now falls back to that instead of to a square.

The blurHash API field stays: Jellyfin clients and third-party native clients
still consume it.
2026-07-31 16:34:01 -04:00
Deluan
4b966ccaea feat(artwork): encode blurhash and thumbhash from one 100px thumbnail
thumbnailSize drops 128 -> 100 so a single CatmullRom scale feeds both encoders;
thumbhash hard-rejects anything larger and a second downscale would cost more
than shrinking the shared one.

decodeArtwork on a 1000x1000 JPEG, before -> after:
  15.53ms -> 15.45ms/op, 5878122 -> 5014796 B/op, 37 -> 75 allocs/op

Time is a wash because the JPEG decode dominates; the 863KB drop is the smaller
thumbnail more than paying for thumbhash's added work.

This rewrites every blurhash value, so it must land before #5847 reaches a
release: Finamp keys its cover cache and download dedup on that value.
2026-07-31 16:29:07 -04:00
Deluan
82b9538bff feat(artwork): hydrate and expose thumbHash on every entity
ItemArtworkInfo.Image() is the single projection every hydration branch goes
through, so adding the field there covers albums, artists, playlists and both
the own-art and inherited media-file branches.
2026-07-31 16:27:15 -04:00
Deluan
313e93c3d9 feat(artwork): persist a thumbhash alongside the blurhash
The artwork table is content-addressed, so this costs one row per unique image
rather than per entity. Measured on a 25k-image library: +0.6MB on a 727MB DB.

The column is added to the existing add_artwork_tables migration rather than a
new one, since #5847 has not been released and the table it extends ships in
that same PR.
2026-07-31 16:24:47 -04:00
Deluan
a4d979c49c test(thumbhash): benchmark against blurhash at the pipeline input size 2026-07-31 16:23:35 -04:00
Deluan
4b3a861303 test(thumbhash): fuzz the opaque path and drop an ill-conditioned golden
The 500-image randomized differential filled every byte randomly, so hasAlpha
(avgA < w*h) was true for all 500 images: the 7x7 no-alpha layout, terms(7,7),
nx=7 and the `if hasAlpha` false branch were never fuzzed. Force full opacity on
alternating iterations, which splits the run 250/250 with the seed and iteration
count unchanged. All 500 still match the reference port.

tiny.png is 1x1, so it has no non-zero AC content: 12 of its 37 AC coefficients
sit exactly on the round(15*f) = .5 tie and 24 are within 1e-12. It passed only
because a single pixel admits no summation reassociation. Give it the same
header-only carve-out solid.png already had, in both test files. The four
well-conditioned fixtures keep strict full byte equality.

Also document the divergence class on Encode itself rather than only in test
comments, add a sub-image regression spec, and use the max builtin over
math.Max. The toNRGBA Rect.Min gate turns out to protect nothing — SubImage
re-slices Pix so Pix[0] is the Rect.Min pixel and the loops read it correctly
either way — so its comment, which claimed the opposite, is corrected. The gate
is kept for now; removing it is a separate call.
2026-07-31 16:23:35 -04:00
Deluan
3dea79ec29 feat(thumbhash): add a two-pass separable ThumbHash encoder
Encode replaces the reference's four w*h scratch arrays (~320 KB at 100x100) and its 40
re-reads of every pixel with a single pixel pass that folds each row into per-frequency
sums, then a second fold over rows. The transform drops from O(terms * pixels) to
O(nx * pixels + terms * h), nx <= 7.

Verified against the vendored JS goldens and, differentially, against the literal Go port
on 500 randomized images. On the fixtures the two implementations' AC coefficients agree
to 8e-15; the separable inner loop reassociates the additions, so exact agreement holds
only where a coefficient is not sitting on a quantization tie, which is why the fixtures
are now dithered.
2026-07-31 16:23:35 -04:00
Deluan
6604f8186a test(thumbhash): dither the fixtures so no DCT coefficient is degenerate
The gradient fixtures are smooth analytic ramps, and alpha.png's alpha channel varied
along x only. Both make whole families of DCT coefficients mathematically zero: 13 of
alpha.png's 38 AC nibbles, and at least one in every other ramp fixture.

A zero coefficient normalizes to exactly the 0.5 midpoint, i.e. 15*f = 7.5, so which of
nibble 7 or 8 it quantizes to is decided by ~1e-16 of float rounding noise. Those nibbles
are therefore unstable across any two summation orders -- the vendored JS and the Go port
already disagreed on solid.png for this reason -- and they carry no signal, so a bug that
transposed two of them would be invisible.

A deterministic +/-4 dither, plus an alpha ramp that varies in x and y, leaves every
coefficient at least 4.8e-5 from the tie boundary: ~1e9 times the observed inter-
implementation noise. solid.png and tiny.png regenerate byte-identical and keep their
existing carve-outs.
2026-07-31 16:23:35 -04:00
Deluan
02d253b2b0 fix(thumbhash): re-condition alpha fixture and assert solid.png header
alpha.png varied only alpha over a constant color, so compositing atop the
average canceled L/P/Q exactly like solid.png, leaving it just as float-noise
unstable and silently dropping alpha-path coverage. Vary R/G/B with position
too so alpha.png carries real signal and reproduces byte-exactly, then
replace the blanket solid.png skip with a precise assertion on its
well-conditioned header bytes and quantized-zero scales.

Also apply a range-over-int modernizer hint in reference_test.go.
2026-07-31 16:23:35 -04:00
Deluan
0c78c65b3c test(thumbhash): add a faithful reference port as the differential oracle 2026-07-31 16:23:35 -04:00
Deluan
b71ee86fb4 test(thumbhash): vendor the reference and generate golden vectors 2026-07-31 16:23:35 -04:00
Mateusz Piotrowski
d23b68a438
fix: typo in description of --prometheus.enabled (#5878)
Remove a stray back tick.
2026-07-31 08:54:29 -04:00
fxj368
600ea5482c fix(ui): update Chinese (Simplified) translation (#5873)
Signed-off-by: Deluan <deluan@navidrome.org>
2026-07-30 12:38:35 -04:00