5071 Commits

Author SHA1 Message Date
Deluan
f74bf6484e test(persistence): scope the GetCursorWithArtwork full-stream spec to tie-free ids
The fixture has title ties (e.g. three "Antenna" tracks), so the unscoped
positional comparison against GetAll only passed because SQLite's tie order
happened to coincide between the full scan and the pre-pass's id IN (...)
fetch. Scope it to onlySongs like the sibling ordering specs already do.
2026-07-24 00:02:55 -04:00
Deluan
77e6f7fdc3 feat(jellyfin): emit a song's own cover art when it differs from the album's
Real Jellyfin fills ImageTags from each item's own images before falling back
to the parent album, and Finamp checks imageTags.Primary before AlbumId. Our
mapper read only the album's image, so a track with distinct embedded art
silently showed the album cover.

Emit exactly one entry under ImageBlurHashes.Primary: Go marshals
map[string]string in sorted key order rather than insertion order, so a
second entry could pair the wrong blurhash with the image imageId resolves
to, and Finamp pins that pairing in its cache for 365 days.
2026-07-23 23:53:09 -04:00
Deluan
8545cc4762 fix(jellyfin): hydrate artwork on the song cursor
Jellyfin's listSongs streamed media files via GetCursor, which never
hydrates artwork, so songs emitted entity-id image tags and no blurhash.

media_file now uses the same id pre-pass as the other three cursors
(album/artist/playlist), for consistency, but on a separate method,
GetCursorWithArtwork: GetCursor itself must stay untouched, since it's
also the scanner's hot path and the scanner never reads artwork.

Measured on 1,000,000 tracks, the pre-pass over all ids costs +41.8 MB
heap and +298 ms versus GetCursor's bounded +0.0 MB. The Jellyfin path
is paginated, though, so in practice it only ever pre-passes a page's
worth of ids, not the full library, and doesn't pay that cost.
2026-07-23 23:46:44 -04:00
Deluan
1cc0df80b1 fix(artwork): hydrate cursor streams via an id pre-pass
The album, artist and playlist GetCursor built their own select and never
called hydrateArtwork, so every Jellyfin list endpoint (all six stream via
GetCursor) emitted entity-id image tags and no blurhash. Only GetAll
hydrated, which is why Subsonic and the native API were unaffected.

Each cursor now resolves its ordered/filtered/paginated id set with the
cheap id-only GetAllIDs query, then streams those ids in chunks through the
repo's existing GetAll, which already hydrates and applies the full select.
Max/Offset are consumed by the pre-pass alone; the chunk query carries only
the caller's filters, Sort and Order.

This also removes a pre-existing deep-pagination cost: keeping OFFSET out of
the joined query makes the pre-pass a covering index scan instead of paying
the library and annotation joins for every skipped row. Benchmarked on a
synthetic 100k-album DB with the real schema, page=500 at offset 90,000:
3.9ms via the id pre-pass, 52.5ms for the current shape, 192.7ms for a naive
join. An unpaginated full stream costs ~24% more, which is the trade.

