Compare commits

...

47 commits

Author SHA1 Message Date
Deluan
14cbff5edc fix(artwork): prevent 32-bit overflow in the decode pixel guard
cfg.Width*cfg.Height in int overflows on 32-bit builds (armv5/v6/v7, 386) for
dimensions like 50000x50000, going negative and bypassing maxDecodePixels — the
exact bomb the guard exists to reject. Multiply in int64.
2026-07-18 00:12:22 -04:00
Deluan
ebaf804dbf fix(persistence): mirror the reader's remaining cheap album-root gates
The parent fold now also requires a single common parent across the album's
folders (a compilation spread over artist folders has no album root), excludes a
parent that is itself one of the album's folders, and excludes the library root,
matching albumRootParent. The subtree-audio gate stays unmirrored on purpose: it
would need a per-row LIKE prefix scan over the folder table, and with omission
plus the write-side clamp its absence can only suppress a hash briefly until the
next serve. Plan verified on a 96K-track production copy: all folder accesses
remain PK point lookups.
2026-07-18 00:05:57 -04:00
Deluan
116fc5b853 fix(artwork): bound decoded dimensions and stop the clamp at serve start
Two hardening fixes to the inline updater. The tee's 20MB cap bounds compressed
input only, so a small file declaring a huge raster could allocate GBs on decode;
DecodeConfig now rejects anything over ~36M pixels from the header alone. And the
write-side version clamp no longer advances past the serve's start time: a version
change that lands mid-serve is not provably covered by the bytes being streamed,
so the clamp stops there, the DTO omits, and the next serve of the new bytes
heals — while structural read-side over-approximation (which always predates the
serve) still clamps fully.
2026-07-17 23:53:22 -04:00
Deluan
cddc30b586 fix(persistence): gate the parent fold by the reader's album-root rule
The parent folder's images_updated_at now counts only when the reader could
actually serve the album-root cover: multiple album folders (disc layout), or a
single folder with no images of its own. This mirrors albumRootParent's first
qualification gate, so an unrelated artist-level image change no longer advances
(and temporarily suppresses) the version of every sibling album with its own
cover. The remaining gates (other-audio, library root) stay unmirrored: with
omission plus the write-side clamp, residual over-approximation only causes a
short suppression that closes on the next serve. Plan verified on a 96K-track
production copy: unchanged, all PK point lookups.
2026-07-17 23:41:08 -04:00
Deluan
9e3fc91cec test(artwork): separate the cover swap in time for Windows clock granularity
The quick-scan e2e swapped the cover milliseconds after the first serve; Windows'
~15ms timer granularity could collapse the stored version and the new folder
timestamp into equal values, failing the staleness assertion. A 50ms gap makes the
ordering deterministic on all platforms.
2026-07-17 23:36:52 -04:00
Deluan
a98bebc314 fix(artwork): clamp the persisted blurhash version to the entity's
The read-side artwork version may over-approximate the served sources (folder
parents for disc layouts), which with omission semantics suppresses a perfectly
valid stored hash. At persist time the hash is fresh for the served bytes by
construction, so the write now loads the entity and clamps blur_hash_updated_at
up to its current ArtworkUpdatedAt: after any serve the DTO accepts the stored
hash, and every omission window closes regardless of read-side precision. The
in-memory state shrinks to a pure decode cache (checksum -> hash); staleness
decisions moved to the row read, which also lets a drifted stored value be
restored from the served bytes.
2026-07-17 23:28:02 -04:00
Deluan
817baa5c1a fix(jellyfin): omit the blurhash when no current hash exists, never fabricate
Upstream Jellyfin omits ImageBlurHashes entries when no hash was computed, and
clients are built around that: redesign Finamp uses the blurhash as immutable
cover identity (year-long cache pins, download dedup) and falls back to id-keyed
caching with a short TTL when it is absent. Emitting a rotating fake seeded by
id+version fed a fabricated identity into those caches and churned them on every
version bump, and a fake accepted under an imprecise artwork version could pin a
wrong value for a year. Absence is strictly safer: no LQIP during the first-serve
gap, self-healing within the fallback TTL.

The staleness gate is kept, its job now being to suppress a stale stored hash
rather than to choose between real and fake. Songs no longer carry a fabricated
per-album value either; art resolution uses AlbumPrimaryImageTag alone.
2026-07-17 23:28:01 -04:00
Deluan
f0fe070ba1 fix(scanner): cap folder images_updated_at at scan time
A future-stamped image file (clock skew on NAS mounts) previously flowed verbatim
into folder.images_updated_at and from there into the album artwork version, while
the stored blur_hash_updated_at is capped at now on write. The DTO staleness gate
then kept emitting the fake blurhash until wall time caught up with the file mtime.
Capping at the source keeps future values out of the DB entirely; rotation is
preserved because any later change is capped to a later scan time. Capping in the
DTO instead would be worse: the version would become a moving target and the fake
seed would rotate on every request.
2026-07-17 22:47:43 -04:00
Deluan
08d83a7785 fix(persistence): include parent folders in the album artwork version
Multi-disc albums keep their cover in the album-root folder, which the artwork
reader reaches via albumRootParent but which is not in album.folder_ids (only the
disc folders hold media files). A root cover swap therefore advanced the served
cache key without moving the selected artwork version, recreating the stale-hash
deadlock for hash-keyed clients. The version subquery now also considers the
folders' parents. This slightly over-covers (an artist-folder image change can
advance a single-folder album's version), which errs on the side of one spurious
refetch instead of permanent staleness. Plan verified on a 96K-track production
copy: still PK point lookups, outer scan unchanged.
2026-07-17 22:47:42 -04:00
Deluan
9a36047096 fix(artwork): version the resized cache key to backfill blurhashes on upgrade
Resized entries cached by a pre-blurhash version serve straight from the cache:
the resized reader never runs, the original is never read, and the tee never gets
a chance to compute a hash — clients that only request sized images (Jellyfin
maxwidth) would keep receiving the fake blurhash indefinitely for those items.

Versioning the resized cache key makes every post-upgrade sized request miss and
refill. The refill pulls the original through Get, which usually hits the cached
original (original keys are unchanged), so the backfill costs one decode+re-encode
per resized variant with no source or external-provider I/O. Orphaned entries are
evicted by the cache's LRU as usual.
2026-07-17 22:30:38 -04:00
Deluan
07af29fbb4 fix(jellyfin): fold folder image mtimes into the album blurhash version
An in-place cover-file swap followed by a quick scan updates only the folder's
images_updated_at: no tracks are imported, so the album row never moves and
ArtworkUpdatedAt() stayed at the old value. The Jellyfin DTO then kept emitting
the previous stored blurhash, and clients that key their cover caches on it never
refetched the image, so the served-bytes recompute could never run.

The album select now surfaces the newest folder images_updated_at (bare-column
correlated subquery over json_each(folder_ids), so the datetime decltype survives
and scans as time.Time) and ArtworkUpdatedAt() folds it in. Benchmarked on a 96K
track production copy: ~90us/page added, no query-plan change; the subquery is a
PK point lookup per folder with at most two rows to order. Artist images and
playlist sidecars have the same theoretical gap but their timestamps cannot be
derived in SQL; they remain a documented follow-up.
2026-07-17 22:14:13 -04:00
Deluan
2499de2bac perf(artwork): key blurhash dedup by identity, plus review cleanups
The seen map was keyed by the full ArtworkID, which embeds the client token's
LastUpdate: every scan bump rotated the key, so the same-bytes dedup (and its
cheap version re-persist path) never fired in production and stale entries
accumulated forever. The key now zeroes LastUpdate, making the map bounded by
entity count and restoring the tested dedup behavior.

Cleanups from the same review: the base83 encoder is now exported from the
blurhash package and the DTO's duplicate copy is deleted; the always-set
blurHashes field lost its test-shaped nil guards; clearIfStored dropped its
inert version parameter (cleared rows never have their timestamp read); the
teeReader done flag is replaced by nilling the callback; worker-era comments
(async, cache-miss recompute) were updated to the inline design; and the e2e
specs assert directly instead of polling, since the hash is persisted before
the read helpers return.
2026-07-17 21:46:12 -04:00
Deluan
83e9bb8180 refactor(artwork): compute the blurhash inline, drop the background worker
Decode+encode is a few ms (the encoder downscales before its pixel loops), and the
tee fires on Close after the response is fully written, so the hash can be computed
in the serving goroutine: the queue, wake/stop lifecycle, lazy-start admin context,
and the e2e worker-teardown ordering all become unnecessary and are removed. This
also closes two correctness holes the queue had: a deletion signal could be dropped
behind a pending serve job, and buffered image bytes had no global cap.

