Skip to content

Improve podcast artwork caching#5549

Draft
joashrajin wants to merge 3 commits into
mainfrom
pcdroid-318-improve-podcast-artwork-caching
Draft

Improve podcast artwork caching#5549
joashrajin wants to merge 3 commits into
mainfrom
pcdroid-318-improve-podcast-artwork-caching

Conversation

@joashrajin

@joashrajin joashrajin commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Description

Improves podcast artwork caching so covers still display when the network or the artwork CDN is unavailable (as happened during the Cloudflare outage, where users reported all thumbnails disappearing).

Root cause: the Coil disk cache for artwork uses the library's default size — 2% of the free disk space with a 10MB floor. On devices low on storage the artwork cache is tiny and constantly LRU-evicts. Artwork is prefetched once at subscribe time (SubscribeManager.cacheArtworkRxCompletable), but once an entry is evicted nothing restores it until a successful network load — so during an outage the app shows placeholder tiles.

Changes:

  • UiModule: give the artwork DiskCache a fixed 250MB maxSizeBytes instead of the free-space-relative default, so cache capacity is deterministic regardless of device storage pressure.
  • PrefetchArtworkWorker (new): a daily WorkManager job (network-connected constraint) that checks each subscribed podcast's artwork URLs against the disk cache and re-fetches only the missing ones, so the cache self-heals after eviction while the device is online.
  • PocketCastsApplication: enqueue the periodic worker at startup, alongside CuratedPodcastsSyncWorker.

Fixes PCDROID-318

Testing Instructions

  1. Install the app and subscribe to a few podcasts, letting the grid load artwork.
  2. Run adb shell run-as au.com.shiftyjelly.pocketcasts.debug rm -r cache/ImageCache (or clear the app cache from system settings) to simulate the disk cache being evicted, then force-stop and relaunch the app while online.
  3. From App Info → force-stop, then trigger the periodic worker: adb shell am broadcast alternatives aside, the simplest is adb shell cmd jobscheduler or WorkManager's test hook — or just wait for the daily PrefetchArtworkWorkerPeriodic run.
  • Verify the worker logs Prefetched N missing podcast artwork images. (Timber) and re-running it logs Prefetched 0 ... (everything cached).
  1. Enable airplane mode and relaunch the app.
  • Verify podcast grid artwork still displays while fully offline (served from the disk cache).
  • Verify no regression in normal artwork loading (podcast grid, episode lists, discover) while online.

⚠️ TODO (manual): End-to-end outage verification (artwork visible offline after a cache wipe + worker run) needs an on-device pass — it can't be verified in CI.

Screenshots or Screencast

⚠️ TODO (manual): n/a expected — no visible UI changes beyond artwork persisting offline; add a before/after offline screenshot if useful.

Checklist

  • If this is a user-facing change, I have added an entry in CHANGELOG.md
  • Ensure the linter passes (./gradlew spotlessApply to automatically apply formatting/linting)
  • I have considered whether it makes sense to add tests for my changes
  • All strings that need to be localized are in modules/services/localization/src/main/res/values/strings.xml — n/a, no strings added
  • Any jetpack compose components I added or changed are covered by compose previews — n/a, no Compose changes
  • I have updated (or requested that someone edit) the Event Horizon schema to reflect any new or changed analytics. — n/a, no analytics changes

No unit test added: the new worker is glue around Coil's ImageLoader/DiskCache and WorkManager with no seam to test without instrumenting Coil itself.

I have tested any UI changes...

  • with different themes — n/a, no UI changes
  • with a landscape orientation — n/a
  • with the device set to have a large display and font size — n/a
  • for accessibility with TalkBack — n/a

Give the Coil artwork disk cache a fixed 250MB size instead of the
default 2% of free disk space (10MB floor), and add a daily worker that
re-fetches subscribed podcast artwork evicted from the disk cache so
covers still display when the network or CDN is unavailable.

PCDROID-318
@claude

claude Bot commented Jul 9, 2026

Copy link
Copy Markdown

Claude finished @joashrajin's task in 3m 12s —— View job


Code Review — Improve podcast artwork caching

  • Read PR diff and understand intent
  • Verify module dependencies (no circular dep introduced)
  • Verify disk-cache-key assumption against how the UI loads artwork
  • Review each changed file for bugs / perf / robustness
  • Post inline comments + summary

