Deluan Quintão 036c9cab96
feat(jellyfin): send a synthetic placeholder blurhash for unresolved artwork (#5941)
* refactor(artwork): let callers pin the blurhash component counts

* feat(artwork): synthesize a unique placeholder blurhash from a seed

* fix(artwork): base the synthetic-hash no-collision guarantee on the prefix, not length

The prior comment and test claimed a synthetic value could never collide with a
real one because it's always shorter. That's false: components() targets ~16
tiles by scaling one axis down as the other hits the 9 cap, so an extreme
aspect ratio (e.g. 10x200) collapses to 1x9 = 9 components, which encodes to
the same 22 characters as a 3x3 synthetic hash. The old test only exercised a
square 64x64 gradient, so it never caught this.

The real guarantee is structural, not length-based: the first character encodes
shape as (xComp-1)+(yComp-1)*9, and components() derives xf*yf = 16 exactly
before flooring/capping (xf = sqrt(16w/h), yf = xf*h/w = sqrt(16h/w), so
xf*yf = sqrt(256) = 16). Two factors both in [2,3) can't multiply to 16, so
Encode can never derive 3x3 - the synthetic prefix 'K' is structurally
exclusive to Synthetic. Replaced the length-based test with one that sweeps
extreme aspect ratios and asserts no real encode ever produces prefix 'K',
alongside the assertion that Synthetic always does.

* feat(artwork): tint a synthetic blurhash from a base colour

Parses baseColor into HSL and clamps saturation/lightness so the tint
stays muted; per-cell hashing (unchanged) is what keeps a shared tint
across an album's tracks from colliding, as the new 1M-seed spec
proves under a single fixed colour.

* fix(artwork): trim over-long comment in synthetic blurhash test

Review flagged the collision spec's comment for exceeding the 2-line
budget; the Finamp rationale it restated already lives in the design
doc and commit history.

* feat(jellyfin): send a synthetic blurhash for unresolved artwork

Clients render nothing where a placeholder belongs when ImageBlurHashes
is omitted for pending artwork. primaryImage now synthesizes a value
(seeded on the tag, so a cover swap re-keys it) whenever a tag is
present but no blurhash has been computed yet. Known-absent artwork
(ImageAbsent) is unaffected: it still emits neither tag nor blurhash,
since GetOrPlaceholder would otherwise pin a shared placeholder under a
distinct cache key for a year.

* fix(jellyfin): avoid computing a synthetic blurhash when a real one exists

cmp.Or evaluates both arguments before choosing between them, so
blurhash.Synthetic ran (and was discarded) on every call even when
img.BlurHash was already set. That's the common case in production:
artwork_repository.go populates BlurHash from persisted values once a
scan resolves it, so a healthy library paid the synthesis cost (xxh3
hashing, HSL conversion, image alloc, DCT encode) on every mapped item
for a value it never used. Branch on emptiness first instead.

* feat(jellyfin): tint a pending track's placeholder with its album's colour

primaryImage never reaches the embeddedArtPending branch of
SongToBaseItem, since there is no resolved image yet to feed it. Seed
the synthetic blurhash on mf.ID (so the value stays unique and the
client still issues the read-through request) but tint it with the
album's DominantColor, which is already hydrated on MediaFile at no
extra cost.

* style(artwork): trim synthetic blurhash comments to the budget

* docs(jellyfin): fix stale blurhash README bullet + two review nits

The "Blurhashes are synthetic" bullet under Known limitations described
dto/blurhash.go, which was deleted when the real core/artwork-computed
blurhash + synthetic-fallback pipeline landed; every claim in it was
false. Replaced it with an accurate paragraph in the Images section,
since the described behaviour is now the finished design, not a gap.

Also: fix a doc/body comment mismatch in blurhash.go (component counts
are 2..9, not 1..9), and deduplicate the inline DC-extraction logic in
synthetic_test.go by reusing the existing dcOf helper.

* refactor(artwork): slice the synthetic cell jitter on byte boundaries

The three perturbations came off one hash with mismatched masks and shifts
(0xFF at 0, 0x3F at 8, 0x3F at 14), so a reader had to do the arithmetic to
confirm the fields did not overlap. Only 20 of the 64 bits were in use either
way, so the narrower fields bought nothing.

Uniform byte slices at 0/8/16 are non-overlapping by inspection and give each
field the full 8 bits. Both 1,000,000-seed collision specs still measure
1,000,000 distinct values. Also drops the local `n` alias, which was a second
name for synthComponents inside a 15-line function.

* docs(jellyfin): fix the blurhash paragraph's opening sentence

It opened with "follow the same principle", pointing back at the preceding
paragraph on admin-context artwork resolution — an unrelated subject, so the
reader looks for a connection that is not there.

* fix(artwork): render the synthetic grid larger than its component count

The 3x3 cell grid was handed straight to the encoder as a 3x3 image, so the
source had exactly as many samples as basis functions. Blurhash normalises its
coefficients by 1/(w*h) and 2/(w*h), which assumes many samples per component,
so the AC terms came out far too large. At the bottom-right corner the x and y
bases are both [1, -0.5, -0.5], everything lines up negative, and the result
clamped to black — a dark blob on every synthetic placeholder.

Rendering the same nine colours bilinearly at 8x8 first removes it: measured
over three seeds, the darkest corner goes from 13 to 74 and the darkest pixel
from 1 to 63. 8px is the smallest size that clears the artefact; 12 and 16 are
visually indistinguishable and cost 1.7x and 2.7x more.

The interpolation is hand-rolled rather than x/image's scaler, which allocated
528 times per call against 14 for this. Both 1,000,000-seed collision specs
still measure 1,000,000 distinct values.

* refactor(artwork): tidy the synthetic upscale helpers

cellWeight nudged its upper bound with a 1e-9 epsilon so int() could never
land on the last cell. Clamping the coordinate and then the index says the
same thing without a magic constant, and makes the clamp-don't-extrapolate
intent explicit — edge pixels map outside the cell centres, so the fraction
would otherwise run past 1.

The grid type is spelled once as colorGrid rather than repeated in the local
and the upscale signature, and encodeAt's doc now states the source-size
contract that Synthetic depends on, so the next caller sees it at the
function rather than only in synthetic.go.

Output is unchanged: all seven sample hashes match byte for byte.

* perf(artwork): make the synthetic upscale separable

Both axes are square and constant-sized, so the per-pixel cell index and
weight were the same 8 values recomputed 64 times per call. They are now
built once by sync.OnceValue, matching the srgbToLinearTable pattern.

The interpolation is also separable: stretching each of the 3 grid rows
horizontally once and then blending rows vertically does 264 lerps where
the per-pixel form did 576.

upscale drops from 347ns to 260ns, Synthetic from 1780ns to 1669ns. Output
is byte-identical across all seven sample hashes — same operations in the
same order, only hoisted.

* refactor(artwork): let a caller supply the cosine basis

encodeAt built its cosine tables inline, so Synthetic rebuilt bit-identical
ones on every call — its shape is always 3 components over 8 pixels. Splitting
the table construction into cosBasis and the encoder proper into encodePixels
lets Synthetic build the basis once via sync.OnceValue, and leaves encodeAt's
signature and behaviour untouched.

Synthetic drops from 14 allocations to 6 (1669ns to 1508ns). The time saving
is small and this path only runs while artwork is still unresolved; the
allocation cut is the point, alongside a shorter encodeAt.

Every hash is unchanged: nine real Encode outputs spanning square, 10x200,
200x10, 1x50 and a real JPEG, plus all seven synthetic samples, all byte for
byte identical before and after.
2026-08-11 18:34:27 -04:00
2026-07-20 09:49:48 -04:00

Navidrome logo

Navidrome Music Server  Tweet

Last Release Build Downloads Docker Pulls Dev Chat Subreddit Contributor Covenant Gurubase

Navidrome is an open source web-based music collection server and streamer. It gives you freedom to listen to your music collection from any browser or mobile device. It's like your personal Spotify!

Note: The master branch may be in an unstable or even broken state during development. Please use releases instead of the master branch in order to get a stable set of binaries.

Check out our Live Demo!

Any feedback is welcome! If you need/want a new feature, find a bug or think of any way to improve Navidrome, please file a GitHub issue or join the discussion in our Subreddit. If you want to contribute to the project in any other way (ui/backend dev, translations, themes), please join the chat in our Discord server.

Installation

See instructions on the project's website

Cloud Hosting

PikaPods has partnered with us to offer you an officially supported, cloud-hosted solution. A share of the revenue helps fund the development of Navidrome at no additional cost for you.

PikaPods

Features

  • Handles very large music collections
  • Streams virtually any audio format available
  • Reads and uses all your beautifully curated metadata
  • Great support for compilations (Various Artists albums) and box sets (multi-disc albums)
  • Multi-user, each user has their own play counts, playlists, favourites, etc...
  • Very low resource usage
  • Multi-platform, runs on macOS, Linux and Windows. Docker images are also provided
  • Ready to use binaries for all major platforms, including Raspberry Pi
  • Automatically monitors your library for changes, importing new files and reloading new metadata
  • Supports lyrics from sidecar .ttml, .yaml/.yml Lyricsfile, .elrc, .lrc, .srt, .txt files and embedded TTML, Enhanced LRC, LRC, SRT, and plain-text tags (via lyricspriority)
  • Themeable, modern and responsive Web interface based on Material UI
  • Compatible with all Subsonic/Madsonic/Airsonic clients
  • Transcoding on the fly. Can be set per user/player. Opus encoding is supported
  • Translated to various languages

Translations

Navidrome uses POEditor for translations, and we are always looking for more contributors

Documentation

All documentation can be found in the project's website: https://www.navidrome.org/docs. Here are some useful direct links:

Screenshots

Languages
Go 81.8%
JavaScript 15.2%
Rust 2.3%
Makefile 0.2%
Shell 0.2%
Other 0.2%