The in-memory dedup now remembers a checksum of the served bytes plus the persisted
version: identical serves skip the decode and the write, but the same bytes under a
newer entity version re-persist, so blur_hash_updated_at keeps pace with row updates
and the Jellyfin DTO's staleness gate keeps emitting the stored hash after scans.
Placeholder-triggered clears check the seen map, then the stored row, so coverless
entities cost one row read once per process instead of a probe and write per serve.
UpdateBlurHash is a plain UPDATE with no user filtering, so the request context is
used directly (a client abort is survived via context.WithoutCancel).
2026-07-17 21:20:46 -04:00
Deluan
7307fd716b fix(artwork): close the underlying stream from the tee; rename to tee_reader
The tee was built around io.NopCloser(r) and teeCachedStream.Close only closed the
tee, so the underlying CachedStream (an open cache-file fd, or the raw source stream
when the image cache is disabled) was never closed — one fd leaked per teed serve,
enough to exhaust the process limit during a client's initial cover sync. The tee now
wraps the stream directly and its Close propagates; teeCachedStream is gone (all
artwork handlers io.Copy, none Seek). The file is renamed to tee_reader.go since the
wrapper is generic, not blurhash-specific.
2026-07-17 21:13:03 -04:00
Deluan
b63c9b095b test(artwork): cover playlist placeholder-clear via the tee
A playlist that loses its sidecar cover serves the bundled placeholder through
Get; those bytes flow through the tee, the runner recognizes the placeholder, and
clears the stored hash. This exercises the free deletion path (no GetOrPlaceholder
needed, since the playlist reader chain ends in fromAlbumPlaceholder).
2026-07-17 20:36:38 -04:00
Deluan
e8fac4c335 feat(artwork): trigger blurhash from the served-bytes tee
Get wraps an eligible original-size serve (album/artist/playlist) in a teeCachedStream
so the bytes streamed to the client are also captured; on a fully-consumed Close the
runner hashes exactly what was served. GetOrPlaceholder routes a vanished album/artist
cover through EnqueueClearIfGone, since no bytes flow through the tee on ErrUnavailable.
capAtNow keeps a future mtime out of the stored version. The mtime-preserved cover-swap
characterization test (previously pending) now passes, and the disappearing-cover e2e
serves through GetOrPlaceholder to match the real Jellyfin/Subsonic path.
2026-07-17 20:34:47 -04:00
Deluan
f6dd722119 refactor(artwork): compute blurhash from served bytes, drop proxy signals
The worker no longer infers whether the served bytes changed through a stack of
proxy signals (snapshot timestamp vs stored blur_hash_updated_at, freshness guard,
gone bit, idempotent-write skip, computeFromArtwork re-read). It now takes the exact
bytes captured from a serve and is a pure function of them: placeholder clears,
undecodable is left alone, otherwise encode and write with an in-memory last-hash
dedup. Deletion is a checkGone job that re-reads once and clears only if the source
is still gone, so a transient fetch failure can't clobber a valid hash.
2026-07-17 20:34:33 -04:00
Deluan
2169938b30 feat(artwork): add teeReader to capture served artwork bytes
A wrapping io.ReadCloser that mirrors read bytes into a bounded buffer and, on a
fully-consumed Close, hands the captured bytes to a callback. Partial reads and
oversized streams are skipped so the callback only ever receives a complete,
bounded image — the exact bytes the client received. This is the capture side of
the served-bytes blurhash tee.
2026-07-17 19:36:31 -04:00
Deluan
eb5ecabc5a test(artwork): characterize mtime-preserved cover swap staleness (pending)
A cover replaced in place without a mtime change is not re-hashed today: the
freshness guard skips recompute when no timestamp moved, so the stored blurhash
(and the client's cover cache keyed by it) stays stale. Marked pending until the
served-bytes tee lands, which recomputes from the exact bytes served.
2026-07-17 19:35:13 -04:00
Deluan
bf7ae5e82e fix(artwork): cap the blurhash snapshot at now to survive future mtimes
A future-dated artwork file mtime (clock skew, or a file stamped ahead of the
server clock) flowed into the reader's LastUpdated snapshot and was persisted
verbatim as blur_hash_updated_at. Both the worker's freshness guard and the
Jellyfin DTO use a !Before comparison against that timestamp, so a later
legitimate cover change whose row/mtime clock was still behind the future value
would be skipped, pinning the stored hash (and the client's cached cover) until
wall time caught up.

Cap the snapshot at time.Now() in process() before every freshness comparison
and persist, so a real later change always advances past it. Normal past mtimes
(the legitimate 'snapshot exceeds row version' case) are unaffected.
2026-07-17 15:51:01 -04:00
Deluan
5cd75503cb fix(artwork): backfill warm caches and supersede pending gone on refill
Two issues in the fill-triggered blurhash recompute:

1. Enqueuing was gated on a cache miss (!r.Cached). On an upgraded instance the
   image cache is persisted and adopted across restarts, so already-cached
   artwork serves as a cache hit and never enqueued, leaving its migrated-empty
   blur_hash as a synthetic value indefinitely. Enqueue on every original-size
   serve instead: the worker's freshness guard turns already-hashed rows into a
   cheap read and only recomputes when the hash is stale or missing.

2. Enqueue merged the gone flag with a sticky OR, so a cover restored right
   after a missing-art serve kept gone=true and took the clear-and-return path,
   never recomputing. A successful serve proves the artwork exists, so it now
   clears any pending gone for that artwork; a gone that follows a fill still
   sticks.
2026-07-17 15:36:17 -04:00
Deluan
2cd967a456 fix(artwork): keep the stored blurhash on transient recompute errors
process() cleared the stored hash whenever computeFromArtwork returned an
error OR an empty hash. A transient failure (the 30s context timeout, a flaky
cache/DB/reader read) is not evidence the artwork changed, so clearing on it
made the Jellyfin DTO fall back to a fake blurhash and churn clients' cover
caches until a later successful fill restored it.

Split the two outcomes: a compute error now leaves the stored hash intact and
lets a later fill retry, while an empty hash (a placeholder, i.e. the cover is
confirmed gone) still clears it. Deletion witnessed by a failed serve is
already handled separately by the gone path.
2026-07-17 15:18:28 -04:00
Deluan
66d6fdc1a6 test(artwork): stop the blurhash worker before spec TempDir cleanup
The artwork e2e suite stopped the worker via a DeferCleanup registered in
setupHarness's BeforeEach, which Ginkgo runs LAST — after a spec body's own
GinkgoT().TempDir() cleanups. With the cache disabled in these tests, every
artwork serve now enqueues a blurhash recompute, so the worker can still be
reading a spec-local sidecar file when its TempDir is removed. On Windows that
unlink fails ("the process cannot access the file because it is being used by
another process"), which flaked the playlist case-insensitive sidecar spec.

Move the worker shutdown to a suite-level AfterEach, which runs before any
spec-body DeferCleanup, so no artwork file is held open when TempDir removal
runs. Close() is idempotent, so the change is safe across specs.
2026-07-17 15:03:19 -04:00
Deluan
3759488db5 refactor(artwork): trigger blurhash recompute from the cache fill
The blurhash worker previously recomputed on every artwork serve and
reconciled a row-level freshness oracle against serve-time hints (force,
sourceGone, imageUpdatedAt) to decide whether the served bytes had actually
changed. Every staleness bug found in review was one case where that weak
oracle disagreed with the true one, and each fix imported one more serve-time
fragment into it.

Move the trigger to the image-cache fill instead: an original-size cache miss
is exactly when the served bytes change, so the reader's LastUpdated snapshot
is the truth and no reconciliation is needed. Resized fills recurse through
Get(size=0) and hash the original once; a disabled cache reports every original
serve as a fill, keeping real hashes available (the worker's unchanged-hash
guard keeps that write-free).

This deletes the force/sourceGone/imageUpdatedAt flags, the double-freshness
comparison, and the entire negative cache. Only the two irreducible pieces
survive: the placeholder byte-compare and the ErrUnavailable clear-hook
(EnqueueGone), since deletion-without-rescan is invisible to every passive
signal. Adds an e2e test for in-place cover swaps, the scenario that drove the
deleted machinery, now covered structurally by the fill trigger.

Schema, DTO, repositories and the blurhash encoder package are unchanged.
2026-07-17 14:47:01 -04:00
Deluan
ae36e4dfc7 fix(artwork): recompute on original serves when the image cache is disabled
With ImageCacheSize=0 every serve reads live bytes, so an in-place cover
swap without rescan changed the served image with no signal the worker
could see. Original-size serves now force on disabled caches, and a new
unchanged-hash guard skips the DB write, so the forced path costs only the
background decode those installs already pay per request.
2026-07-17 14:13:09 -04:00
Deluan
d0ac427377 ci: disable green-tea GC on the Windows test job
Go 1.26's green-tea GC crashes intermittently on Windows runners: three
distinct runtime fatal-error signatures across 1.26.4/1.26.5, always in
the persistence suite, twice in a row on this PR.
2026-07-17 14:08:44 -04:00
Deluan
7a61776c1c fix(artwork): clear a fresh-looking hash when the serve finds no source
In the window before a rescan records a deleted cover (or after cache
eviction/restart), no row or mtime signal moves, so the freshness skip kept
the stale hash. ErrUnavailable serves now carry a sourceGone signal that
bypasses the skip only while a stored hash remains to clear — afterwards
the negative cache applies, so artwork-less entities still don't re-resolve
per serve. The e2e spec now covers the no-rescan window.
2026-07-17 13:53:08 -04:00
Deluan
a7eec3afc3 fix(artwork): clear the stored hash when the artwork source disappears
A removed cover made Get error with ErrUnavailable before the enqueue, so
the worker never saw the entity and the stale hash kept being emitted. The
unavailable path now enqueues too; the worker's reader signal carries the
folder change and the existing no-result clearing applies. Covered by an
e2e spec exercising the full disappear-and-clear flow.
2026-07-17 13:33:45 -04:00
Deluan
0e74cf0ab1 fix(artwork): only force blurhash recompute on original-size cache misses
Resized cache keys vary per requested size, so a first request for a new
thumbnail size (or an evicted resized entry) forced a recompute with an
unchanged source — wasted work, and a transient failure during it could
clear a valid stored hash. Resized readers re-fetch the original through
Get, so the original-size call still carries the real change signal.
2026-07-17 13:16:07 -04:00
Deluan
9ac2c6a5e3 fix(model): exclude blur hash fields from full-row writes
Scanner and maintenance paths build fresh entities with empty blur hash
fields, so every ordinary refresh erased the computed hash and caused a
double Finamp cover refetch (fake, then real again). The fields are now
read-only projections (structs:"-"): only UpdateBlurHash writes them.
2026-07-17 12:57:33 -04:00
Deluan
c4ca3dca5e fix(artwork): clear the stored hash when a recompute yields no result
A cover deleted or corrupted in place (mtime-only signal, row unchanged)
left the old blur_hash in the DB, and the DTO kept emitting a hash for
artwork no longer served. Since the worker only reaches compute when there
is change evidence, a no-result with a stored hash now clears it, making
the DTO fall back to the rotating fake.
2026-07-17 12:48:36 -04:00
Deluan
3a8505584b fix(artwork): hash the cached artwork bytes, not a fresh source read
Generated playlist mosaics pick albums with random(), so a direct source
read produced a different image than the cached one clients download, and
the persisted blurhash described a mosaic nobody sees. The worker now reads
through the image cache and detects placeholders by byte equality with the
embedded assets (cached reads carry no source path).
2026-07-17 12:27:13 -04:00
Deluan
f9f6d36ebb fix(artwork): TTL for no-result memoization, prompt Close, no warmup force spike
- memoize every no-result outcome (ErrUnavailable can wrap transient agent
  and storage failures, so it is not a reliable definitive/transient
  discriminator) but bound it with a 1h TTL, which also un-poisons entries
  recorded under future file mtimes
- cancel the worker context and check done in the drain loop, so Close
  returns promptly instead of draining the backlog at up to 30s per item
- only force recompute when the image cache is operational: during warmup
  every serve is a miss, which caused a recompute spike at startup
2026-07-17 12:11:30 -04:00
Deluan
43500c0ffe fix(artwork): persist the image freshness signal to stop per-serve recomputes
When a cover file's mtime is newer than the entity row (common), persisting
the row version made the freshness check fail on every serve, re-encoding
and re-writing the same hash each time. The snapshot now stores the newest
signal (row version or image mtime), and both freshness checks accept a
snapshot at-or-after the row version.
2026-07-17 11:59:24 -04:00
Deluan
b040345fb0 fix(artwork): stop the blurhash worker on Close to fix test data races
CI's race detector caught the process-lifetime worker goroutine outliving
Ginkgo specs and touching mocks/fake filesystems being torn down. The
worker now has a stop() that waits for in-flight work, exposed as Close()
on the artwork service; unit suites close it up front (they don't exercise
blurhash), the artwork e2e suite closes it on cleanup.
2026-07-17 11:51:24 -04:00
Deluan
5bdeaad95e perf(artwork): eliminate per-pixel allocations in blurhash encoding
Normalize the downscaled image to *image.RGBA once and read Pix directly
(the image.At interface boxed a color per pixel — ~16k allocs/encode), and
replace per-pixel math.Pow with a 256-entry sRGB-to-linear table. ~72%
faster (3.0ms -> 0.86ms for covers >=300px), 19 allocs/op. Benchmark
included.
2026-07-17 11:33:16 -04:00
Deluan
696399dab7 fix(artwork): don't memoize transient blurhash failures; track image freshness with cache disabled
Only ErrUnavailable (definitively no artwork) is negative-cached; timeouts
and storage/agent hiccups retry on a later serve. Enqueue now carries the
reader's LastUpdated so file swaps are detected even when the image cache
is disabled (where every serve reads the source and miss-forcing is off).
2026-07-17 11:20:41 -04:00
Deluan
9ea4e22ea0 fix(artwork): address PR review bot feedback
- don't record noResult for timed-out/cancelled computations (transient
  failures must not suppress retries for the same artwork version)
- bound the noResult negative cache at 25k entries
- use context.Background for the worker's root context
- add hash:"ignore" to Artist/Playlist blurhash fields for consistency
  with Album (inert today; neither struct is hashed)
2026-07-17 11:13:59 -04:00
Deluan
4d8b486dfd Merge remote-tracking branch 'origin/master' into feat/artwork-blurhash
# Conflicts:
#	model/playlist.go
#	model/playlist_test.go
2026-07-17 11:09:09 -04:00
Deluan
e100c48102 fix(artwork): address blurhash review findings
- force recompute on image-cache miss, so in-place cover/sidecar swaps and
  agent image updates refresh the stored hash even when no entity row moved
- exclude external_info_updated_at from the artwork version: agent TTL
  refreshes bump it with an unchanged image, churning Finamp's cover cache
- lazy-start the worker goroutine and bound each computation with a 30s
  timeout; remember no-result entities per version to avoid re-decoding
  placeholders on every serve
- error (instead of silently skipping) on unknown artwork kinds in persist
2026-07-16 21:52:28 -04:00
Deluan
0b365a0090 refactor(artwork): simplify blurhash cleanup review findings
- detect placeholder artwork via the reader's source path instead of
  comparing against precomputed placeholder hashes (fragile fingerprint,
  dead artist-placeholder branch)
- collapse the three identical UpdateBlurHash repo methods into a shared
  sqlRepository.updateBlurHash helper
2026-07-16 21:52:28 -04:00
Deluan
76e1fba067 test(artwork): e2e coverage for async blurhash persistence 2026-07-16 21:52:28 -04:00
Deluan
585ac0aab3 feat(jellyfin): emit stored blurhashes with version-seeded fake fallback 2026-07-16 21:52:28 -04:00
Deluan
9f9019df07 feat(artwork): compute and persist blurhashes asynchronously on artwork serve 2026-07-16 21:51:04 -04:00
Deluan
f763ebff5b feat(persistence): add UpdateBlurHash targeted update to album/artist/playlist repos 2026-07-16 21:51:04 -04:00
Deluan
a115726e71 feat(model): add blur_hash columns and ArtworkUpdatedAt version methods 2026-07-16 21:51:04 -04:00
Deluan
470b4bdfa0 feat(artwork): add blurhash encoder package 2026-07-16 21:51:04 -04:00
36 changed files with 1567 additions and 67 deletions

View file

@ -147,6 +147,9 @@ jobs:
env:
FFMPEG_VERSION: "7.1"
FFMPEG_REPOSITORY: navidrome/ffmpeg-windows-builds
# Go 1.26's green-tea GC crashes intermittently on Windows runners (runtime fatal errors in
# the persistence suite, three distinct signatures across 1.26.4/1.26.5).
GOEXPERIMENT: nogreenteagc
steps:
- uses: actions/checkout@v7

View file

@ -19,20 +19,34 @@ import (
var ErrUnavailable = errors.New("artwork unavailable")
// maxTeeBytes bounds the per-serve capture buffer; artwork is a few MB, and anything larger is not
// hashed (skipped), so a pathological source can't accumulate unbounded memory across serves.
const maxTeeBytes = 20 * 1024 * 1024
// capAtNow keeps a future artwork mtime (clock skew, a future-stamped file) from being stored as the
// blurhash version, which would let the DTO's !Before check pin the hash until wall time caught up.
func capAtNow(t time.Time) time.Time {
if now := time.Now(); t.After(now) {
return now
}
return t
}
type Artwork interface {
Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (io.ReadCloser, time.Time, error)
GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error)
}
func NewArtwork(ds model.DataStore, cache cache.FileCache, ffmpeg ffmpeg.FFmpeg, provider external.Provider) Artwork {
return &artwork{ds: ds, cache: cache, ffmpeg: ffmpeg, provider: provider}
return &artwork{ds: ds, cache: cache, ffmpeg: ffmpeg, provider: provider, blurHashes: newBlurHashUpdater(ds)}
}
type artwork struct {
ds model.DataStore
cache cache.FileCache
ffmpeg ffmpeg.FFmpeg
provider external.Provider
ds model.DataStore
cache cache.FileCache
ffmpeg ffmpeg.FFmpeg
provider external.Provider
blurHashes *blurHashUpdater
}
type artworkReader interface {
@ -47,6 +61,9 @@ func (a *artwork) GetOrPlaceholder(ctx context.Context, id string, size int, squ
reader, lastUpdate, err = a.Get(ctx, artID, size, square)
}
if errors.Is(err, ErrUnavailable) {
// The client is receiving the placeholder, so a stored hash describing the old cover must
// clear — hash-what-you-serve applies to the fallback too.
a.blurHashes.clearIfStored(ctx, artID)
if artID.Kind == model.KindArtistArtwork {
reader, _ = resources.FS().Open(consts.PlaceholderArtistArt)
} else {
@ -70,7 +87,17 @@ func (a *artwork) Get(ctx context.Context, artID model.ArtworkID, size int, squa
}
return nil, time.Time{}, err
}
return r, artReader.LastUpdated(), nil
reader = r
if size == 0 && !square && eligibleKind(artID) {
// Tee the served bytes: the blurhash is computed from exactly what the client downloads, so it
// changes precisely when the served cover changes. Placeholder bytes (playlist fallback) clear.
// The tee wraps r directly, so Close reaches the underlying stream (no fd leak).
version := capAtNow(artReader.LastUpdated())
start := time.Now()
reader = newTeeReader(r, maxTeeBytes,
func(data []byte) { a.blurHashes.update(ctx, artID, data, version, start) })
}
return reader, artReader.LastUpdated(), nil
}
type coverArtGetter interface {

View file

@ -0,0 +1,177 @@
// Package blurhash implements the blurhash encoding algorithm (https://github.com/woltapp/blurhash),
// matching Jellyfin's parameters so clients tuned against Jellyfin see equivalent hashes.
package blurhash
import (
"errors"
"image"
"image/draw"
"math"
"strings"
"sync"
xdraw "golang.org/x/image/draw"
)
const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~"
// maxInputSize matches Jellyfin: larger inputs are slower with no visually discernible difference.
const maxInputSize = 128
// Components picks x/y component counts for an image, targeting ~16 near-square tiles (Jellyfin's formula).
func Components(width, height int) (int, int) {
if width <= 0 || height <= 0 {
return 0, 0
}
xf := math.Sqrt(16.0 * float64(width) / float64(height))
yf := xf * float64(height) / float64(width)
return min(int(xf)+1, 9), min(int(yf)+1, 9)
}
// Encode returns the blurhash of img using xComp x yComp components.
func Encode(img image.Image, xComp, yComp int) (string, error) {
if xComp < 1 || xComp > 9 || yComp < 1 || yComp > 9 {
return "", errors.New("blurhash: components must be between 1 and 9")
}
rgba := toRGBA(downscale(img))
bounds := rgba.Bounds()
w, h := bounds.Dx(), bounds.Dy()
if w == 0 || h == 0 {
return "", errors.New("blurhash: empty image")
}
cosX := make([][]float64, xComp)
for i := range cosX {
cosX[i] = make([]float64, w)
for x := range cosX[i] {
cosX[i][x] = math.Cos(math.Pi * float64(i) * float64(x) / float64(w))
}
}
cosY := make([][]float64, yComp)
for j := range cosY {
cosY[j] = make([]float64, h)
for y := range cosY[j] {
cosY[j][y] = math.Cos(math.Pi * float64(j) * float64(y) / float64(h))
}
}
lin := srgbToLinearTable()
factors := make([][3]float64, xComp*yComp)
for y := 0; y < h; y++ {
row := rgba.Pix[y*rgba.Stride:]
for x := 0; x < w; x++ {
p := x * 4
lr, lg, lb := lin[row[p]], lin[row[p+1]], lin[row[p+2]]
for j := 0; j < yComp; j++ {
for i := 0; i < xComp; i++ {
basis := cosX[i][x] * cosY[j][y]
f := &factors[j*xComp+i]
f[0] += basis * lr
f[1] += basis * lg
f[2] += basis * lb
}
}
}
}
for idx := range factors {
norm := 2.0
if idx == 0 {
norm = 1.0
}
scale := norm / float64(w*h)
factors[idx][0] *= scale
factors[idx][1] *= scale
factors[idx][2] *= scale
}
var sb strings.Builder
sb.WriteString(Encode83((xComp-1)+(yComp-1)*9, 1))
ac := factors[1:]
maxVal := 1.0
if len(ac) > 0 {
actualMax := 0.0
for _, f := range ac {
actualMax = max(actualMax, math.Abs(f[0]), math.Abs(f[1]), math.Abs(f[2]))
}
quantMax := int(math.Max(0, math.Min(82, math.Floor(actualMax*166-0.5))))
maxVal = float64(quantMax+1) / 166
sb.WriteString(Encode83(quantMax, 1))
} else {
sb.WriteString(Encode83(0, 1))
}
dc := factors[0]
sb.WriteString(Encode83(linearToSRGB(dc[0])<<16|linearToSRGB(dc[1])<<8|linearToSRGB(dc[2]), 4))
for _, f := range ac {
sb.WriteString(Encode83(quantAC(f[0], maxVal)*19*19+quantAC(f[1], maxVal)*19+quantAC(f[2], maxVal), 2))
}
return sb.String(), nil
}
// toRGBA gives the pixel loop direct Pix access, avoiding a per-pixel allocation through the
// image.At interface (~16k allocs per encode).
func toRGBA(img image.Image) *image.RGBA {
if rgba, ok := img.(*image.RGBA); ok {
return rgba
}
b := img.Bounds()
dst := image.NewRGBA(image.Rect(0, 0, b.Dx(), b.Dy()))
draw.Draw(dst, dst.Bounds(), img, b.Min, draw.Src)
return dst
}
var srgbToLinearTable = sync.OnceValue(func() *[256]float64 {
var t [256]float64
for i := range t {
t[i] = srgbToLinear(i)
}
return &t
})
func downscale(img image.Image) image.Image {
b := img.Bounds()
w, h := b.Dx(), b.Dy()
if w <= maxInputSize && h <= maxInputSize {
return img
}
scale := float64(maxInputSize) / float64(max(w, h))
dst := image.NewRGBA(image.Rect(0, 0, max(1, int(float64(w)*scale)), max(1, int(float64(h)*scale))))
xdraw.ApproxBiLinear.Scale(dst, dst.Bounds(), img, b, draw.Src, nil)
return dst
}
func quantAC(v, maxVal float64) int {
return int(math.Max(0, math.Min(18, math.Floor(signPow(v/maxVal, 0.5)*9+9.5))))
}
func signPow(v, exp float64) float64 {
return math.Copysign(math.Pow(math.Abs(v), exp), v)
}
func srgbToLinear(v int) float64 {
f := float64(v) / 255
if f <= 0.04045 {
return f / 12.92
}
return math.Pow((f+0.055)/1.055, 2.4)
}
func linearToSRGB(v float64) int {
v = math.Min(math.Max(0, v), 1)
if v <= 0.0031308 {
return int(v*12.92*255 + 0.5)
}
return int((1.055*math.Pow(v, 1/2.4)-0.055)*255 + 0.5)
}
// Encode83 encodes value as a fixed-width, big-endian base83 string of the given length, using the
// blurhash spec's alphabet.
func Encode83(value, length int) string {
b := make([]byte, length)
for i := length - 1; i >= 0; i-- {
b[i] = alphabet[value%83]
value /= 83
}
return string(b)
}

View file

@ -0,0 +1,41 @@
package blurhash_test
import (
"fmt"
"image"
"image/color"
"testing"
"github.com/navidrome/navidrome/core/artwork/blurhash"
)
// benchImage builds a deterministic gradient so runs are comparable across revisions.
func benchImage(size int) image.Image {
img := image.NewNRGBA(image.Rect(0, 0, size, size))
for y := 0; y < size; y++ {
for x := 0; x < size; x++ {
img.SetNRGBA(x, y, color.NRGBA{
R: uint8(255 * x / size),
G: uint8(255 * y / size),
B: uint8((x + y) * 255 / (2 * size)),
A: 255,
})
}
}
return img
}
func BenchmarkEncode(b *testing.B) {
for _, size := range []int{100, 300, 600, 900, 1200, 1500} {
img := benchImage(size)
x, y := blurhash.Components(size, size)
b.Run(fmt.Sprintf("%dx%d", size, size), func(b *testing.B) {
b.ReportAllocs()
for range b.N {
if _, err := blurhash.Encode(img, x, y); err != nil {
b.Fatal(err)
}
}
})
}
}

View file

@ -0,0 +1,17 @@
package blurhash_test
import (
"testing"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestBlurHash(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "BlurHash Suite")
}

View file

@ -0,0 +1,113 @@
package blurhash_test
import (
"image"
"image/color"
"strings"
"github.com/navidrome/navidrome/core/artwork/blurhash"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~"
func decode83(s string) int {
v := 0
for _, c := range s {
v = v*83 + strings.IndexRune(alphabet, c)
}
return v
}
func solidImage(w, h int, c color.NRGBA) image.Image {
img := image.NewNRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
img.SetNRGBA(x, y, c)
}
}
return img
}
func gradientImage(w, h int) image.Image {
img := image.NewNRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
img.SetNRGBA(x, y, color.NRGBA{R: uint8(255 * x / w), G: uint8(255 * y / h), B: 128, A: 255})
}
}
return img
}
var _ = Describe("Components", func() {
DescribeTable("derives component counts from aspect ratio (Jellyfin formula)",
func(w, h, expectedX, expectedY int) {
x, y := blurhash.Components(w, h)
Expect(x).To(Equal(expectedX))
Expect(y).To(Equal(expectedY))
},
Entry("square album art", 600, 600, 5, 5),
Entry("small square", 1, 1, 5, 5),
Entry("landscape 16:9", 1920, 1080, 6, 4),
Entry("portrait 9:16", 1080, 1920, 4, 6),
Entry("extreme landscape capped at 9", 10000, 100, 9, 1),
Entry("zero width", 0, 600, 0, 0),
Entry("zero height", 600, 0, 0, 0),
)
})
var _ = Describe("Encode", func() {
It("rejects out-of-range components", func() {
_, err := blurhash.Encode(solidImage(8, 8, color.NRGBA{A: 255}), 0, 5)
Expect(err).To(HaveOccurred())
_, err = blurhash.Encode(solidImage(8, 8, color.NRGBA{A: 255}), 5, 10)
Expect(err).To(HaveOccurred())
})
It("produces the spec-mandated length", func() {
// 1 (size flag) + 1 (max AC) + 4 (DC) + 2 per AC component
h, err := blurhash.Encode(solidImage(8, 8, color.NRGBA{R: 10, G: 20, B: 30, A: 255}), 4, 3)
Expect(err).ToNot(HaveOccurred())
Expect(h).To(HaveLen(4 + 2 + 2*(4*3-1)))
})
It("encodes the size flag as the first character", func() {
h, err := blurhash.Encode(solidImage(8, 8, color.NRGBA{A: 255}), 4, 3)
Expect(err).ToNot(HaveOccurred())
Expect(decode83(h[:1])).To(Equal((4 - 1) + (3-1)*9))
})
It("stores the average color in the DC component", func() {
h, err := blurhash.Encode(solidImage(16, 16, color.NRGBA{R: 200, G: 100, B: 50, A: 255}), 4, 3)
Expect(err).ToNot(HaveOccurred())
dc := decode83(h[2:6])
Expect(dc >> 16).To(BeNumerically("~", 200, 1))
Expect((dc >> 8) & 0xFF).To(BeNumerically("~", 100, 1))
Expect(dc & 0xFF).To(BeNumerically("~", 50, 1))
})
It("is deterministic", func() {
img := gradientImage(64, 64)
h1, err1 := blurhash.Encode(img, 5, 5)
h2, err2 := blurhash.Encode(img, 5, 5)
Expect(err1).ToNot(HaveOccurred())
Expect(err2).ToNot(HaveOccurred())
Expect(h1).To(Equal(h2))
})
It("produces different hashes for different images", func() {
h1, _ := blurhash.Encode(solidImage(16, 16, color.NRGBA{R: 255, A: 255}), 4, 4)
h2, _ := blurhash.Encode(gradientImage(16, 16), 4, 4)
Expect(h1).ToNot(Equal(h2))
})
It("downscales large images internally without changing the result materially", func() {
// A 1000px solid image must encode fine and carry the same DC as its small version.
big, err := blurhash.Encode(solidImage(1000, 1000, color.NRGBA{R: 60, G: 120, B: 180, A: 255}), 5, 5)
Expect(err).ToNot(HaveOccurred())
small, err := blurhash.Encode(solidImage(16, 16, color.NRGBA{R: 60, G: 120, B: 180, A: 255}), 5, 5)
Expect(err).ToNot(HaveOccurred())
Expect(big[2:6]).To(Equal(small[2:6]))
})
})

View file

@ -0,0 +1,224 @@
package artwork
import (
"bytes"
"context"
"fmt"
"hash/fnv"
"image"
"io"
"sync"
"time"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/artwork/blurhash"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/resources"
"github.com/navidrome/navidrome/utils"
)
// blurHashState is a decode cache: the hash last computed for an artwork's served bytes, keyed by
// their checksum, so repeated serves of the same image skip the decode.
type blurHashState struct {
sum uint64
hash string
}
// blurHashUpdater keeps stored blurhashes in sync with the bytes actually served. It runs inline in
// the serving goroutine after the response is fully written (decode+encode is a few ms): the hash is
// a pure function of the captured bytes — no change-detection proxy, no background worker.
type blurHashUpdater struct {
ds model.DataStore
mutex sync.Mutex
seen map[model.ArtworkID]blurHashState
}
func newBlurHashUpdater(ds model.DataStore) *blurHashUpdater {
return &blurHashUpdater{ds: ds, seen: make(map[model.ArtworkID]blurHashState)}
}
func eligibleKind(artID model.ArtworkID) bool {
switch artID.Kind {
case model.KindAlbumArtwork, model.KindArtistArtwork, model.KindPlaylistArtwork:
return true
}
return false
}
// maxDecodePixels bounds the decoded raster: the tee's byte cap limits compressed size only, and a
// small file can declare huge dimensions that would allocate GBs on decode (decompression bomb).
const maxDecodePixels = 36_000_000 // ~6000x6000; decoded RGBA tops out around 144MB
// update hashes the exact bytes served for artID and persists the result. Placeholder bytes mean the
// entity has no artwork anymore, so they clear a stored hash instead. start is when the serve began.
func (u *blurHashUpdater) update(ctx context.Context, artID model.ArtworkID, data []byte, version, start time.Time) {
// Decoding arbitrary image bytes can panic; the serve already succeeded, so just log it.
defer func() {
if r := recover(); r != nil {
log.Error(ctx, "BlurHash: recovered from panic", "artID", artID, "panic", r)
}
}()
// ArtworkID embeds the client token's LastUpdate; zero it so the decode cache keys by identity.
artID.LastUpdate = time.Time{}
// The response is already written when the tee fires; a client abort must not lose the write.
ctx = context.WithoutCancel(ctx)
if isPlaceholder(data) {
u.clearIfStored(ctx, artID)
return
}
sum := checksum(data)
hash := u.cachedHash(artID, sum)
if hash == "" {
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
// int64: on 32-bit builds the pixel product can overflow int and bypass the guard.
if err != nil || int64(cfg.Width)*int64(cfg.Height) > maxDecodePixels {
// Undecodable or oversized served bytes are not proof of change; keep the stored hash.
log.Trace(ctx, "BlurHash: skipping served bytes", "artID", artID, "width", cfg.Width, "height", cfg.Height, err)
return
}
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
log.Trace(ctx, "BlurHash: served bytes not decodable, keeping stored hash", "artID", artID, err)
return
}
b := img.Bounds()
x, y := blurhash.Components(b.Dx(), b.Dy())
if hash, err = blurhash.Encode(img, x, y); err != nil || hash == "" {
return
}
}
stored, storedAt, entityVersion, err := u.loadState(ctx, artID)
if err != nil {
return
}
// Clamp the persisted version up to the entity's, but never past the serve's start: a version that
// predates the serve is provably covered by the served bytes, one that landed mid-serve is not —
// there the clamp stops, the DTO omits, and the next serve of the new bytes heals.
if entityVersion.After(start) {
entityVersion = start
}
if stored == hash && storedAt != nil && !storedAt.Before(entityVersion) {
u.remember(artID, blurHashState{sum: sum, hash: hash})
return
}
target := capAtNow(utils.TimeNewest(version, entityVersion))
if err := u.persist(ctx, artID, hash, target); err != nil {
log.Warn(ctx, "BlurHash: error persisting", "artID", artID, err)
return
}
u.remember(artID, blurHashState{sum: sum, hash: hash})
}
// clearIfStored clears the persisted hash after a placeholder was served (a cold map costs one row
// read to skip never-hashed entities); a failed read clears nothing — unknown state is not deletion.
func (u *blurHashUpdater) clearIfStored(ctx context.Context, artID model.ArtworkID) {
if !eligibleKind(artID) {
return
}
artID.LastUpdate = time.Time{}
ctx = context.WithoutCancel(ctx)
u.mutex.Lock()
prev, ok := u.seen[artID]
u.mutex.Unlock()
if ok && prev.hash == "" {
return
}
if !ok {
stored, _, _, err := u.loadState(ctx, artID)
if err != nil {
return
}
if stored == "" {
u.remember(artID, blurHashState{})
return
}
}
if err := u.persist(ctx, artID, "", time.Now()); err != nil {
log.Warn(ctx, "BlurHash: error clearing hash", "artID", artID, err)
return
}
u.remember(artID, blurHashState{})
}
// cachedHash returns the previously computed hash when the served bytes are unchanged.
func (u *blurHashUpdater) cachedHash(artID model.ArtworkID, sum uint64) string {
u.mutex.Lock()
defer u.mutex.Unlock()
if prev, ok := u.seen[artID]; ok && prev.hash != "" && prev.sum == sum {
return prev.hash
}
return ""
}
func (u *blurHashUpdater) remember(artID model.ArtworkID, s blurHashState) {
u.mutex.Lock()
u.seen[artID] = s
u.mutex.Unlock()
}
func checksum(data []byte) uint64 {
h := fnv.New64a()
_, _ = h.Write(data)
return h.Sum64()
}
func (u *blurHashUpdater) loadState(ctx context.Context, artID model.ArtworkID) (string, *time.Time, time.Time, error) {
switch artID.Kind {
case model.KindAlbumArtwork:
al, err := u.ds.Album(ctx).Get(artID.ID)
if err != nil {
return "", nil, time.Time{}, err
}
return al.BlurHash, al.BlurHashUpdatedAt, al.ArtworkUpdatedAt(), nil
case model.KindArtistArtwork:
ar, err := u.ds.Artist(ctx).Get(artID.ID)
if err != nil {
return "", nil, time.Time{}, err
}
return ar.BlurHash, ar.BlurHashUpdatedAt, ar.ArtworkUpdatedAt(), nil
case model.KindPlaylistArtwork:
pl, err := u.ds.Playlist(ctx).Get(artID.ID)
if err != nil {
return "", nil, time.Time{}, err
}
return pl.BlurHash, pl.BlurHashUpdatedAt, pl.ArtworkUpdatedAt(), nil
}
return "", nil, time.Time{}, model.ErrNotFound
}
// isPlaceholder byte-compares against the embedded placeholder assets: placeholder artwork must never
// be persisted as an entity's blurhash, and captured bytes carry no source path to check.
func isPlaceholder(data []byte) bool {
for _, p := range placeholderImages() {
if bytes.Equal(data, p) {
return true
}
}
return false
}
var placeholderImages = sync.OnceValue(func() [][]byte {
var imgs [][]byte
for _, name := range []string{consts.PlaceholderAlbumArt, consts.PlaceholderArtistArt} {
if f, err := resources.FS().Open(name); err == nil {
if data, err := io.ReadAll(f); err == nil {
imgs = append(imgs, data)
}
_ = f.Close()
}
}
return imgs
})
func (u *blurHashUpdater) persist(ctx context.Context, artID model.ArtworkID, hash string, version time.Time) error {
switch artID.Kind {
case model.KindAlbumArtwork:
return u.ds.Album(ctx).UpdateBlurHash(artID.ID, hash, version)
case model.KindArtistArtwork:
return u.ds.Artist(ctx).UpdateBlurHash(artID.ID, hash, version)
case model.KindPlaylistArtwork:
return u.ds.Playlist(ctx).UpdateBlurHash(artID.ID, hash, version)
}
return fmt.Errorf("blurhash: no persister for artwork kind %q", artID.Kind)
}

