Skip to content

Persistent cover cache: thumbnails + negative cache + pre-warm (prototype for #732)#733

Draft
Paelsmoessan wants to merge 7 commits into
Listenarrs:canaryfrom
Paelsmoessan:feature/cover-cache-thumbnails
Draft

Persistent cover cache: thumbnails + negative cache + pre-warm (prototype for #732)#733
Paelsmoessan wants to merge 7 commits into
Listenarrs:canaryfrom
Paelsmoessan:feature/cover-cache-thumbnails

Conversation

@Paelsmoessan

@Paelsmoessan Paelsmoessan commented Jul 4, 2026

Copy link
Copy Markdown

Draft: prototype for discussion #732. Opening early (as the contributing guide invites) so the
approach can be reviewed before it's polished. Not requesting merge yet; feedback very welcome.

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

  • Server-side thumbnails (CoverThumbnailService): downscales cached originals to allow-listed
    sizes (grid=400px, grid2x=800px) via ImageSharp, caches them under
    config/cache/images/thumbs/<size>/, and returned when the request asks for a named size (for
    example grid). Roughly 30 KB versus 400 KB.
  • Persistent cover cache (ImageCache.db): a dedicated sidecar SQLite DB (not the main
    listenarr.db, so no EF migration, and rebuildable). The image endpoint consults it first. A known
    no-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).
  • Background pre-warm (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

  • Book covers are stored as already-built /api/.../images/{id} paths, which skipped getImageUrl's
    size-appending branches, so the grid was silently fetching full-size originals. Appended the size in
    the passthrough for image-endpoint URLs.

Testing

  • 14/14 image controller tests pass (including a new test covering the anonymous-metadata-envelope
    path).
  • Verified on a ~950-book library: covers serve as ~37 KB thumbnails; the pre-warm regenerated 150
    deleted thumbnails in ~7s with no browser involved; ImageCache.db records resolved/no-cover
    results.

Notes and open questions for #732

Paelsmoessan and others added 6 commits July 4, 2026 21:02
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>
@therobbiedavis

Copy link
Copy Markdown
Collaborator

@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?

@Paelsmoessan

Copy link
Copy Markdown
Author

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 cache/images/.nocover/) whose mtime is the last-checked time. Exists = known-missing (serve placeholder, zero provider calls), mtime + 23h = when to re-check. That drops ImageCache.db entirely and stays MediaCover-style.

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), Asin is nullable on authors, series, and metadata, and there are whole cover paths that key off ISBN or title+author via OpenLibrary. So /cache/authors/[ASIN].png would silently miss (or couldn't even form a filename for) any author or series without an ASIN, plus sideloaded and non-Audible books.

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 /cache/.../<key>.jpg idea you had, just with a key that every item actually has.

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.

@Paelsmoessan

Copy link
Copy Markdown
Author

Update: I went with the simpler path and dropped the sidecar DB entirely, no ImageCache.db, no filesystem markers either. Digging into it, the negative cache turned out not to be needed for the user-visible problem: the #731 render-loop fix stops the grid from 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 on its own. The only thing the DB was still buying was the server skipping provider re-lookups for genuinely-coverless items, which is minor once the loop and the 500 are gone.

So this PR is now just: server thumbnails (?size=grid), the getImageUrl size fix, and the background pre-warm, no extra database. Build is clean and the 19 image controller tests pass. I verified on the live library that the grid serves thumbnails correctly (e.g. a 1.5 MB original collapses to ~65 KB via ?size=grid).

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>
Paelsmoessan added a commit to Paelsmoessan/Listenarr that referenced this pull request Jul 5, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants