Skip to content

List-view covers, pre-warm + scroller memory fix (requires #676)#731

Open
Paelsmoessan wants to merge 15 commits into
Listenarrs:canaryfrom
Paelsmoessan:fix/audiobooks-freeze
Open

List-view covers, pre-warm + scroller memory fix (requires #676)#731
Paelsmoessan wants to merge 15 commits into
Listenarrs:canaryfrom
Paelsmoessan:fix/audiobooks-freeze

Conversation

@Paelsmoessan

@Paelsmoessan Paelsmoessan commented Jul 4, 2026

Copy link
Copy Markdown

Requires #676 (prerequisite)

This PR builds on @s3ntin3l8's list-view virtual-scroller fix (#676) and needs it to work, #676 should land first. Our changes sit on top of Björn's scroller rather than replacing it.

Summary

On top of #676:

  • Cover thumbnails + background pre-warm — covers are generated/cached ahead of time, so scrolling doesn't fire a burst of on-demand cover work (lower memory churn, faster first paint).
  • Cacheable-placeholder for missing covers — a missing cover returns a cacheable placeholder instead of an uncacheable 500 that grid views re-fire on every render pass.
  • Cover-metadata fallback fix — read the metadata envelope via reflection instead of dynamic (the internal anonymous type across the assembly boundary threw RuntimeBinderException → uncacheable 500). This is the original List-view covers, pre-warm + scroller memory fix (requires #676) #731 fix, retained.
  • Virtual-scroller memory fix — drop the visibleRange watcher whose re-measure loop leaked DOM/RAM over a session on large libraries. Row height is measured on mount / resize / view-mode change / details toggle instead.

Notes

  • Scroll-position preservation (remembering scroll on list ↔ detail) was attempted and dropped, it was too flaky to ship reliably.
  • This hand-rolled .vue scroller works well enough now, but has a ceiling on very large libraries. A virtualization rewrite (TanStack Virtual) has been prototyped and may be pursued separately in a fork; this PR keeps the existing scroller and just stops the freeze/leak.

Testing

Verified locally on a large library: scrolling is smooth, RAM holds steady over a session, and covers pre-warm.

@Paelsmoessan Paelsmoessan requested a review from a team July 4, 2026 16:09
@Paelsmoessan Paelsmoessan force-pushed the fix/audiobooks-freeze branch from 365288e to 71bffc7 Compare July 4, 2026 16:44
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>
@Paelsmoessan

Copy link
Copy Markdown
Author

Heads up, I just found #676 (closing #675) by @s3ntin3l8, opened a few weeks before this PR, and it fixes the same AudiobooksView.vue virtual-scroller loop that the first half of this PR addresses. I hit the root cause independently and didn't see his PR at the time, credit for spotting and fixing it first goes to him.

His #676 is actually the more complete scroller fix: it also handles the oversized scroll area, overscroll past both ends, the unreachable last row, and header height in totalHeight, plus dedicated Vitest coverage. This PR's scroller change overlaps with it (both touch the shared measuredRowHeight / visibleRange measurement path, so they'd also conflict).

Where this PR doesn't overlap is the second fix: the cover-metadata fallback returning uncacheable 500s (an internal anonymous type read via dynamic throwing RuntimeBinderException, so those covers re-fetched on every render). #676 doesn't touch that.

Happy to consolidate whichever way you'd prefer:

Mainly flagging so the effort isn't duplicated and #676 gets a look, it's been open and unreviewed for a few weeks.

@therobbiedavis

Copy link
Copy Markdown
Collaborator

@Paelsmoessan Agreed, split these changes. #676 will own the virtual-scroller fix and #675. Remove AudiobooksView.vue from this PR and retain only the cover-metadata fallback fix and its tests.

Paelsmoessan added a commit to Paelsmoessan/Listenarr that referenced this pull request Jul 6, 2026
`GetMetadataAsync` returns `Task<object?>` and, on success, an *internal* anonymous
type `new { metadata = ... }` declared in listenarr.application. The controller read
it via `dynamic env.metadata`; because the anonymous type is internal to another
assembly, the dynamic binder sees it as `object` and throws RuntimeBinderException
("'object' does not contain a definition for 'metadata'"), so this fallback never
resolved those covers and always 500'd. Browsers don't cache 500s, so grid views
re-fired them on every render pass.

Fix: read the property via reflection, which reaches public members across the
assembly boundary; a genuinely absent member yields null and falls through to the
cacheable placeholder. Adds a controller test proving the anonymous envelope resolves
(the image URL is fetched) and that a missing member no longer 500s.

Per maintainer review on Listenarrs#731: the AudiobooksView virtual-scroller fix is split out
(Listenarrs#676 owns it); this PR now contains only the cover-metadata fallback fix and its test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Paelsmoessan Paelsmoessan force-pushed the fix/audiobooks-freeze branch from 71bffc7 to 015145c Compare July 6, 2026 18:10
@Paelsmoessan Paelsmoessan changed the title Fix AudiobooksView freeze/leak and uncacheable cover 500s Fix broken cover-metadata fallback (uncacheable cover 500s) Jul 6, 2026
@Paelsmoessan Paelsmoessan force-pushed the fix/audiobooks-freeze branch from 015145c to e014b82 Compare July 6, 2026 19:18
@Paelsmoessan Paelsmoessan changed the title Fix broken cover-metadata fallback (uncacheable cover 500s) List-view covers, pre-warm + memory fixes (requires #676) Jul 6, 2026
Paelsmoessan and others added 15 commits July 6, 2026 21:24
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>
Sidebar sub-items (.nav-subitem) had CSS for only their default and active states, so they
never highlighted on hover or keyboard focus. On top of that, .nav-sub was collapsed with
pointer-events:none until opened via a parent hover, so a non-active section's children were
unreachable until you hovered the top node first.

Add a :hover / :focus-visible highlight, and keep the sub-navs expanded so children are always
interactive and highlight on their own hover.

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>
Adopts Bjorn Hansen's list-view scroller fix (upstream PR Listenarrs#676, closing Listenarrs#675):
fixed list-row heights, rAF-throttled scroll handler, measure-grid-once instead
of per-scroll re-measure, header height reserved in totalHeight, and overscroll
clamping. This supersedes our earlier freeze fix (52d0299) with a more complete
approach, and fixes the last-row text clipping our version had.

REQUIRES upstream PR Listenarrs#676. Carried in canary until that PR
merges upstream; the next upstream sync will reconcile it automatically. Our
cover-thumbnail (?size=grid) changes in the same file are preserved.

Co-Authored-By: s3ntin3l8 <58235613+s3ntin3l8@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Return to the Audiobooks list after viewing a book/author/series and land back
at your previous scroll position instead of the top.

Two scroll models, handled natively:
- Authors/series scroll the window: a router scrollBehavior restores savedPosition
  on back/forward and scrolls to top on forward nav (also fixes detail pages
  opening at the list's scroll offset).
- Books scroll an inner container: save/restore scrollContainer.scrollTop across
  the remount (reliable now that Listenarrs#676 made row heights deterministic).
- Silent background refresh on return so the remount does not flash a spinner.

No keep-alive (deliberately): the view remounts, so there is a brief render-then-
restore jump. That is the robust trade-off; keep-alive removes the jump but
reintroduces the route-watcher nav-hijack complexity (see reason-notes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wrap the list view in <KeepAlive> so authors/series (a non-virtualized grouped
view) no longer re-render every cover on back-out. The blocker that broke earlier
keep-alive attempts was AudiobooksView's route.query.group watcher calling
router.replace('/audiobooks') while cached-but-inactive, which hijacked every
detail click back to the list; now guarded to run only on the active list route.

- App.vue: <KeepAlive :include=['AudiobooksView']> (no key, no transition).
- Watcher guard: bail unless route.name is the list route (audiobooks/home).
- Lifecycle moved to onActivated/onDeactivated (the view no longer remounts):
  save/restore books inner scroll, silent background refresh, click-listener guard.

Builds on the scrollBehavior + manual-restore scroll preservation. Known minor:
the cold first view can land ~2 rows high because grouped-view covers load after
the restore (layout shift); to be polished by reserving cover box size.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Paelsmoessan Paelsmoessan force-pushed the fix/audiobooks-freeze branch from e014b82 to eef3be3 Compare July 6, 2026 19:57
@Paelsmoessan Paelsmoessan changed the title List-view covers, pre-warm + memory fixes (requires #676) List-view covers, pre-warm + scroller memory fix (requires #676) Jul 6, 2026
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