View file

@ -0,0 +1,181 @@
package artwork
import (
"bytes"
"encoding/binary"
"hash/crc32"
"image"
"image/color"
"image/png"
"time"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// hugePNGHeader builds a valid PNG signature+IHDR declaring a 50000x50000 raster with no pixel data:
// enough for DecodeConfig to report the dimensions the decode gate must reject.
func hugePNGHeader() []byte {
var buf bytes.Buffer
buf.Write([]byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a})
ihdr := make([]byte, 13)
binary.BigEndian.PutUint32(ihdr[0:], 50000)
binary.BigEndian.PutUint32(ihdr[4:], 50000)
ihdr[8] = 8 // bit depth
ihdr[9] = 6 // RGBA
var chunk bytes.Buffer
chunk.WriteString("IHDR")
chunk.Write(ihdr)
_ = binary.Write(&buf, binary.BigEndian, uint32(13))
buf.Write(chunk.Bytes())
_ = binary.Write(&buf, binary.BigEndian, crc32.ChecksumIEEE(chunk.Bytes()))
return buf.Bytes()
}
// pngImage builds a deterministic 2x2 PNG image for a label (color derived from label bytes).
func pngImage(label string) *image.RGBA {
img := image.NewRGBA(image.Rect(0, 0, 2, 2))
var seed byte
for i := range len(label) {
seed += label[i]
}
c := color.RGBA{R: seed, G: seed * 3, B: seed * 7, A: 255}
for y := range 2 {
for x := range 2 {
img.Set(x, y, c)
}
}
return img
}
func realPNGBytes(label string) []byte {
var buf bytes.Buffer
Expect(png.Encode(&buf, pngImage(label))).To(Succeed())
return buf.Bytes()
}
var _ = Describe("blurHashUpdater", func() {
var u *blurHashUpdater
var ds *tests.MockDataStore
var repo *tests.MockAlbumRepo
var version time.Time
album := func(al model.Album) model.ArtworkID {
repo = tests.CreateMockAlbumRepo()
repo.SetData(model.Albums{al})
ds.MockedAlbum = repo
return al.CoverArtID()
}
stored := func(id string) model.Album {
al, err := ds.Album(GinkgoT().Context()).Get(id)
Expect(err).ToNot(HaveOccurred())
return *al
}
BeforeEach(func() {
ds = &tests.MockDataStore{}
u = newBlurHashUpdater(ds)
version = time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
})
It("persists a hash computed from the served bytes", func() {
id := album(model.Album{ID: "al-1", UpdatedAt: version})
u.update(GinkgoT().Context(), id, realPNGBytes("x"), version, time.Now())
al := stored("al-1")
Expect(al.BlurHash).ToNot(BeEmpty())
Expect(al.BlurHashUpdatedAt).To(HaveValue(Equal(version)))
})
It("clears the stored hash when the served bytes are a placeholder", func() {
id := album(model.Album{ID: "al-1", UpdatedAt: version, BlurHash: "OLD"})
u.update(GinkgoT().Context(), id, placeholderImages()[0], version, time.Now())
Expect(stored("al-1").BlurHash).To(BeEmpty())
})
It("leaves the hash untouched on undecodable bytes", func() {
id := album(model.Album{ID: "al-1", UpdatedAt: version, BlurHash: "KEEP"})
u.update(GinkgoT().Context(), id, []byte("not an image"), version, time.Now())
Expect(stored("al-1").BlurHash).To(Equal("KEEP"))
})
It("does not rewrite when the stored hash is current for the entity version", func() {
id := album(model.Album{ID: "al-1", UpdatedAt: version})
data := realPNGBytes("dedup")
u.update(GinkgoT().Context(), id, data, version, time.Now())
first := stored("al-1")
// A later serve of the same bytes (newer tee version, unchanged entity) must not move the row.
u.update(GinkgoT().Context(), id, data, version.Add(time.Hour), time.Now())
Expect(stored("al-1").BlurHashUpdatedAt).To(HaveValue(Equal(*first.BlurHashUpdatedAt)))
})
It("clamps the persisted version up to the entity's artwork version", func() {
// The read-side version may over-approximate (folder parents); after a serve the hash is fresh
// by construction, so the write clamps up and the DTO accepts it — omission windows close.
id := album(model.Album{ID: "al-1", UpdatedAt: version})
data := realPNGBytes("clamp")
u.update(GinkgoT().Context(), id, data, version, time.Now())
first := stored("al-1")
newer := version.Add(time.Hour)
album(model.Album{ID: "al-1", UpdatedAt: newer, BlurHash: first.BlurHash, BlurHashUpdatedAt: first.BlurHashUpdatedAt})
u.update(GinkgoT().Context(), id, data, version, time.Now()) // same bytes, old tee version
second := stored("al-1")
Expect(second.BlurHash).To(Equal(first.BlurHash))
Expect(second.BlurHashUpdatedAt).To(HaveValue(Equal(newer)))
})
It("restores the stored hash when it drifts from the served bytes", func() {
id := album(model.Album{ID: "al-1", UpdatedAt: version})
data := realPNGBytes("truth")
u.update(GinkgoT().Context(), id, data, version, time.Now())
truth := stored("al-1").BlurHash
Expect(repo.UpdateBlurHash("al-1", "DRIFTED", version)).To(Succeed())
u.update(GinkgoT().Context(), id, data, version, time.Now())
Expect(stored("al-1").BlurHash).To(Equal(truth))
})
It("skips images whose declared dimensions exceed the decode bound", func() {
// The tee's byte cap limits compressed size only; a decompression bomb must be rejected from
// the header before the raster is allocated.
id := album(model.Album{ID: "al-1", UpdatedAt: version, BlurHash: "KEEP"})
u.update(GinkgoT().Context(), id, hugePNGHeader(), version, time.Now())
Expect(stored("al-1").BlurHash).To(Equal("KEEP"))
})
It("does not mark older served bytes as current when the version advances mid-serve", func() {
// The cover was replaced and scanned after this serve started: the clamp must stop at the
// serve's start, so the DTO keeps omitting until the new bytes are served.
changedAt := version.Add(time.Hour)
id := album(model.Album{ID: "al-1", UpdatedAt: changedAt})
u.update(GinkgoT().Context(), id, realPNGBytes("old-bytes"), version, version)
al := stored("al-1")
Expect(al.BlurHash).ToNot(BeEmpty())
Expect(al.BlurHashUpdatedAt).To(HaveValue(Equal(version)))
Expect(al.BlurHashUpdatedAt.Before(al.ArtworkUpdatedAt())).To(BeTrue(), "must read as stale")
})
It("does not write when a placeholder is served and nothing was ever stored", func() {
id := album(model.Album{ID: "al-1", UpdatedAt: version})
u.update(GinkgoT().Context(), id, placeholderImages()[0], version, time.Now())
Expect(stored("al-1").BlurHashUpdatedAt).To(BeNil())
})
It("ignores non-eligible artwork kinds", func() {
Expect(func() {
u.clearIfStored(GinkgoT().Context(), model.ArtworkID{Kind: model.KindMediaFileArtwork, ID: "mf-1"})
}).ToNot(Panic())
Expect(u.seen).To(BeEmpty())
})
It("keys the decode cache by identity, ignoring the artwork id's embedded timestamp", func() {
id := album(model.Album{ID: "al-1", UpdatedAt: version})
data := realPNGBytes("dedup")
u.update(GinkgoT().Context(), id, data, version, time.Now())
bumped := id
bumped.LastUpdate = version.Add(time.Hour)
u.update(GinkgoT().Context(), bumped, data, version, time.Now())
Expect(u.seen).To(HaveLen(1))
})
})