Nice, well-scoped fix with a clear root-cause writeup. The approach (deterministic cache size + a self-healing prefetch worker) is sound and the implementation is consistent with the existing CuratedPodcastsSyncWorker and SubscribeManager patterns. I verified the potential concern that worried me most — a circular module dependency — and it's fine: modules/services/ui already declares api(projects.modules.services.repositories), so pulling in PodcastManager/PodcastImage introduces nothing new.

I left 4 inline comments. Summary below.

Correctness / robustness

  1. Worker reports success even when every fetch fails (PrefetchArtworkWorker.doWork). In the exact outage scenario this PR targets (device online, CDN down), all execute() calls return ErrorResult, yet doWork returns Result.success() — so the cache won't re-heal until the next daily tick (~24h). Track failures and return Result.retry() when any fetch fails, and wrap the body in a try/catch → Result.retry() since findSubscribedNoOrder()/openSnapshot() do I/O and can throw.
  2. Load-bearing (and untested) disk-cache-key assumption. diskCache.openSnapshot(url) only matches Coil's stored entry because UiModule registers no custom Keyer/diskCacheKey (Coil then defaults the key to the URL string). Correct today, but silent breakage if a keyer is ever added — worth a comment guarding it. I confirmed no keyer is currently set.

Design / trade-offs

  1. Fixed 250 MB may fight the low-storage case it's meant to help (UiModule). A hard cap removes Coil's free-space-relative default, so on a nearly-full device the cache still grows toward 250 MB and the OS is more likely to trim the whole dir. Consider clamping to available free space, and note this is the app-wide ImageLoader cache (shared by all images, not just podcast covers). A comment justifying the 250 MB number would help.
  2. Duplication with SubscribeManager.cacheArtworkRxCompletable — same ImageRequest build + ErrorResult logging. Worth extracting one shared prefetch helper so the subscribe-time and worker-time caching contract can't drift.

Minor / nits

  • Sequential fetches across all subscriptions × 3 sizes; a bounded concurrent fan-out would shorten the run/wakelock. openSnapshot disk reads run on Dispatchers.DefaultDispatchers.IO is the more correct home.
  • For very large libraries, 3 sizes × N × ~200KB can exceed 250 MB, so the worker may never converge to "0 missing" (daily fetch/evict thrash). Not a bug, just a ceiling worth being aware of.
  • Prefetching the 960px LARGE size for every subscription is heavy; if grids never request it, consider whether all three sizes are needed. (Matches existing getArtworkUrls behavior, so consistent — just flagging.)

Non-blocking observations

  • The worker's Util.isWearOs branch is effectively dead on mobile since it's only enqueued from the mobile PocketCastsApplication (same as CuratedPodcastsSyncWorker). Fine to leave, but the wear/automotive apps won't get this self-healing.
  • No unit test — reasonable given the Coil/WorkManager glue, and the author called it out. If any test is added, the highest-value one asserts the read key (openSnapshot(url)) equals the write key produced by the real request path.

