Persistent cover cache: thumbnails + negative cache + pre-warm (prototype for #732)#733
Persistent cover cache: thumbnails + negative cache + pre-warm (prototype for #732)#733Paelsmoessan wants to merge 7 commits into
Conversation
New CoverThumbnailService downscales cached originals to allow-listed sizes (grid=400px, grid2x=800px) on demand, caches them under config/cache/images/thumbs/, and serves them via ImagesController when ?size= is passed. Frontend getImageUrl / getProtectedImageSrc gain an optional size option (backend image URLs only). Cuts grid cover payloads from MBs to ~30KB. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… 500s The grid's custom virtual scroller re-measured row height inside a watcher that then called updateVisibleRange() (which writes visibleRange), forming an infinite measure->update loop when card heights vary. Removed that self-retrigger; row height is measured on mount/resize/view-mode/details instead. ImageCandidateLookupWorkflow used `dynamic env.metadata`, throwing RuntimeBinderException (=> 500, uncacheable) for covers whose envelope lacks a metadata property, so every render pass re-fetched them. Replaced with safe reflection so misses fall through to the cacheable placeholder. Also wires the library grid to request ?size=grid thumbnails. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…test The cover-thumbnail commit made ICoverThumbnailService a required 9th ctor arg on ImagesController, breaking 10 existing image tests that use the 8-arg form. Make it an optional parameter (DI still injects the registered service; ActivatorUtilities resolves it over the default). Also correct the ImageCandidateLookupWorkflow comment to the real root cause (internal anonymous type + cross-assembly dynamic, not a missing property) and add ImagesController_MetadataEnvelopeReflectionTests proving the anonymous-envelope fallback now resolves the cover via reflection and no longer 500s. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Book covers are stored as already-built /api/.../images/{id} paths, which matched none of
getImageUrl's buildApiImageUrl branches, so the ?size= param was never appended and the grid
fetched full-size originals. Append the size in the passthrough branch when the URL targets the
images endpoint. Cuts grid cover payloads from ~530KB to ~37KB.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Dedicated SQLite index (config/cache/images/ImageCache.db) beside listenarr.db — no EF migration, rebuildable. ImagesController consults it before provider lookups: a known 'no-cover' result (re- checked after 23h) returns the cacheable placeholder immediately instead of re-hitting Audible/ Audnexus on every refresh; resolutions are recorded. ICoverThumbnailService/IImageCacheStore are optional ctor params so existing 8-arg image tests still compile. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CoverThumbnailWarmupService pre-generates grid thumbnails for all library covers on startup (1 min after boot) and every 12h, throttled to 3 concurrent ImageSharp decodes. Idempotent — fresh thumbnails are skipped. Removes the on-demand generation lag on first library load; covers are ready on disk before the page is opened. Verified: regenerated deleted thumbnails in ~7s with no browser involved. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
@Paelsmoessan Hey, first off thanks for contributing! I like this in theory, however I am not sold on the persistent db cache. I would prefer not to have/juggle multiple dbs. My initial implementation was to just use ASINs as a unique identifier, and just hard code the location for these (e.g. /cache/authors/[ASIN].png) so that would prevent the need for a DB. Can you help my understanding a little bit by justifying the need for this additional sidecar db? |
|
Thanks Robbie, and good push, I think you're right that the sidecar DB is more than this needs. Happy to drop it. One thing I'd want to sort out with you first is the key, because that's where I think ASIN-specifically gets us into trouble. Splitting what the cache was doing into two jobs: Positive cache (a cover we found): you're right, this is just a file on disk. A deterministic path handles it completely and the DB adds nothing here. Negative cache (a book/author/series that genuinely has no cover): this was the one real job the DB had. With a plain "file exists? serve : fetch" scheme, anything coverless falls into the fetch branch on every render and re-hits Audible/Audnexus forever. That repeated live lookup was a chunk of what was hammering the backend during the #731 freeze. But this doesn't need a DB either. I can do it filesystem-only: a zero-byte marker (e.g. under On the key itself: this is the part I'd like your read on. I don't think we can key the files strictly on ASIN, because ASIN isn't universal in the library. The image endpoint already resolves covers by ASIN or internal DB id or author name (its own doc comment says as much), The fix keeps your filesystem approach without that gap: name the cache file from the identifier the endpoint already receives (ASIN when we have one, otherwise the DB id or name), hashed to a filesystem-safe stem so names with spaces or unicode behave. Same If that direction sits right with you I'll rip out the sidecar DB and rework it as filesystem markers keyed that way. Or if you'd rather standardize the whole cache around ASIN and accept the non-ASIN items falling back to on-demand, I'm glad to go that way too, just wanted to flag the coverage tradeoff before I rebuild it. |
|
Update: I went with the simpler path and dropped the sidecar DB entirely, no So this PR is now just: server thumbnails ( On the ASIN-keying idea from your first comment: as I flagged above, I don't think we can key strictly on ASIN, authors and (once non-Audible providers land) whole swaths of the library won't have one. Worth noting the current cache doesn't actually key on ASIN today anyway; it names files by a sanitized identifier, so it already handles non-ASIN items. The deeper wrinkle I don't want to hand-wave is that the same cover can currently be addressed under more than one identifier (an author by ASIN in the grid vs by name in the fallback, etc.), so the durable fix is picking one canonical key per entity, not just changing the filename. That's bigger than this PR and touches the frontend, so I've written it up as separate follow-up work. Happy to open an issue if you'd like to weigh in on the direction (internal entity id, MediaCover-style, vs a hashed canonical provider key). |
…dling Per review feedback: avoid introducing a second database. Remove the IImageCacheStore / ImageCacheStore sidecar SQLite negative cache and its DI registration, and strip the negative-cache logic from ImagesController. The re-fetch pressure it guarded against is already handled without it: the AudiobooksView render-loop fix stops the grid re-issuing image requests at rest, and a cover miss now returns a cacheable placeholder (Cache-Control: public, max-age=300) instead of an uncacheable 500, so the browser stops re-requesting on its own. The only thing dropped is the server-side skip of provider re-lookups for genuinely-coverless items, which is minor once the loop and the 500 are resolved. Thumbnails, the getImageUrl size fix, and the pre-warm service are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…dling Per PR Listenarrs#733 review (therobbiedavis): avoid juggling a second DB. Remove the IImageCacheStore / ImageCacheStore sidecar SQLite negative cache and its DI registration, and strip the negative-cache logic from ImagesController. The user-visible re-fetch hammering it was meant to prevent is already handled without it: the Listenarrs#731 render-loop fix stops the grid re-issuing image requests at rest, and a cover miss now returns a cacheable placeholder (Cache-Control: public, max-age=300) instead of the old uncacheable 500, so the browser stops re-asking. The only thing dropped is the server-side skip of provider re-lookups for known-coverless items, which is minor now that the loop and 500 are fixed. Thumbnails, the getImageUrl size fix, and the pre-warm service are unaffected. Build clean, 19/19 image tests pass. TODO.md: record the two follow-ups surfaced in review, canonical cover identity (hash/entity-id keying instead of ASIN) and an oversized-thumbnail byte guard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
A working implementation of the persistent cover cache proposed in #732. It turns "fetch/resize a
cover on every view" into "resize once, serve a small thumbnail from disk, and remember misses." On a
~950-book library this took the Audiobooks grid from loading hundreds of 400 to 530 KB originals down
to ~37 KB thumbnails, pre-generated before the page is even opened.
Changes
Added
CoverThumbnailService): downscales cached originals to allow-listedsizes (
grid=400px,grid2x=800px) via ImageSharp, caches them underconfig/cache/images/thumbs/<size>/, and returned when the request asks for a named size (forexample
grid). Roughly 30 KB versus 400 KB.ImageCache.db): a dedicated sidecar SQLite DB (not the mainlistenarr.db, so no EF migration, and rebuildable). The image endpoint consults it first. A knownno-cover result (re-checked after 23h) returns the placeholder immediately with zero provider
calls, instead of re-hitting Audible/Audnexus on every refresh. This is the "negative cache" the
other *arrs don't have (see Persistent metadata + image cache (the pattern the other *arr apps use) #732).
CoverThumbnailWarmupService): generates all grid thumbnails on startup(and every 12h), throttled to 3 concurrent decodes, idempotent, so the first library open is
instant. MediaCover-style, but populated proactively.
Fixed
/api/.../images/{id}paths, which skippedgetImageUrl'ssize-appending branches, so the grid was silently fetching full-size originals. Appended the size in
the passthrough for image-endpoint URLs.
Testing
path).
deleted thumbnails in ~7s with no browser involved;
ImageCache.dbrecords resolved/no-coverresults.
Notes and open questions for #732
fix as a base (they touch the same files). If Fix AudiobooksView freeze/leak and uncacheable cover 500s #731 merges first I'll rebase to drop the overlap.
sizeparameter naming, the 23h negative-cache re-check window, and the sidecar-DB-versus-main-DB choice are all up for discussion. Happy to align with whatever the maintainers prefer.