View file

@ -0,0 +1,207 @@
package artworke2e_test
import (
"os"
"path/filepath"
"testing/fstest"
"time"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("BlurHash", func() {
BeforeEach(func() {
setupHarness()
})
// The blurhash is computed inline when the served reader is closed, so by the time the read
// helpers return, the hash is already persisted — no polling needed.
storedAlbum := func(id string) model.Album {
GinkgoHelper()
updated, err := ds.Album(ctx).Get(id)
Expect(err).ToNot(HaveOccurred())
return *updated
}
It("persists a real blurhash after album artwork is served", func() {
setLayout(fstest.MapFS{
"Artist/Album/01 - Song.mp3": trackFile(1, "Song"),
"Artist/Album/cover.png": realPNG("blurhash-album"),
})
scan()
al := firstAlbum()
Expect(al.BlurHash).To(BeEmpty())
readArtwork(al.CoverArtID())
updated := storedAlbum(al.ID)
Expect(len(updated.BlurHash)).To(BeNumerically(">", 6))
Expect(updated.BlurHashUpdatedAt).ToNot(BeNil())
// The snapshot must not be before the artwork version, or the DTO would treat it as
// stale (it may exceed it: image file mtimes are folded in).
Expect(updated.BlurHashUpdatedAt.Before(updated.ArtworkUpdatedAt())).To(BeFalse())
})
It("does not persist a future-dated blurhash timestamp", func() {
cover := realPNG("future-cover")
cover.ModTime = time.Now().Add(500 * time.Hour) // clock skew / future-stamped file
setLayout(fstest.MapFS{
"Artist/Album/01 - Song.mp3": trackFile(1, "Song"),
"Artist/Album/cover.png": cover,
})
scan()
al := firstAlbum()
readArtwork(al.CoverArtID())
updated := storedAlbum(al.ID)
Expect(updated.BlurHash).ToNot(BeEmpty())
Expect(updated.BlurHashUpdatedAt).ToNot(BeNil())
// A future file mtime must be capped at now, or the !Before checks would pin the hash
// (and the client's cover cache) until wall time caught up.
Expect(updated.BlurHashUpdatedAt.After(time.Now())).To(BeFalse())
// The scanner caps the folder's images_updated_at too, so the artwork version is not future
// and the freshly computed hash is accepted by the DTO instead of the fake.
Expect(updated.BlurHashUpdatedAt.Before(updated.ArtworkUpdatedAt())).To(BeFalse())
})
It("recomputes when the cover is swapped in place", func() {
setLayout(fstest.MapFS{
"Artist/Album/01 - Song.mp3": trackFile(1, "Song"),
"Artist/Album/cover.png": realPNG("original-cover"),
})
scan()
al := firstAlbum()
readArtwork(al.CoverArtID())
firstHash := storedAlbum(al.ID).BlurHash
Expect(firstHash).ToNot(BeEmpty())
// Swap the cover bytes and rescan, then serve: the tee hashes the newly-served bytes, so the
// stored hash moves to describe the new cover.
setLayout(fstest.MapFS{
"Artist/Album/01 - Song.mp3": trackFile(1, "Song"),
"Artist/Album/cover.png": realPNG("swapped-cover"),
})
scan()
readArtwork(al.CoverArtID())
updated := storedAlbum(al.ID)
Expect(updated.BlurHash).ToNot(BeEmpty())
Expect(updated.BlurHash).ToNot(Equal(firstHash))
})
It("clears the stored blurhash when the cover disappears", func() {
setLayout(fstest.MapFS{
"Artist/Album/01 - Song.mp3": trackFile(1, "Song"),
"Artist/Album/cover.png": realPNG("vanishing-cover"),
})
scan()
al := firstAlbum()
readArtwork(al.CoverArtID())
Expect(storedAlbum(al.ID).BlurHash).ToNot(BeEmpty())
// No rescan: the folder row still lists the cover, but the file is gone. The serve falls back
// to the placeholder (GetOrPlaceholder, the real Jellyfin/Subsonic path), which clears the
// stored hash inline.
setLayout(fstest.MapFS{
"Artist/Album/01 - Song.mp3": trackFile(1, "Song"),
})
Expect(readOrPlaceholder(al.CoverArtID())).To(Equal(placeholderBytes()))
Expect(storedAlbum(al.ID).BlurHash).To(BeEmpty())
})
It("recomputes when cover bytes change under a preserved mtime (cache disabled)", func() {
cover := realPNG("orig-bytes")
fixed := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
cover.ModTime = fixed
setLayout(fstest.MapFS{
"Artist/Album/01 - Song.mp3": trackFile(1, "Song"),
"Artist/Album/cover.png": cover,
})
scan()
al := firstAlbum()
readArtwork(al.CoverArtID())
firstHash := storedAlbum(al.ID).BlurHash
Expect(firstHash).ToNot(BeEmpty())
// Replace the bytes but keep the SAME mtime and do NOT rescan: only the served bytes change.
swapped := realPNG("swapped-bytes")
swapped.ModTime = fixed
setLayout(fstest.MapFS{
"Artist/Album/01 - Song.mp3": trackFile(1, "Song"),
"Artist/Album/cover.png": swapped,
})
readArtwork(al.CoverArtID())
Expect(storedAlbum(al.ID).BlurHash).ToNot(Equal(firstHash))
})
It("advances the album artwork version when only the cover file changes (quick scan)", func() {
setLayout(fstest.MapFS{
"Artist/Album/01 - Song.mp3": trackFile(1, "Song"),
"Artist/Album/cover.png": realPNG("p1-orig"),
})
scan()
al := firstAlbum()
readArtwork(al.CoverArtID())
first := storedAlbum(al.ID)
Expect(first.BlurHash).ToNot(BeEmpty())
Expect(first.BlurHashUpdatedAt.Before(first.ArtworkUpdatedAt())).To(BeFalse())
// Replace only the cover and quick-scan: the album row stays untouched while the folder's
// images_updated_at advances the artwork version, so hash-keyed clients refetch.
time.Sleep(50 * time.Millisecond) // Windows clock granularity: the swap must be measurably later
fakeFS.Add("Artist/Album/cover.png", realPNG("p1-swapped"), time.Now())
quickScan()
stale := storedAlbum(al.ID)
Expect(stale.UpdatedAt).To(Equal(first.UpdatedAt), "premise: image-only change must not touch the album row")
Expect(stale.BlurHash).To(Equal(first.BlurHash))
Expect(stale.BlurHashUpdatedAt.Before(stale.ArtworkUpdatedAt())).To(BeTrue(), "stored hash must read as stale")
// The refetch serves the new bytes; the tee rotates the hash and its version catches up.
readArtwork(al.CoverArtID())
fresh := storedAlbum(al.ID)
Expect(fresh.BlurHash).ToNot(Equal(first.BlurHash))
Expect(fresh.BlurHashUpdatedAt.Before(fresh.ArtworkUpdatedAt())).To(BeFalse())
})
It("clears a stored playlist hash when it falls back to the placeholder", func() {
// A playlist with a sidecar cover gets a real hash; removing the sidecar makes the reader chain
// fall through to fromAlbumPlaceholder(), whose bytes flow through the tee on Get and clear it.
dir := GinkgoT().TempDir()
m3uPath := filepath.Join(dir, "MyList.m3u")
Expect(os.WriteFile(m3uPath, []byte("#EXTM3U\n"), 0600)).To(Succeed())
sidecar := filepath.Join(dir, "MyList.png")
Expect(os.WriteFile(sidecar, realPNG("pl-cover").Data, 0600)).To(Succeed())
pl := putPlaylist(model.Playlist{ID: "pl-blur", Name: "MyList", Path: m3uPath})
readArtwork(pl.CoverArtID())
stored, err := ds.Playlist(ctx).Get(pl.ID)
Expect(err).ToNot(HaveOccurred())
Expect(stored.BlurHash).ToNot(BeEmpty())
// Remove the sidecar: the serve now falls through to the placeholder, captured by the tee.
Expect(os.Remove(sidecar)).To(Succeed())
Expect(readArtwork(pl.CoverArtID())).To(Equal(placeholderBytes()))
stored, err = ds.Playlist(ctx).Get(pl.ID)
Expect(err).ToNot(HaveOccurred())
Expect(stored.BlurHash).To(BeEmpty())
})
It("does not persist a blurhash when the served image cannot be decoded", func() {
setLayout(fstest.MapFS{
"Artist/Album/01 - Song.mp3": trackFile(1, "Song"),
"Artist/Album/cover.png": imageFile("not-a-real-image"),
})
scan()
al := firstAlbum()
readArtwork(al.CoverArtID())
Expect(storedAlbum(al.ID).BlurHash).To(BeEmpty())
})
})

View file

@ -158,6 +158,18 @@ func readArtworkOrErr(artID model.ArtworkID) ([]byte, error) {
return io.ReadAll(r)
}
// readOrPlaceholder serves through GetOrPlaceholder — the path real Jellyfin/Subsonic handlers use —
// so a vanished album/artist cover falls back to the placeholder, whose bytes drive the blurhash clear.
func readOrPlaceholder(artID model.ArtworkID) []byte {
GinkgoHelper()
r, _, err := aw.GetOrPlaceholder(ctx, artID.String(), 0, false)
Expect(err).ToNot(HaveOccurred())
defer r.Close()
b, err := io.ReadAll(r)
Expect(err).ToNot(HaveOccurred())
return b
}
// noopProvider implements external.Provider with not-found returns so the
// "external" priority entry never produces a result.
type noopProvider struct{}

View file

@ -91,10 +91,22 @@ func setupHarness() {
}
func scan() {
GinkgoHelper()
doScan(true)
}
// quickScan runs a non-full scan: only outdated folders are processed and unchanged audio files are
// not reimported, so an image-only change reaches the folder row without touching the album row.
func quickScan() {
GinkgoHelper()
doScan(false)
}
func doScan(full bool) {
GinkgoHelper()
s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance())
_, err := s.ScanAll(ctx, true)
_, err := s.ScanAll(ctx, full)
Expect(err).ToNot(HaveOccurred())
}