Overall: approvable after considering the Result.retry()/error-handling point (#1) — that's the one with a real functional impact on the outage scenario. The rest are trade-offs and polish.
· pcdroid-318-improve-podcast-artwork-caching

@claude

claude Bot commented Jul 9, 2026

Copy link
Copy Markdown

Claude finished @joashrajin's task in 2m 39s —— View job


PR Review: Improve podcast artwork caching

  • Read changed files
  • Analyze worker logic & DI change
  • Check for edge cases / performance concerns
  • Post review

Nice, focused change — clear problem statement, and the code fits the existing conventions well (mirrors SubscribeManager.cacheArtworkRxCompletable and CuratedPodcastsSyncWorker). I verified the core correctness concern and it holds up; a few design/robustness suggestions below. I left 3 inline comments for the specifics.

✅ What's correct

  • Disk-cache key matches the UI. The worker checks/fetches by raw URL (diskCache.openSnapshot(url), .data(url)), while the UI loads via PocketCastsImageRequestFactory with RoundedCornersTransformation + .size(...). That's fine: Coil's disk cache key defaults to the URL — transformations and size only affect the memory cache key. So entries the worker writes are readable by the UI offline. 👍
  • URL buckets line up. The worker prefetches SMALL/MEDIUM/LARGE, and getArtworkUrl(size = actualSize, …) buckets every UI request into exactly those three sizes, so coverage is complete.
  • memoryCachePolicy(CachePolicy.DISABLED) correctly avoids polluting the memory cache during prefetch, consistent with SubscribeManager.
  • ExistingPeriodicWorkPolicy.KEEP + unique name matches the CuratedPodcastsSyncWorker pattern.

🔧 Suggestions (in priority order)

  1. Return Result.retry() on failure (most impactful — inline). During the very outage this targets, all fetches fail but the worker returns success(); being periodic, it won't re-heal until the next daily run (~24h). Tracking failures and returning Result.retry() (with setBackoffCriteria) would self-heal minutes after connectivity returns instead of a day later.

  2. Sequential fetching (inline). Large libraries → up to 3 × subscriptions serial disk+network ops. Consider bounded parallelism, and wrapping the blocking openSnapshot I/O in withContext(Dispatchers.IO) (CoroutineWorker defaults to Dispatchers.Default).

  3. Wear/Automotive coverage (inline). The worker is only enqueued in the mobile PocketCastsApplication, yet it has an isWearOs branch and the 250MB cache change lives in the shared ui module. Worth confirming whether the self-heal worker was meant to run on Wear too.

💭 Minor notes

  • 250MB fixed cache is generous but reasonable given artwork is small. Note it's a complement to the worker, not a full guarantee: the OS can still purge cacheDir under storage pressure regardless of Coil's maxSizeBytes — which is exactly why the self-heal worker is the right belt-and-braces move.
  • Testability: agree a full test needs to instrument Coil. If you wanted a seam, the "URLs missing from cache" filtering could be extracted into a pure function and unit-tested, leaving only the fetch as untested glue. Low priority.

Overall this is a sound fix. The Result.retry() change is the one I'd most encourage before merge, since it directly affects how quickly the cache recovers during the outage scenario described.

@dangermattic

dangermattic commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator
1 Warning
⚠️ Class PrefetchArtworkWorker is missing tests, but unit-tests-exemption label was set to ignore this.
1 Message
📖 This PR is still a Draft: some checks will be skipped.

Generated by 🚫 Danger

Comment on lines +41 to +50
// Coil's default size is 2% of the free disk space with a 10MB floor, which on
// devices low on storage evicts artwork almost immediately.
.maxSizeBytes(ARTWORK_DISK_CACHE_SIZE_BYTES)
.build()
}
.build()
}

private companion object {
const val ARTWORK_DISK_CACHE_SIZE_BYTES = 250L * 1024 * 1024

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed 250 MB cache may work against the exact scenario this PR targets. The root-cause analysis says the problem is devices low on storage, but a hard maxSizeBytes(250MB) removes the free-space-relative safety valve that Coil's default provides. On a device with, say, 150 MB free, Coil will still grow this cache toward 250 MB, contributing to storage pressure — and the OS is then more likely to trim the whole cache dir anyway (which is the eviction you're trying to avoid).

A couple of things to weigh:

  • Consider clamping to a fraction of free space rather than an unconditional constant, e.g. keep a generous floor but cap at available space:
    .maxSizeBytes(ARTWORK_DISK_CACHE_SIZE_BYTES) // or coerce against StatFs free bytes
  • This ImageLoader is the app-wide one, so 250 MB is shared by all images (episode/user artwork, discover, avatars…), not just podcast covers. Worth confirming 250 MB is the intended total budget, and that the number is justified (rough math: ~250 podcasts × 3 sizes × ~200 KB ≈ 150 MB).

Not blocking, but a comment documenting why 250 MB and the low-storage trade-off would help the next reader.

Comment on lines +57 to +72
continue
}
val request = ImageRequest.Builder(applicationContext)
.data(url)
.memoryCachePolicy(CachePolicy.DISABLED)
.build()
val result = imageLoader.execute(request)
if (result is ErrorResult) {
Timber.i("Could not prefetch artwork from $url. ${result.throwable.message}")
} else {
fetched++
}
}
}
Timber.i("Prefetched $fetched missing podcast artwork images.")
return Result.success()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The worker returns Result.success() even when every fetch failed. In the precise outage scenario this PR is built for (CDN down but device online), the network constraint is satisfied, all execute() calls come back as ErrorResult, and the worker reports success — so nothing re-runs until the next daily tick, up to ~24h later.

Consider tracking failures and returning Result.retry() when some/all fetches failed, so the cache self-heals shortly after the CDN recovers rather than a day later:

var failed = 0
// ... in the loop: if (result is ErrorResult) { failed++; Timber.i(...) } else { fetched++ }
Timber.i("Prefetched $fetched missing podcast artwork images ($failed failed).")
return if (failed > 0) Result.retry() else Result.success()