GetAllIDs gains the annotation join whenever the caller's filters or sort
reference an annotation column (same gate CountAll uses), otherwise
Filters=IsFavorite and SortBy=PlayCount would fail in the pre-pass. The
playlist pre-pass repeats GetAll's columns so ORDER BY keeps resolving to
playlist.name rather than the joined user.name.
2026-07-23 22:41:54 -04:00
Deluan
adadec9070 feat(ui): show the blurhash while an album cover loads 2026-07-23 21:02:29 -04:00
Deluan
2510b06c4d fix(ui): clear stale blurhash pixels and assert the draw path in tests
Clear the canvas before each decode attempt so a hash change that fails
to decode doesn't leave the previous frame's pixels on screen once this
wires into a list that recycles items. Also strengthen the specs to
assert createImageData/putImageData were actually invoked (and with
what), instead of only checking that a <canvas> element exists.
2026-07-23 20:57:15 -04:00
Deluan
6ae855256c feat(ui): add BlurHashCanvas placeholder component 2026-07-23 20:45:04 -04:00
Deluan
9f2b78ab2f feat(ui): version cover art urls by content hash and skip absent art 2026-07-23 20:36:21 -04:00
Deluan
dafcdf438f feat(jellyfin): emit real blurhashes and drop the synthesized fallback 2026-07-23 20:28:56 -04:00
Deluan
c1a22889c7 fix(jellyfin): trim primaryImageTag comment to why-only, within budget 2026-07-23 20:19:43 -04:00
Deluan
6f8e33d28a feat(jellyfin): version album and artist image tags by content hash 2026-07-23 20:14:40 -04:00
Deluan
49e8464db4 test(artwork): add hydrateArtwork regression guard for AlbumImage wiring
Drives hydrateArtwork itself (not applyItemImage directly) over tracks that
take each of the loop's continue branches, so a future edit moving the
AlbumImage fill below a continue would fail loudly instead of passing silently.
2026-07-23 20:07:45 -04:00
Deluan
f9aaff7d7f feat(artwork): hydrate the parent album's artwork state onto tracks 2026-07-23 20:01:04 -04:00
Deluan
9bb685a6af feat(nativeapi): expose artwork hash, absence and blurhash 2026-07-23 19:54:32 -04:00
Deluan
c66b40f415 feat(artwork): carry blurhash through item image hydration 2026-07-23 19:48:46 -04:00
Deluan
a998a329e6 test(artwork): use renamed ArtworkWorkerConcurrency in e2e tests 2026-07-23 14:09:39 -04:00
Deluan
42fd263cdf fix(artwork): clamp negative sizes to full-size; convert imghttp test to Ginkgo
- A negative size (Subsonic size / Jellyfin maxwidth accept signed ints) reached
  resizeStaticImage, where the square path builds image.NewNRGBA(Rect(0,0,size,size))
  — a giant rectangle that panics/OOMs. Clamp size<0 to 0 (full-size) at the Service
  entry. Positive sizes were already clamped to the original.
- imghttp used a plain func Test with a table; convert to a Ginkgo DescribeTable with
  the suite entry point in imghttp_suite_test.go (AGENTS.md test-framework requirement).
2026-07-23 14:08:08 -04:00
Deluan
f92507b152 fix(artwork): preserve the drive when normalizing Windows file:// library paths
url.Parse puts the volume of file://C:/Music in Host, not Path, so localOSRoot dropped
it and returned /Music — os.Open/os.Stat then failed and folder/embedded art on Windows
looped as dangling. Rejoin the host volume, matching core/storage/local's newLocalStorage.
2026-07-23 14:08:08 -04:00
Deluan
aba7ed925c fix(artwork): honor disabled per-track art at serve time; use nanosecond mtime provenance
Two serving-correctness fixes from review:
- serveMediaFile served a persisted mf embedded image even after EnableMediaFileCoverArt
  was turned off (the setting isn't in the config fingerprint, so found rows aren't
  reprocessed). Direct mf- URLs now honor the setting at serve time and fall back to
  disc/album art.
- The file-backed staleness check compared whole-second mtimes, so a same-second content
  replacement (two writes in one second, or timestamp-preserving tools) could serve
  different bytes under the old hash + immutable policy. RefMtime is now unix-nanoseconds
  (no schema change; int64 column), detecting sub-second changes where the filesystem
  records them.
2026-07-23 14:08:08 -04:00
Deluan
aa6c0b1f17 fix(artwork): enqueue new empty playlists by id, and refresh on absent outcomes
Two worker/enqueue fixes from review:
- playlistRepository.Put assigned the generated id to the caller's Playlist but passed
  the stale copy (empty id) to refreshCounters, enqueueing a pl|"" row the worker
  failed until the daily dangling purge while the real playlist went unresolved. Set
  the id on the copy before enqueueing.
- The drain refresh batch only included found/foundStale, so a cover removed by a scan
  (found -> absent) never notified clients, leaving the old immutable image displayed.
  Broadcast absent outcomes too; precache still only warms found/foundStale.
2026-07-23 14:08:08 -04:00
Deluan
e9c15d7fcb fix(artwork): don't stamp the album hash onto multi-disc tracks
The hydration fallback assigned a found album hash to every fallback track, but a
multi-disc track's CoverArtID emits a dc- id served from disc-specific art whose hash
is unknown at hydration time. Advertising dc-..._<albumHash> gave clients a content-
version that never changes when the disc image does, breaking id-based refresh. Only
stamp the album hash for single-disc tracks (DiscNumber == 0); multi-disc tracks stay
unhashed and rely on the correct ETag returned by the served response.
2026-07-23 14:08:08 -04:00
Deluan
3eaa21229d fix(artwork): version the artwork ETag with the served representation
The ETag was the pixel hash of the original image, so a CoverArtQuality or
EnableWebPEncoding change altered the resized bytes without changing the ETag —
revalidating clients got a spurious 304 and kept the old encoding. Resized responses
now carry a representation ETag (hash + size + square + encode settings) used for the
ETag header and If-None-Match, while the immutable decision stays on the pixel hash
(URLs remain pixel-identity per the spec, so hash-suffixed clients keep zero-request
caching). Full-size originals fall back to the pixel hash as before.
2026-07-23 14:08:08 -04:00
Deluan
9dd306eb10 fix(artwork): enforce entity visibility on the Subsonic getCoverArt path
serveEntity reads persisted item_artwork by id, bypassing the library and private-
playlist filters that the legacy entity-load applied. On the authenticated Subsonic
path a user could fetch artwork for an inaccessible album or someone else's private
playlist by guessing an id. getCoverArt now resolves the underlying entity through
the request-scoped (filtered) repositories and serves the placeholder when it is not
visible, so existence isn't leaked and the always-an-image invariant holds. The
public share (JWT-authorized) and Jellyfin (admin) paths are intentionally untouched.
2026-07-23 14:08:08 -04:00
Deluan
8d715ba2fa fix(artwork): restore synthetic-artist guard and unicode normalization in agent lookups
Moving agent calls into the worker bypassed two behaviors of the aggregate provider:
Agents.GetArtistImages' guard for Unknown/Various Artists (a direct retriever call
could assign an unrelated image to a synthetic artist), and auxAlbum/auxArtist.Name's
DevPreserveUnicodeInExternalCalls normalization (records with typographic quotes/dashes
missed exact-name searches). Re-apply both before enumerating retrievers.
2026-07-23 14:08:08 -04:00
Deluan
50ada9ad29 fix(artwork): invalidate artwork when an uploaded image is deleted
Deleting an artist/radio/playlist upload cleared the filename but left the found
item_artwork row and its hash, so lists kept advertising the deleted cover's
hash-suffixed immutable URL and clients could display it indefinitely. Call
EnqueueArtwork after the delete-side Put, symmetric with upload, so the state is
cleared and re-resolved to the next source (or absent).
2026-07-23 14:08:08 -04:00
Deluan
ed4178a6a9 fix(artwork): only use disc resolution for multi-disc albums
DiscCoverArtID returns a dc- id for any track with DiscNumber>0, so serveDisc ran
the full DiscArtPriority chain even for single-disc albums, where a stray disc*/
embedded image could shadow higher-priority album art. Gate disc resolution on the
album having more than one disc, matching the legacy reader; single-disc tracks
serve album art directly.
2026-07-23 14:08:08 -04:00
Deluan
ce06599288 fix(artwork): open library-backed artwork through its on-disk root
A library configured with a file:// path stored absRoot as the raw URI, so Abs
produced strings like file:/music/cover.jpg that os.Open/os.Stat reject — folder,
upload and embedded art were treated as dangling on every request, looping forever.
Normalize a file:// path to its parsed OS path (the same root os.DirFS uses);
non-local schemes are left unchanged (out of scope, per the artwork-musicfs TODO).
2026-07-23 14:08:08 -04:00
Deluan
f016192eec fix(artwork): requeue playlist cover when its track set changes
A generated-grid cover went stale after track mutations: nothing re-resolved the
playlist's artwork, and the request path deliberately never rebuilds the grid, so
serveEntity kept returning the old grid hash indefinitely. Enqueue pl artwork from
refreshCounters (the choke point for every track-set change); no clear, so the old
cover keeps serving until the worker rebuilds.
2026-07-23 14:08:08 -04:00
Deluan
cfca4a2433 fix(artwork): serve a local playlist ExternalImageURL as a file-backed reference
A local ExternalImageURL was resolved through the external step and labelled
external, so placeBytes copied it into the content-addressed store and dropped its
path/mtime — replacing the file never tripped the staleness check. Classify local
references as file-backed (resolved in place, even on the request path) and keep
store-backed behaviour only for http(s) URLs.
2026-07-23 14:08:08 -04:00
Deluan
49039fab47 fix(artwork): keep multi-disc tracks requestable when the album is absent
Round-1's hydration fix still copied the album's known-absent onto a non-eligible
(or own-absent) track, but MediaFile.CoverArtID routes a multi-disc track to disc
art, which resolves provisionally and is never known-absent. Marking it absent made
Subsonic omit coverArt so clients never requested a valid disc image. Only mark a
single-disc track absent, and only when its own art won't resolve.
2026-07-23 14:08:08 -04:00
Deluan
66d3d23149 fix(artwork): enqueue uploaded artwork only after the filename is persisted
SetImage cleared state and enqueued the bump before the caller stored the new
filename, so a worker drain in that window could resolve against the old (already
deleted) file and settle absent, leaving the upload unused until a later scan. Move
the invalidate+enqueue into EnqueueArtwork, which each caller now invokes after the
entity Put.
2026-07-23 14:08:08 -04:00
Deluan
f4e14e9c1a fix(artwork): fall back to disc art, not the album, for multi-disc tracks
serveMediaFile delegated an absent/ineligible track straight to AlbumCoverArtID,
skipping the disc-specific lookup that MediaFile.CoverArtID (and the deleted legacy
reader) use. On multi-disc albums with per-disc images that served the album cover
instead of the configured disc artwork. Delegate through DiscCoverArtID.
2026-07-23 14:08:08 -04:00
Deluan
d15d85ad0c fix(artwork): validate each agent image URL before picking the largest
bestImageURL selected the largest by size and only then parsed it, so a malformed
largest URL (e.g. a bad percent-escape) returned nil and shadowed a valid smaller
candidate, contradicting the documented skip-unparseable behavior. Parse per
candidate and compare sizes only among URLs that parse.
2026-07-23 14:08:08 -04:00
Deluan
0781c4a9b2 fix(artwork): keep an eligible track's cover requestable when its album is absent
An embedded-eligible track with no resolved item_artwork row inherited the album's
ImageAbsent, so when the album resolved absent (e.g. CoverArtPriority without
'embedded') the track's coverArt was omitted permanently — the client never
requested it, so the lazy mediafile path never resolved it — even though the
serving path would extract and serve the track's own embedded art. Hydration now
never copies the album's absence onto an eligible-but-unresolved track.
2026-07-23 14:08:08 -04:00
Deluan
ca4220b029 fix(artwork): request read-through must not reset the failure backoff
The provisional read-through and dangling re-enqueue used Enqueue, whose upsert
resets retry_at, so any browse of an unresolved entity that was backing off after
an external failure made it immediately eligible again — defeating the exponential
backoff during a provider outage. Add EnqueueBump, which raises priority but leaves
an existing row's retry_at intact, and route the serving path through it. Scan and
manual re-resolve keep Enqueue's reset (a detected change wants immediate retry).
2026-07-23 14:08:08 -04:00
Deluan
c2d7ae773c chore(artwork): generic 500 bodies on refresh endpoint, trim stale test comments 2026-07-23 14:08:08 -04:00
Deluan
dfd4bec270 test(artwork): end-to-end coverage for the serving cutover 2026-07-23 14:08:08 -04:00
Deluan
0d1df1648e feat(artwork): precache on acquisition, bump on upload/radio changes, manual re-resolve API 2026-07-23 14:08:08 -04:00
Deluan
937e58e5fb refactor(artwork): delete the legacy reader chain, cache warmer, and provider image methods 2026-07-23 14:08:08 -04:00
Deluan
8fea7efa50 feat(subsonic): content-hash coverArt ids, omit artwork on known-absent 2026-07-23 14:08:08 -04:00
Deluan
25b32f9706 feat(server): serve artwork from persisted state with content-hash caching 2026-07-23 14:08:08 -04:00
Deluan
313998fd65 feat(artwork): state-backed serving path with provisional read-through 2026-07-23 14:08:08 -04:00
Deluan
3a9dadfe34 fix(artwork): broadcast refresh for stale-found artwork too 2026-07-23 14:08:08 -04:00
Deluan
05ba549843 feat(artwork): broadcast refresh events when artwork lands 2026-07-23 14:08:08 -04:00
Deluan
5179691811 feat(artwork): resolve media_file embedded art in the worker, invalidate on rescan 2026-07-23 14:07:52 -04:00
Deluan
2ab1323b28 feat(persistence): hydrate artwork hash and absence onto entity pages 2026-07-23 14:07:52 -04:00
Deluan
3b7cbf41dd feat(model): content-hash artwork id suffix and hydratable per-entity image state 2026-07-23 14:07:52 -04:00
Deluan
39e939686b fix(artwork): treat agent not-found as breaker success 2026-07-23 14:07:52 -04:00
Deluan
190c291e61 feat(artwork): worker fetches agent images directly with per-agent rate limits and breakers 2026-07-23 14:07:52 -04:00
Deluan
b7f94f6727 feat(agents): enumerate enabled image-retriever agents per capability 2026-07-23 14:06:30 -04:00