View file

@ -66,8 +66,12 @@ func resizedFromOriginal(ctx context.Context, a *artwork, artID model.ArtworkID,
return r, nil
}
// resizedKeyVersion invalidates resized entries cached by pre-blurhash versions: the refill is what
// pulls the original through the tee, so warm entries would otherwise never backfill a hash.
const resizedKeyVersion = "v1"
func (a *resizedArtworkReader) Key() string {
baseKey := fmt.Sprintf("%s.%d", a.cacheKey, a.size)
baseKey := fmt.Sprintf("%s.%d.%s", a.cacheKey, a.size, resizedKeyVersion)
if a.square {
return baseKey + ".square"
}

View file

@ -6,12 +6,28 @@ import (
"errors"
"io"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("resizedArtworkReader.Key", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.CoverArtQuality = 75
})
It("includes the cache version so pre-blurhash resized entries are invalidated", func() {
r := &resizedArtworkReader{cacheKey: "al-1.123", size: 300}
Expect(r.Key()).To(Equal("al-1.123.300.v1.75"))
r.square = true
Expect(r.Key()).To(Equal("al-1.123.300.v1.square"))
})
})
var _ = Describe("resizeImage", func() {
var mockFF *tests.MockFFmpeg
var r *resizedArtworkReader

View file

@ -0,0 +1,48 @@
package artwork
import (
"bytes"
"io"
)
// teeReader mirrors bytes read from src into buf, and on Close invokes onComplete with the captured
// bytes only if the stream was fully consumed (EOF) and stayed within maxBytes. Partial reads and
// oversized streams are skipped, so the callback only ever receives a complete, bounded payload.
type teeReader struct {
src io.ReadCloser
buf bytes.Buffer
maxBytes int
onComplete func(data []byte)
eof bool
over bool
}
func newTeeReader(src io.ReadCloser, maxBytes int, onComplete func(data []byte)) *teeReader {
return &teeReader{src: src, maxBytes: maxBytes, onComplete: onComplete}
}
func (t *teeReader) Read(p []byte) (int, error) {
n, err := t.src.Read(p)
if n > 0 && !t.over {
if t.buf.Len()+n > t.maxBytes {
t.over = true
t.buf.Reset()
} else {
t.buf.Write(p[:n])
}
}
if err == io.EOF {
t.eof = true
}
return n, err
}
func (t *teeReader) Close() error {
err := t.src.Close()
if t.eof && !t.over && t.onComplete != nil {
cb := t.onComplete
t.onComplete = nil // fire at most once, even on double Close
cb(t.buf.Bytes())
}
return err
}

View file

@ -0,0 +1,59 @@
package artwork
import (
"bytes"
"io"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
type closeSpy struct {
io.Reader
closed bool
}
func (c *closeSpy) Close() error { c.closed = true; return nil }
var _ = Describe("teeReader", func() {
It("closes the underlying source exactly once", func() {
src := &closeSpy{Reader: bytes.NewReader([]byte("hello"))}
tr := newTeeReader(src, 1024, func([]byte) {})
_, err := io.ReadAll(tr)
Expect(err).ToNot(HaveOccurred())
Expect(tr.Close()).To(Succeed())
Expect(src.closed).To(BeTrue(), "the source stream must be closed, or its fd leaks")
})
It("calls onComplete with the full bytes after a complete read+close", func() {
var got []byte
src := io.NopCloser(bytes.NewReader([]byte("hello world")))
tr := newTeeReader(src, 1024, func(data []byte) { got = data })
out, err := io.ReadAll(tr)
Expect(err).ToNot(HaveOccurred())
Expect(string(out)).To(Equal("hello world"))
Expect(tr.Close()).To(Succeed())
Expect(string(got)).To(Equal("hello world"))
})
It("does not call onComplete when the stream is not fully read", func() {
called := false
src := io.NopCloser(bytes.NewReader([]byte("hello world")))
tr := newTeeReader(src, 1024, func(data []byte) { called = true })
buf := make([]byte, 3)
_, err := tr.Read(buf) // partial read, then close without EOF
Expect(err).ToNot(HaveOccurred())
Expect(tr.Close()).To(Succeed())
Expect(called).To(BeFalse())
})
It("does not call onComplete when the data exceeds maxBytes", func() {
called := false
src := io.NopCloser(bytes.NewReader([]byte("hello world")))
tr := newTeeReader(src, 4, func(data []byte) { called = true })
_, err := io.ReadAll(tr)
Expect(err).ToNot(HaveOccurred())
Expect(tr.Close()).To(Succeed())
Expect(called).To(BeFalse())
})
})

View file

@ -0,0 +1,16 @@
-- +goose Up
-- blur_hash is not null default '' so NULLs never reach the Go string field; '' means "not computed".
alter table album add column blur_hash varchar not null default '';
alter table album add column blur_hash_updated_at datetime;
alter table artist add column blur_hash varchar not null default '';
alter table artist add column blur_hash_updated_at datetime;
alter table playlist add column blur_hash varchar not null default '';
alter table playlist add column blur_hash_updated_at datetime;
-- +goose Down
alter table album drop column blur_hash;
alter table album drop column blur_hash_updated_at;
alter table artist drop column blur_hash;
alter table artist drop column blur_hash_updated_at;
alter table playlist drop column blur_hash;
alter table playlist drop column blur_hash_updated_at;

View file

@ -67,12 +67,35 @@ type Album struct {
ImportedAt time.Time `structs:"imported_at" json:"importedAt" hash:"ignore"` // When this album was imported/updated
CreatedAt time.Time `structs:"created_at" json:"createdAt"` // Oldest CreatedAt for all songs in this album
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` // Newest UpdatedAt for all songs in this album
// BlurHash of the album cover, computed from the served artwork bytes. Excluded from
// full-row writes (structs:"-"): only UpdateBlurHash writes it, so scans can't erase it.
BlurHash string `structs:"-" json:"blurHash,omitempty" hash:"ignore"`
BlurHashUpdatedAt *time.Time `structs:"-" json:"-" hash:"ignore"`
// FolderImagesUpdatedAt is the newest images_updated_at among the album's folders (selected, not
// persisted): an in-place cover-file swap moves it even though the album row stays untouched.
FolderImagesUpdatedAt *time.Time `structs:"-" json:"-" hash:"ignore"`
}
func (a Album) CoverArtID() ArtworkID {
return artworkIDFromAlbum(a)
}
// ArtworkUpdatedAt is the album's artwork version. ExternalInfoUpdatedAt is deliberately excluded:
// it bumps on every agent TTL refresh even when the image is unchanged, and actual image changes
// are caught by hashing the served bytes instead.
func (a Album) ArtworkUpdatedAt() time.Time {
t := a.UpdatedAt
if a.ImportedAt.After(t) {
t = a.ImportedAt
}
if a.FolderImagesUpdatedAt != nil && a.FolderImagesUpdatedAt.After(t) {
t = *a.FolderImagesUpdatedAt
}
return t
}
func (a Album) FullName() string {
if conf.Server.Subsonic.AppendAlbumVersion && len(a.Tags[TagAlbumVersion]) > 0 {
return fmt.Sprintf("%s (%s)", a.Name, a.Tags[TagAlbumVersion][0])
@ -139,6 +162,7 @@ type AlbumRepository interface {
Exists(id string) (bool, error)
Put(*Album) error
UpdateExternalInfo(*Album) error
UpdateBlurHash(id string, blurHash string, artworkUpdatedAt time.Time) error
Get(id string) (*Album, error)
GetAll(...QueryOptions) (Albums, error)
GetCursor(...QueryOptions) (AlbumCursor, error)

View file

@ -2,6 +2,7 @@ package model_test
import (
"encoding/json"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
@ -50,3 +51,30 @@ var _ = Describe("Albums", func() {
})
})
})
var _ = Describe("Album.ArtworkUpdatedAt", func() {
base := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
later := base.Add(24 * time.Hour)
latest := base.Add(48 * time.Hour)
It("returns UpdatedAt when it is the newest", func() {
al := Album{UpdatedAt: later, ImportedAt: base}
Expect(al.ArtworkUpdatedAt()).To(Equal(later))
})
It("returns ImportedAt when it is the newest", func() {
al := Album{UpdatedAt: base, ImportedAt: later}
Expect(al.ArtworkUpdatedAt()).To(Equal(later))
})
It("ignores ExternalInfoUpdatedAt (agent TTL refreshes bump it without an image change)", func() {
al := Album{UpdatedAt: base, ImportedAt: later, ExternalInfoUpdatedAt: &latest}
Expect(al.ArtworkUpdatedAt()).To(Equal(later))
})
It("returns FolderImagesUpdatedAt when it is the newest (in-place cover swap)", func() {
al := Album{UpdatedAt: base, ImportedAt: later, FolderImagesUpdatedAt: &latest}
Expect(al.ArtworkUpdatedAt()).To(Equal(latest))
})
It("ignores an older FolderImagesUpdatedAt", func() {
al := Album{UpdatedAt: later, ImportedAt: base, FolderImagesUpdatedAt: &base}
Expect(al.ArtworkUpdatedAt()).To(Equal(later))
})
})

View file

@ -41,6 +41,9 @@ type Artist struct {
CreatedAt *time.Time `structs:"created_at" json:"createdAt,omitempty"`
UpdatedAt *time.Time `structs:"updated_at" json:"updatedAt,omitempty"`
BlurHash string `structs:"-" json:"blurHash,omitempty" hash:"ignore"`
BlurHashUpdatedAt *time.Time `structs:"-" json:"-" hash:"ignore"`
}
type ArtistStats struct {
@ -63,6 +66,16 @@ func (a Artist) CoverArtID() ArtworkID {
return artworkIDFromArtist(a)
}
// ArtworkUpdatedAt is the artist's artwork version. ExternalInfoUpdatedAt is deliberately
// excluded: it bumps on every agent TTL refresh even when the image is unchanged, and actual
// image changes are caught by hashing the served bytes instead.
func (a Artist) ArtworkUpdatedAt() time.Time {
if a.UpdatedAt == nil {
return time.Time{}
}
return *a.UpdatedAt
}
func (a Artist) UploadedImagePath() string {
return UploadedImagePath(consts.EntityArtist, a.UploadedImage)
}
@ -87,6 +100,7 @@ type ArtistRepository interface {
Exists(id string) (bool, error)
Put(m *Artist, colsToUpdate ...string) error
UpdateExternalInfo(a *Artist) error
UpdateBlurHash(id string, blurHash string, artworkUpdatedAt time.Time) error
Get(id string) (*Artist, error)
GetAll(options ...QueryOptions) (Artists, error)
GetCursor(options ...QueryOptions) (ArtistCursor, error)

View file

@ -2,6 +2,7 @@ package model_test
import (
"path/filepath"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
@ -28,3 +29,18 @@ var _ = Describe("Artist", func() {
})
})
})
var _ = Describe("Artist.ArtworkUpdatedAt", func() {
base := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
later := base.Add(24 * time.Hour)
It("handles nil timestamps", func() {
Expect(model.Artist{}.ArtworkUpdatedAt()).To(Equal(time.Time{}))
})
It("returns UpdatedAt", func() {
Expect(model.Artist{UpdatedAt: &later, ExternalInfoUpdatedAt: &base}.ArtworkUpdatedAt()).To(Equal(later))
})
It("ignores ExternalInfoUpdatedAt (agent TTL refreshes bump it without an image change)", func() {
Expect(model.Artist{UpdatedAt: &base, ExternalInfoUpdatedAt: &later}.ArtworkUpdatedAt()).To(Equal(base))
})
})

View file

@ -31,6 +31,9 @@ type Playlist struct {
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"`
BlurHash string `structs:"-" json:"blurHash,omitempty" hash:"ignore"`
BlurHashUpdatedAt *time.Time `structs:"-" json:"-" hash:"ignore"`
// SmartPlaylist attributes
Rules *criteria.Criteria `structs:"rules" json:"rules"`
EvaluatedAt *time.Time `structs:"evaluated_at" json:"evaluatedAt"`
@ -40,6 +43,10 @@ func (pls Playlist) IsSmartPlaylist() bool {
return pls.Rules != nil && pls.Rules.Expression != nil
}
func (pls Playlist) ArtworkUpdatedAt() time.Time {
return pls.UpdatedAt
}
// RefreshDelay returns the playlist's own refresh window when set, falling
// back to the global SmartPlaylistRefreshDelay.
func (pls Playlist) RefreshDelay() time.Duration {
@ -145,6 +152,7 @@ type PlaylistRepository interface {
GetAll(options ...QueryOptions) (Playlists, error)
GetCursor(options ...QueryOptions) (PlaylistCursor, error)
FindByPath(path string) (*Playlist, error)
UpdateBlurHash(id string, blurHash string, artworkUpdatedAt time.Time) error
Delete(id string) error
Tracks(playlistId string, refreshSmartPlaylist bool) PlaylistTrackRepository
GetPlaylists(mediaFileId string) (Playlists, error)

View file

@ -74,3 +74,10 @@ var _ = Describe("Playlist", func() {
})
})
})
var _ = Describe("Playlist.ArtworkUpdatedAt", func() {
It("returns UpdatedAt", func() {
now := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
Expect(model.Playlist{UpdatedAt: now}.ArtworkUpdatedAt()).To(Equal(now))
})
})

View file

@ -213,6 +213,10 @@ func (r *albumRepository) Put(al *model.Album) error {
return nil
}
func (r *albumRepository) UpdateBlurHash(id, blurHash string, artworkUpdatedAt time.Time) error {
return r.updateBlurHash(id, blurHash, artworkUpdatedAt)
}
// TODO Move external metadata to a separated table
func (r *albumRepository) UpdateExternalInfo(al *model.Album) error {
_, err := r.put(al.ID, &dbAlbum{Album: al}, "description", "small_image_url", "medium_image_url", "large_image_url", "external_url", "external_info_updated_at")
@ -220,7 +224,22 @@ func (r *albumRepository) UpdateExternalInfo(al *model.Album) error {
}
func (r *albumRepository) selectAlbum(options ...model.QueryOptions) SelectBuilder {
sql := r.newSelect(options...).Columns("album.*", "library.path as library_path", "library.name as library_name").
sql := r.newSelect(options...).Columns("album.*", "library.path as library_path", "library.name as library_name",
// Folds folder image mtimes into the artwork version: an in-place cover swap moves them without
// touching the album row. The parent counts only when albumRootParent could serve it: single
// common parent, not the library root, not an album folder, and disc subfolders or an imageless
// folder. The subtree-audio gate is deliberately unmirrored (a per-row LIKE scan): it can only
// suppress a hash briefly, healed on the next serve. Bare column keeps the datetime decltype.
"(select f.images_updated_at from folder f where f.id in"+
" (select je.value from json_each(album.folder_ids) je"+
" union select pf.id from json_each(album.folder_ids) je2"+
" join folder p on p.id = je2.value join folder pf on pf.id = p.parent_id"+
" where pf.parent_id <> ''"+
" and (json_array_length(album.folder_ids) > 1 or json_array_length(p.image_files) = 0)"+
" and pf.id not in (select je3.value from json_each(album.folder_ids) je3)"+
" and (select count(distinct p2.parent_id) from folder p2, json_each(album.folder_ids) je4"+
" where p2.id = je4.value) = 1)"+
" order by f.images_updated_at desc limit 1) as folder_images_updated_at").
LeftJoin("library on album.library_id = library.id")
sql = r.withAnnotation(sql, "album.id")
return r.applyLibraryFilter(sql)

View file

@ -899,3 +899,141 @@ func _p(id, name string, sortName ...string) model.Participant {
}
return p
}
var _ = Describe("AlbumRepository folder images version", func() {
var repo model.AlbumRepository
BeforeEach(func() {
ctx := request.WithUser(GinkgoT().Context(), model.User{ID: "userid", UserName: "johndoe"})
repo = NewAlbumRepository(ctx, GetDBXBuilder())
var origFolderIDs string
Expect(GetDBXBuilder().NewQuery("select folder_ids from album where id = '103'").
Row(&origFolderIDs)).To(Succeed())
DeferCleanup(func() {
_, err := GetDBXBuilder().NewQuery("delete from folder where id like 'fold-blur-%'").Execute()
Expect(err).ToNot(HaveOccurred())
_, err = GetDBXBuilder().NewQuery("update album set folder_ids = {:f} where id = '103'").
Bind(map[string]any{"f": origFolderIDs}).Execute()
Expect(err).ToNot(HaveOccurred())
})
})
It("surfaces the newest folder images_updated_at on the selected album", func() {
// Newer than any fixture row timestamp, so it must win as the artwork version.
imagesAt := time.Date(2030, 6, 1, 12, 0, 0, 0, time.UTC)
_, err := GetDBXBuilder().NewQuery(
"insert into folder (id, library_id, path, name, images_updated_at) values ('fold-blur-1', 1, '.', 'Radioactivity', {:t})").
Bind(map[string]any{"t": imagesAt}).Execute()
Expect(err).ToNot(HaveOccurred())
_, err = GetDBXBuilder().NewQuery(`update album set folder_ids = '["fold-blur-1"]' where id = '103'`).Execute()
Expect(err).ToNot(HaveOccurred())
al, err := repo.Get("103")
Expect(err).ToNot(HaveOccurred())
Expect(al.FolderImagesUpdatedAt).ToNot(BeNil())
Expect(al.FolderImagesUpdatedAt.Equal(imagesAt)).To(BeTrue())
Expect(al.ArtworkUpdatedAt().Equal(imagesAt)).To(BeTrue(), "folder image changes must advance the artwork version")
})
It("includes the parent folder's images (album-root cover with disc subfolders)", func() {
discAt := time.Date(2030, 6, 1, 12, 0, 0, 0, time.UTC)
rootAt := discAt.Add(time.Hour) // the root cover is the newest image
// The album root sits under an artist folder (non-empty parent_id): the library root never counts.
_, err := GetDBXBuilder().NewQuery(
"insert into folder (id, library_id, path, name, parent_id, images_updated_at) values" +
" ('fold-blur-root', 1, './Artist', 'Album', 'fold-blur-artist', {:root})," +
" ('fold-blur-1', 1, './Artist/Album', 'CD1', 'fold-blur-root', {:disc})").
Bind(map[string]any{"root": rootAt, "disc": discAt}).Execute()
Expect(err).ToNot(HaveOccurred())
_, err = GetDBXBuilder().NewQuery(`update album set folder_ids = '["fold-blur-1"]' where id = '103'`).Execute()
Expect(err).ToNot(HaveOccurred())
al, err := repo.Get("103")
Expect(err).ToNot(HaveOccurred())
Expect(al.FolderImagesUpdatedAt).ToNot(BeNil())
Expect(al.FolderImagesUpdatedAt.Equal(rootAt)).To(BeTrue(), "the parent folder's newer cover must win")
})
It("ignores parents when the album's folders do not share a single one (mixed parents)", func() {
// A compilation spread across artist folders has no album root; folding every artist's images
// would suppress the album's hash on any unrelated artist-image change.
at := time.Date(2030, 6, 1, 12, 0, 0, 0, time.UTC)
_, err := GetDBXBuilder().NewQuery(
"insert into folder (id, library_id, path, name, parent_id, images_updated_at) values" +
" ('fold-blur-root', 1, '.', 'ArtistA', 'fold-blur-lib', {:parent})," +
" ('fold-blur-1', 1, './A', 'Songs', 'fold-blur-root', {:own})," +
" ('fold-blur-2', 1, './B', 'Songs', 'fold-blur-other', {:own})").
Bind(map[string]any{"parent": at.Add(time.Hour), "own": at}).Execute()
Expect(err).ToNot(HaveOccurred())
_, err = GetDBXBuilder().NewQuery(`update album set folder_ids = '["fold-blur-1","fold-blur-2"]' where id = '103'`).Execute()
Expect(err).ToNot(HaveOccurred())
al, err := repo.Get("103")
Expect(err).ToNot(HaveOccurred())
Expect(al.FolderImagesUpdatedAt).To(HaveValue(Equal(at)), "only the albums' own folders must count")
})
It("ignores the parent when the album's single folder has images of its own", func() {
// Mirrors albumRootParent's first gate: the reader would serve the folder's own cover, so an
// unrelated artist-level image must not advance (and suppress) this album's version.
ownAt := time.Date(2030, 6, 1, 12, 0, 0, 0, time.UTC)
parentAt := ownAt.Add(time.Hour)
_, err := GetDBXBuilder().NewQuery(
"insert into folder (id, library_id, path, name, parent_id, images_updated_at, image_files) values" +
" ('fold-blur-root', 1, '.', 'Artist', '', {:parent}, '[\"artist.jpg\"]')," +
" ('fold-blur-1', 1, './Artist', 'Album', 'fold-blur-root', {:own}, '[\"cover.jpg\"]')").
Bind(map[string]any{"parent": parentAt, "own": ownAt}).Execute()
Expect(err).ToNot(HaveOccurred())
_, err = GetDBXBuilder().NewQuery(`update album set folder_ids = '["fold-blur-1"]' where id = '103'`).Execute()
Expect(err).ToNot(HaveOccurred())
al, err := repo.Get("103")
Expect(err).ToNot(HaveOccurred())
Expect(al.FolderImagesUpdatedAt).To(HaveValue(Equal(ownAt)))
})
It("leaves FolderImagesUpdatedAt nil when the album has no folders", func() {
al, err := repo.Get("101")
Expect(err).ToNot(HaveOccurred())
Expect(al.FolderImagesUpdatedAt).To(BeNil())
})
})
var _ = Describe("AlbumRepository.UpdateBlurHash", func() {
var repo model.AlbumRepository
BeforeEach(func() {
ctx := request.WithUser(GinkgoT().Context(), model.User{ID: "userid", UserName: "johndoe"})
repo = NewAlbumRepository(ctx, GetDBXBuilder())
DeferCleanup(func() {
_, err := GetDBXBuilder().NewQuery("update album set blur_hash = '', blur_hash_updated_at = null").Execute()
Expect(err).ToNot(HaveOccurred())
})
})
It("persists the hash and its artwork version snapshot", func() {
al, err := repo.Get("103")
Expect(err).ToNot(HaveOccurred())
Expect(al.BlurHash).To(BeEmpty())
version := time.Date(2024, 5, 1, 10, 30, 0, 0, time.UTC)
Expect(repo.UpdateBlurHash(al.ID, "LKO2?U%2Tw=w]~RBVZRi};RPxuwH", version)).To(Succeed())
updated, err := repo.Get(al.ID)
Expect(err).ToNot(HaveOccurred())
Expect(updated.BlurHash).To(Equal("LKO2?U%2Tw=w]~RBVZRi};RPxuwH"))
Expect(updated.BlurHashUpdatedAt).ToNot(BeNil())
// Round-trip through SQLite must preserve equality — the DTO layer compares with Equal.
Expect(updated.BlurHashUpdatedAt.Equal(version)).To(BeTrue())
// The targeted update must not touch the row's own timestamps.
Expect(updated.UpdatedAt).To(Equal(al.UpdatedAt))
// A full-row Put (e.g. a scanner refresh with empty BlurHash fields) must preserve the hash.
updated.BlurHash = ""
updated.BlurHashUpdatedAt = nil
Expect(repo.Put(updated)).To(Succeed())
after, err := repo.Get(al.ID)
Expect(err).ToNot(HaveOccurred())
Expect(after.BlurHash).To(Equal("LKO2?U%2Tw=w]~RBVZRi};RPxuwH"))
})
})

View file

@ -232,6 +232,10 @@ func (r *artistRepository) Put(a *model.Artist, colsToUpdate ...string) error {
return err
}
func (r *artistRepository) UpdateBlurHash(id, blurHash string, artworkUpdatedAt time.Time) error {
return r.updateBlurHash(id, blurHash, artworkUpdatedAt)
}
func (r *artistRepository) UpdateExternalInfo(a *model.Artist) error {
dba := &dbArtist{Artist: a}
_, err := r.put(a.ID, dba,

View file

@ -155,6 +155,10 @@ func (r *playlistRepository) GetWithTracks(id string, refreshSmartPlaylist, incl
return pls, nil
}
func (r *playlistRepository) UpdateBlurHash(id, blurHash string, artworkUpdatedAt time.Time) error {
return r.updateBlurHash(id, blurHash, artworkUpdatedAt)
}
func (r *playlistRepository) FindByPath(path string) (*model.Playlist, error) {
return r.findBy(Eq{"path": path})
}

View file

@ -293,6 +293,16 @@ func (r sqlRepository) resetSeededRandom(options []model.QueryOptions) {
}
}
// updateBlurHash is a targeted update: a full-row put would race with the scanner. Deliberately
// a plain UPDATE with no insert fallback — updating a just-deleted row must be a silent no-op.
func (r sqlRepository) updateBlurHash(id, blurHash string, artworkUpdatedAt time.Time) error {
upd := Update(r.tableName).Where(Eq{"id": id}).
Set("blur_hash", blurHash).
Set("blur_hash_updated_at", artworkUpdatedAt)
_, err := r.executeSQL(upd)
return err
}
func (r sqlRepository) executeSQL(sq Sqlizer) (int64, error) {
query, args, err := r.toSQL(sq)
if err != nil {

View file

@ -9,6 +9,7 @@ import (
"slices"
"sort"
"strings"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/storage"
@ -160,7 +161,13 @@ func loadDir(ctx context.Context, job *scanJob, dirPath string, checker *IgnoreC
folder.numPlaylists++
case model.IsImageFile(name):
folder.imageFiles[entry.Name()] = entry
folder.imagesUpdatedAt = utils.TimeNewest(folder.imagesUpdatedAt, fileInfo.ModTime(), folder.modTime)
imagesAt := utils.TimeNewest(folder.imagesUpdatedAt, fileInfo.ModTime(), folder.modTime)
// Cap at now: a future-stamped image (clock skew) would otherwise become a future artwork
// version that pins the emitted blurhash to the fake until wall time caught up.
if now := time.Now(); imagesAt.After(now) {
imagesAt = now
}
folder.imagesUpdatedAt = imagesAt
}
}
}

View file

@ -1,36 +1,23 @@
package dto
import "hash/fnv"
import (
"time"
)
// base83Alphabet is the blurhash spec's base83 encoding alphabet; order is part of the spec.
const base83Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~"
// base83 encodes value as a fixed-width, big-endian base83 string of the given length.
func base83(value, length int) string {
b := make([]byte, length)
for i := 1; i <= length; i++ {
digit := (value / pow83(length-i)) % 83
b[i-1] = base83Alphabet[digit]
// primaryBlurHash returns the stored blurhash when current for the artwork version, else "" so the
// key is omitted (upstream behavior); clients treat it as cover identity, so absence beats a fake.
func primaryBlurHash(stored string, storedAt *time.Time, version time.Time) string {
if stored != "" && storedAt != nil && !storedAt.Before(version) {
return stored
}
return string(b)
return ""
}
func pow83(n int) int {
result := 1
for range n {
result *= 83
// primaryBlurHashes builds the ImageBlurHashes map for a known-current hash, or nil so the field is
// omitted entirely when there is none.
func primaryBlurHashes(tag, hash string) map[string]map[string]string {
if hash == "" {
return nil
}
return result
}
// blurHash returns a valid 6-char blurhash for a solid color derived from seed. Finamp only needs a
// well-formed, per-tag-stable value (it uses this as a download de-dup key and blur placeholder), so
// a solid color unique to the tag satisfies both without decoding cover art.
func blurHash(seed string) string {
h := fnv.New32a()
_, _ = h.Write([]byte(seed))
sum := h.Sum(nil)
r, g, b := int(sum[0]), int(sum[1]), int(sum[2])
dc := (r << 16) | (g << 8) | b
return "00" + base83(dc, 4)
return map[string]map[string]string{"Primary": {tag: hash}}
}

View file

@ -1,27 +1,41 @@
package dto
import (
"strings"
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("blurHash", func() {
It("returns a 6-char valid blurhash starting with the 1x1 component prefix", func() {
h := blurHash("x")
Expect(h).To(HaveLen(6))
Expect(h).To(HavePrefix("00"))
for _, c := range h {
Expect(strings.ContainsRune(base83Alphabet, c)).To(BeTrue(), "unexpected char %q", c)
}
var _ = Describe("primaryBlurHash", func() {
version := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
It("returns the stored hash when it matches the current artwork version", func() {
Expect(primaryBlurHash("LEHV6nWB2yk8", &version, version)).To(Equal("LEHV6nWB2yk8"))
})
It("is deterministic for the same seed", func() {
Expect(blurHash("cover-tag-1")).To(Equal(blurHash("cover-tag-1")))
It("returns the stored hash when the snapshot is newer than the version (image mtime)", func() {
newer := version.Add(time.Hour)
Expect(primaryBlurHash("LEHV6nWB2yk8", &newer, version)).To(Equal("LEHV6nWB2yk8"))
})
It("differs for different seeds", func() {
Expect(blurHash("cover-tag-1")).ToNot(Equal(blurHash("cover-tag-2")))
It("omits when there is no stored hash", func() {
Expect(primaryBlurHash("", nil, version)).To(BeEmpty())
})
It("omits when the stored hash is stale (cover changed, not yet re-served)", func() {
stale := version.Add(-time.Hour)
Expect(primaryBlurHash("LEHV6nWB2yk8", &stale, version)).To(BeEmpty())
})
})
var _ = Describe("primaryBlurHashes", func() {
It("wraps a hash under the Primary tag", func() {
Expect(primaryBlurHashes("tag-1", "LEHV6nWB2yk8")).To(
Equal(map[string]map[string]string{"Primary": {"tag-1": "LEHV6nWB2yk8"}}))
})
It("returns nil when there is no hash, so the field is omitted", func() {
Expect(primaryBlurHashes("tag-1", "")).To(BeNil())
})
})

View file

@ -179,10 +179,10 @@ func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto {
} else if mf.Genre != "" {
item.Genres = []string{mf.Genre}
}
// Finamp resolves song art via AlbumId + a non-empty AlbumPrimaryImageTag.
// Finamp resolves song art via AlbumId + a non-empty AlbumPrimaryImageTag. No blurhash for songs:
// there is no stored hash of their own, and clients cache covers by the value as identity.
if mf.AlbumID != "" {
item.AlbumPrimaryImageTag = mf.AlbumID
item.ImageBlurHashes = map[string]map[string]string{"Primary": {mf.AlbumID: blurHash(mf.AlbumID)}}
}
return item
}
@ -201,7 +201,7 @@ func AlbumToBaseItem(al model.Album) BaseItemDto {
RunTimeTicks: TicksFromSeconds(al.Duration),
DateCreated: jellyfinDate(&al.CreatedAt),
ImageTags: map[string]string{"Primary": al.ID},
ImageBlurHashes: map[string]map[string]string{"Primary": {al.ID: blurHash(al.ID)}},
ImageBlurHashes: primaryBlurHashes(al.ID, primaryBlurHash(al.BlurHash, al.BlurHashUpdatedAt, al.ArtworkUpdatedAt())),
BackdropImageTags: []string{},
UserData: UserData(al.Annotations, al.ID),
}
@ -231,7 +231,7 @@ func ArtistToBaseItem(ar model.Artist) BaseItemDto {
SongCount: new(ar.SongCount),
DateCreated: jellyfinDate(ar.CreatedAt),
ImageTags: map[string]string{"Primary": ar.ID},
ImageBlurHashes: map[string]map[string]string{"Primary": {ar.ID: blurHash(ar.ID)}},
ImageBlurHashes: primaryBlurHashes(ar.ID, primaryBlurHash(ar.BlurHash, ar.BlurHashUpdatedAt, ar.ArtworkUpdatedAt())),
BackdropImageTags: []string{},
UserData: UserData(ar.Annotations, ar.ID),
}
@ -264,7 +264,7 @@ func PlaylistToBaseItem(p model.Playlist) BaseItemDto {
ChildCount: new(p.SongCount),
RunTimeTicks: TicksFromSeconds(p.Duration),
ImageTags: map[string]string{"Primary": tag},
ImageBlurHashes: map[string]map[string]string{"Primary": {tag: blurHash(tag)}},
ImageBlurHashes: primaryBlurHashes(tag, primaryBlurHash(p.BlurHash, p.BlurHashUpdatedAt, p.ArtworkUpdatedAt())),
BackdropImageTags: []string{},
UserData: UserData(p.Annotations, p.ID),
}

View file

@ -33,8 +33,9 @@ var _ = Describe("mappers", func() {
Expect(item.UserData.Played).To(BeTrue())
Expect(item.UserData.Key).To(Equal(EncodeID("song-1")))
Expect(item.UserData.ItemId).To(Equal(EncodeID("song-1")))
Expect(item.ImageBlurHashes["Primary"]).To(HaveKey(item.AlbumPrimaryImageTag))
Expect(item.ImageBlurHashes["Primary"][item.AlbumPrimaryImageTag]).To(HaveLen(6))
Expect(item.AlbumPrimaryImageTag).To(Equal("alb-1"))
// Songs never carry a fabricated blurhash; clients cache covers by it as identity.
Expect(item.ImageBlurHashes).To(BeNil())
})
Describe("Fields gating (matches real Jellyfin)", func() {
@ -209,8 +210,8 @@ var _ = Describe("mappers", func() {
Expect(item.ArtistItems).To(Equal(item.AlbumArtists))
Expect(*item.ProductionYear).To(Equal(1999))
Expect(*item.ChildCount).To(Equal(10))
Expect(item.ImageBlurHashes["Primary"]).To(HaveKey(item.ImageTags["Primary"]))
Expect(item.ImageBlurHashes["Primary"][item.ImageTags["Primary"]]).To(HaveLen(6))
// No stored blurhash: the field is omitted, never fabricated.
Expect(item.ImageBlurHashes).To(BeNil())
})
It("maps an artist to a MusicArtist folder item", func() {
@ -283,19 +284,18 @@ var _ = Describe("mappers", func() {
Expect(*item.UserData.Rating).To(Equal(8.0))
tag := item.ImageTags["Primary"]
Expect(tag).ToNot(BeEmpty())
Expect(item.ImageBlurHashes["Primary"]).To(HaveKey(tag))
Expect(item.ImageBlurHashes["Primary"][tag]).To(HaveLen(6))
// No stored blurhash on this playlist: omitted, never fabricated.
Expect(item.ImageBlurHashes).To(BeNil())
})
It("changes the playlist image tag and blurhash when the playlist is updated (cover upload)", func() {
It("changes the playlist image tag when the playlist is updated (cover upload)", func() {
p := model.Playlist{ID: "pl-1", Name: "Chill", UpdatedAt: time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)}
before := PlaylistToBaseItem(p)
p.UpdatedAt = time.Date(2026, 7, 2, 0, 0, 0, 0, time.UTC)
after := PlaylistToBaseItem(p)
// Finamp caches covers keyed by blurHash, so tag and blurhash must change with the cover.
// The tag is the cover cache-buster: it must rotate when the playlist (cover) is updated.
Expect(after.ImageTags["Primary"]).ToNot(Equal(before.ImageTags["Primary"]))
Expect(after.ImageBlurHashes["Primary"]).ToNot(Equal(before.ImageBlurHashes["Primary"]))
})
It("keeps the playlist image tag stable when nothing changed", func() {
@ -390,3 +390,31 @@ var _ = Describe("LyricDtoFromLyrics", func() {
Expect(c.Start).To(Equal(int64(10_000_000)))
})
})
var _ = Describe("stored blurhashes", func() {
version := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
It("emits the stored album blurhash when fresh, and omits it when stale", func() {
al := model.Album{ID: "al-1", Name: "A", UpdatedAt: version, ImportedAt: version,
BlurHash: "LEHV6nWB2yk8", BlurHashUpdatedAt: &version}
Expect(AlbumToBaseItem(al).ImageBlurHashes["Primary"]["al-1"]).To(Equal("LEHV6nWB2yk8"))
al.UpdatedAt = version.Add(time.Hour) // artwork version moved; stored hash is now stale
Expect(AlbumToBaseItem(al).ImageBlurHashes).To(BeNil(),
"a stale hash must be suppressed, not emitted or replaced by a fake")
})
It("emits the stored artist blurhash when fresh", func() {
ar := model.Artist{ID: "ar-1", Name: "B", UpdatedAt: &version,
BlurHash: "LEHV6nWB2yk8", BlurHashUpdatedAt: &version}
Expect(ArtistToBaseItem(ar).ImageBlurHashes["Primary"]["ar-1"]).To(Equal("LEHV6nWB2yk8"))
})
It("emits the stored playlist blurhash when fresh, keyed by the versioned tag", func() {
p := model.Playlist{ID: "pl-1", Name: "P", UpdatedAt: version,
BlurHash: "LEHV6nWB2yk8", BlurHashUpdatedAt: &version}
item := PlaylistToBaseItem(p)
tag := item.ImageTags["Primary"]
Expect(item.ImageBlurHashes["Primary"][tag]).To(Equal("LEHV6nWB2yk8"))
})
})

View file

@ -217,7 +217,7 @@ var _ = Describe("Playlists", func() {
// Guards the whole chain: SetImage must go through a full Put (which bumps UpdatedAt), and the
// tag must be versioned by it, or clients keep their blurhash-keyed cover cache forever.
It("rotates the playlist's image tag and blurhash after a cover upload", func() {
It("rotates the playlist's image tag after a cover upload", func() {
plID := createPlaylist("Cover Tag", nil)
imageTag := func() string {
q := queryResult(get("/Items?ids=" + enc(plID)))
@ -233,8 +233,10 @@ var _ = Describe("Playlists", func() {
after := imageTag()
Expect(after).ToNot(Equal(before))
// The stored hash (if any) is stale for the new cover, so no blurhash is emitted — clients
// fall back to tag-keyed caching until the new cover is served and re-hashed.
q := queryResult(get("/Items?ids=" + enc(plID)))
Expect(q.Items[0].ImageBlurHashes["Primary"]).To(HaveKey(after))
Expect(q.Items[0].ImageBlurHashes).To(BeEmpty())
})
})

View file

@ -29,6 +29,17 @@ func (m *MockAlbumRepo) SetError(err bool) {
m.Err = err
}
func (m *MockAlbumRepo) UpdateBlurHash(id, blurHash string, artworkUpdatedAt time.Time) error {
if m.Err {
return errors.New("unexpected error")
}
if al, ok := m.Data[id]; ok {
al.BlurHash = blurHash
al.BlurHashUpdatedAt = &artworkUpdatedAt
}
return nil
}
func (m *MockAlbumRepo) SetData(albums model.Albums) {
m.Data = make(map[string]*model.Album, len(albums))
m.All = albums

View file

@ -32,6 +32,17 @@ func (m *MockArtistRepo) SetData(artists model.Artists) {
}
}
func (m *MockArtistRepo) UpdateBlurHash(id, blurHash string, artworkUpdatedAt time.Time) error {
if m.Err {
return errors.New("unexpected error")
}
if ar, ok := m.Data[id]; ok {
ar.BlurHash = blurHash
ar.BlurHashUpdatedAt = &artworkUpdatedAt
}
return nil
}
func (m *MockArtistRepo) Exists(id string) (bool, error) {
if m.Err {
return false, errors.New("Error!")

View file

@ -42,6 +42,17 @@ func (m *MockPlaylistRepo) SetData(playlists model.Playlists) {
}
}
func (m *MockPlaylistRepo) UpdateBlurHash(id, blurHash string, artworkUpdatedAt time.Time) error {
if m.Err {
return errors.New("unexpected error")
}
if pl, ok := m.Data[id]; ok {
pl.BlurHash = blurHash
pl.BlurHashUpdatedAt = &artworkUpdatedAt
}
return nil
}
func (m *MockPlaylistRepo) GetAll(options ...model.QueryOptions) (model.Playlists, error) {
if len(options) > 0 {
m.Options = options[0]