Also worth wrapping the body in a try/catch → Result.retry(): podcastManager.findSubscribedNoOrder() and openSnapshot() do I/O and can throw; an uncaught exception marks the run as failed with no controlled retry policy.

Comment on lines +57 to +60
continue
}
val request = ImageRequest.Builder(applicationContext)
.data(url)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Load-bearing assumption: openSnapshot(url) matches the disk-cache key Coil writes. This is correct today because the ImageLoader in UiModule registers no custom Keyer/diskCacheKey, so Coil defaults the disk-cache key to the data string (the URL). But it's the single point the whole worker hinges on, and it's untested — if a keyer is ever added, isCached becomes permanently false and the worker silently re-fetches everything every day. A one-line comment noting this dependency (and ideally the same PocketCastsImageRequestFactory path being used for reads) would protect it.

Minor: diskCache.openSnapshot(url)?.use { true } ?: false is fine, but openSnapshot(url)?.use { } != null reads a touch more clearly.

Comment on lines +61 to +71
.memoryCachePolicy(CachePolicy.DISABLED)
.build()
val result = imageLoader.execute(request)
if (result is ErrorResult) {
Timber.i("Could not prefetch artwork from $url. ${result.throwable.message}")
} else {
fetched++
}
}
}
Timber.i("Prefetched $fetched missing podcast artwork images.")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplication with SubscribeManager.cacheArtworkRxCompletable. This builds essentially the same ImageRequest (.data(url).memoryCachePolicy(DISABLED) + execute + ErrorResult logging) that already exists in SubscribeManager. Consider extracting a single shared prefetch helper (e.g. in the repositories layer alongside PodcastImage) so the caching contract lives in one place and can't drift between subscribe-time and worker-time.

Perf nit (not blocking for a daily job): the per-podcast × per-size loop runs fully sequentially. For a large library that's hundreds of serialized execute() calls; a bounded concurrent fan-out (e.g. coroutineScope + a small semaphore) would finish faster and shorten the wakelock. Also, the openSnapshot disk reads run on Dispatchers.Default — fine, but Dispatchers.IO is the more correct home for that I/O.

Comment on lines +64 to +72
if (result is ErrorResult) {
Timber.i("Could not prefetch artwork from $url. ${result.throwable.message}")
} else {
fetched++
}
}
}
Timber.i("Prefetched $fetched missing podcast artwork images.")
return Result.success()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider Result.retry() when fetches fail. This is the most important point given the feature's goal.

During the exact scenario this worker targets — a CDN/network outage — every execute returns ErrorResult, the failures are logged, and the worker still returns Result.success(). Because it's a periodic worker, the missing artwork then won't be re-fetched until the next daily run, so the cache won't self-heal promptly once the CDN recovers (it could be up to ~24h later).

Consider tracking whether any fetch failed and returning Result.retry() in that case, so WorkManager retries with exponential backoff (set via setBackoffCriteria) minutes after connectivity/CDN is restored rather than a day later.

var failed = 0
...
if (result is ErrorResult) {
    failed++
    Timber.i("Could not prefetch artwork from $url. ${result.throwable.message}")
} else {
    fetched++
}
...
Timber.i("Prefetched $fetched missing podcast artwork images.")
return if (failed > 0) Result.retry() else Result.success()

Comment on lines +53 to +70
for (podcast in podcastManager.findSubscribedNoOrder()) {
for (url in PodcastImage.getArtworkUrls(uuid = podcast.uuid, isWearOS = isWearOs)) {
val isCached = diskCache.openSnapshot(url)?.use { true } ?: false
if (isCached) {
continue
}
val request = ImageRequest.Builder(applicationContext)
.data(url)
.memoryCachePolicy(CachePolicy.DISABLED)
.build()
val result = imageLoader.execute(request)
if (result is ErrorResult) {
Timber.i("Could not prefetch artwork from $url. ${result.throwable.message}")
} else {
fetched++
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sequential fetching may be slow for large libraries. A user with, say, 300 subscriptions triggers up to 900 sequential openSnapshot disk reads + network fetches, one at a time. It's a background worker so latency is tolerable, but on the first run after a cache wipe this is a long serial chain. Consider bounded parallelism (e.g. chunked() + coroutineScope { async {} }, or a Semaphore) to cut wall-clock time.

Minor: diskCache.openSnapshot(url) is blocking disk I/O and CoroutineWorker.doWork() runs on Dispatchers.Default by default — wrapping the loop body in withContext(Dispatchers.IO) would be more correct for the I/O-bound work.

downloadStatusObserver.monitorDownloadStatus()
userManager.beginMonitoringAccountManager(playbackManager)
CuratedPodcastsSyncWorker.enqueuePeriodicWork(this)
PrefetchArtworkWorker.enqueuePeriodicWork(this)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The worker is only enqueued here in the mobile app. The worker itself has an isWearOs branch (PodcastImage.getArtworkUrls(..., isWearOS = ...)), and the 250MB cache change lives in the shared ui UiModule, so Wear/Automotive get the larger cache but no self-healing worker. If that's intentional (e.g. Wear artwork persistence isn't a concern), fine — but worth confirming, since the isWearOs handling in the worker reads as if Wear support was intended. On the mobile app that branch is always false.

Address review feedback: return Result.retry() with exponential backoff
when any artwork fetch fails or the worker throws, run the prefetch loop
on Dispatchers.IO, and document the disk cache key assumption and the
250MB cache size rationale.
@claude

claude Bot commented Jul 9, 2026

Copy link
Copy Markdown

Claude finished @joashrajin's task in 1m 8s —— View job


Re-review after Retry artwork prefetch when fetches fail

  • Read current state of changed files
  • Assess whether prior review feedback was addressed
  • Post updated review

The new commit addresses the highest-priority feedback from the earlier rounds. This looks good to merge.

✅ Addressed since last review

  • Result.retry() on failure (Update issue templates #1 — the blocking one). doWork now tracks failed, returns Result.retry() when any fetch failed, and enqueuePeriodicWork sets setBackoffCriteria(EXPONENTIAL, 10min). So in the target outage scenario the cache now self-heals minutes after the CDN recovers rather than up to ~24h later. 👍
  • Uncaught exceptions. The body is wrapped in try/catchResult.retry(), so I/O throws from findSubscribedNoOrder() / openSnapshot() no longer mark the run as an uncontrolled failure.
  • I/O dispatcher. The loop runs inside withContext(Dispatchers.IO), the correct home for the blocking openSnapshot/execute calls.
  • Disk-cache-key assumption documented. The comment at PrefetchArtworkWorker.kt:62-63 now guards the load-bearing "URL == disk cache key" assumption. I verified this is not just a Coil default — CoilManager.clearCache (CoilManager.kt:15) already calls diskCache.remove(url) with the same URL key, so existing production code depends on the identical mapping. That's a stronger guarantee than the previous review credited.
  • 250MB rationale. UiModule now documents why 250MB, that the cache is app-wide (all images, not just covers), and the free-space trade-off.
  • Readability nit. openSnapshot(url)?.use { } != null adopted.

Remaining non-blocking items (fine to defer)

  • Sequential fetching. Still one-at-a-time (3 × subscriptions serial fetches on first run after a wipe). Acceptable for a daily background job; bounded parallelism (Semaphore + async) would just shorten the wakelock. Not required.
  • Duplication with SubscribeManager.cacheArtworkRxCompletable. The ImageRequest build + ErrorResult logging still lives in two places. Extracting one shared prefetch helper would keep the subscribe-time and worker-time contracts from drifting — worth a follow-up, not a blocker.
  • Wear/Automotive. The worker is only enqueued in the mobile PocketCastsApplication, so isWearOs is effectively always false here and the wear/automotive apps get the larger cache but no self-healing. Consistent with CuratedPodcastsSyncWorker; confirm it's intentional.
  • Convergence ceiling. For very large libraries 3 sizes × N × ~200KB can exceed 250MB, so the worker may never reach "0 missing" (mild daily fetch/evict thrash). Just a ceiling to be aware of.

Correctness spot-checks (all pass)

  • URL buckets align: worker prefetches LARGE/MEDIUM/SMALL via getArtworkUrls, and UI requests bucket into the same three sizes → full coverage.
  • memoryCachePolicy(DISABLED) avoids polluting the memory cache during prefetch, matching SubscribeManager.
  • ExistingPeriodicWorkPolicy.KEEP + unique name matches the CuratedPodcastsSyncWorker pattern.

Overall: the functional concern from the prior reviews is resolved. Approvable from my side (I can't formally approve). The remaining points are polish/trade-offs suitable for follow-ups.
· pcdroid-318-improve-podcast-artwork-caching

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