fix(resources): fetch each cover once instead of once per size - #4143
Merged
Conversation
get_cover() called _store_cover() once for CoverSize.SMALL and once for CoverSize.BIG with the same URL, and each call performed a complete fetch. The small cover has no distinct source: it is the large image downloaded in full and then resized in place, so the second request only re-retrieved bytes already on disk. Every cover cost twice the bandwidth, doubled the load on every provider, and on ScreenScraper spent a second request from the account's quota, since media is served from the same authenticated api2 endpoints as metadata lookups. _store_cover() now takes no size: it fetches once into big.png and derives small.png from it, the way store_artwork() already did. A missing small cover no longer triggers a fetch at all. get_cover() downloads only when overwriting or when the large cover is absent, and otherwise rebuilds the small one from the large one already on disk via _derive_small_cover(). That drops the half-written pair case from one request to none, and keeps a good large cover out of the blast radius of a download that might fail. The derive path resolves its source through _get_cover_path, so an uploaded big.jpg or a converted big.webp yields a matching small file rather than assuming a .png extension. Failure paths now clean up both destinations. Undecodable bytes are discarded rather than left on disk, where they satisfy cover_exists() and stop any later scan from refetching a working cover, and a discarded chroma-key placeholder also clears a small cover left by an earlier scan. _derive_small_cover() is the exception: it did not write those bytes, so a large cover it cannot decode is left in place and only the partial small one is dropped. Fixes rommapp#4102 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Contributor
Greptile SummaryThe PR consolidates cover retrieval into one fetch of the large image and derives the small image locally, including recovery of a missing small cover without network access.
Confidence Score: 5/5The PR appears safe to merge with no actionable changed-code defect identified. The new single-fetch and local-derivation paths preserve existing cover lookup behavior and are covered across normal, overwrite, local-file, conversion, and failure scenarios. Important Files Changed
Reviews (1): Last reviewed commit: "fix(resources): fetch each cover once in..." | Re-trigger Greptile |
gantoine
approved these changes
Aug 8, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Explain the changes or enhancements you are proposing with this pull request.
get_cover()called_store_cover()once forCoverSize.SMALLand once forCoverSize.BIGwith the same URL, and each call performed a complete fetch. The small cover has no distinct source: it is the large image downloaded in full and then resized in place, so the second request only re-retrieved bytes already on disk.Measured against the real code path with a local HTTP server counting inbound requests: 2 GETs per cover, to a byte-identical URL. Covers in this repo's
romm_mock/resourcesaverage 556 KiB, so a 5,000-ROM library re-scraped withoverwrite=Truethrows away roughly 2.6 GiB of downloads. On ScreenScraper it costs quota rather than just bandwidth, since media is served from the same authenticated api2 endpoints as metadata lookups and routes through the same limiters (media_download_slot), so every cover spends two requests from the account's allowance instead of one. Local sources (file:///launchbox-file://) did two full file copies off the user's disk per cover._store_cover()no longer takes a size. It fetches once intobig.pngand derivessmall.pngfrom it, the waystore_artwork()already did. The local work (chroma-key check, resize, WebP conversion) still runs after the provider's request slot has been handed back.get_cover()'s signature and return value are unchanged, so all four call sites (scan socket, two collection endpoints, the ROM update endpoint) are untouched. No response schema change, so no regenerated types, no i18n, no frontend code.A missing small cover no longer triggers a fetch at all.
get_cover()downloads only when overwriting or when the large cover is absent; when the large one is already on disk and only the small is missing,_derive_small_cover()rebuilds it locally for zero requests. That keeps a good large cover out of the blast radius of a download that might fail, and drops the torn-pair case from one request to none.What a reviewer should look at hardest, in order:
get_cover(). The condition is now "URL present and (overwriting or no large cover)", with the derive path as theelif. The important property is that_store_cover()only ever clobbersbig.pngwhen the caller asked for an overwrite or there was nothing there, so no failure path can cost a large cover that was already good._derive_small_cover()resolves the source through_get_cover_path, so an uploadedbig.jpgor a convertedbig.webpproduces a matchingsmall.jpg/small.webprather than assuming.png._derive_small_cover()deliberately does not discard an unreadable large cover, where_store_cover()does. The distinction:_store_cover()just wrote those bytes itself and knows they're bad, while the derive path found them on disk and can't rule out a format PIL lacks a plugin for. It logs and drops only the partial small.overwrite=Truerefresh now leaves the oldsmall.pngbehind (big.png, being truncated, is still discarded), where the old code destroyed both.get_coverthen returns(small, None), and v2 resolvespath_cover_large ?? path_cover_small, so the ROM detail page shows the upscaled thumbnail instead of the dark placeholder until the next scan refetches. Deliberate: a network blip no longer wipes working artwork. Trade is visible, so flagging it.UnidentifiedImageErrornow discards both files. Undecodable bytes satisfycover_exists(), so leaving them on disk meant no later scan ever retried and the UI rendered a broken image permanently. Same reasoning as_discard_partial_file's docstring. AnOSErrorin the resize/convert tail is now caught too; it previously escaped into the scan. Relatedly, a non-200 on the small fetch used to reachImage.open()on a file that was never written, raising an uncaughtFileNotFoundErrorout into the scan; thedownloadedguard removes that.allow_link=Falseon the local-file copy is still load-bearing, for a new reason:big.pngis no longer resized in place, but an overwriting scan opens the destination"wb"viawrite_file_streamed, which would truncate the user's library file through a hardlink. Covered by anst_nlink == 1assertion.Fixes #4102
AI assistance disclosure: this PR was written primarily by Claude Code (Claude Opus 5), under my direction and review. AI generated the handler change, the tests, this description, and the commit message; I specified the approach, reviewed every line, and ran the verification below myself. PR responses will be written by me, with AI assistance disclosed if that changes.
Checklist
Please check all that apply.
Verification
Backend, from
backend/:Two tests asserted the old behaviour (
assert mock_store.call_count == 2) and were deleted; mocking_store_coveris exactly what let this ship. The 16 replacements inTestCoverSingleFetchuse a counting httpx client serving real PNG bytes, so the resize actually runs and the request count is the assertion: one fetch when neither cover exists, one when overwriting, one when only the small exists, zero when both exist, zero without a URL, zero when only the large exists (rebuilt from disk instead), onecopy_fileon the local branch, plus the chroma-key / dropped-connection / undecodable-bytes / unreadable-large-cover / non-PNG-extension / WebP / resize-ratio / collection-entity cases. One test pins the failure mode directly: with a large cover on disk and a provider that drops the connection, the file must come out byte-identical.End-to-end against the real handler (not mocks), driving
fs_resource_handler.get_cover()at a local server counting inbound GETs: 1 GET per cover withoverwrite=True, 0 when covers are present,big.png900x1200 andsmall.png180x240. Serving ScreenScraper's green placeholder to the same path returns(None, None)and clears a stale small left by an earlier pass.Browser (v2, light and dark, kiosk stack): platform gallery renders both covers with no failed
/cover/requests, ROM detail renders the large cover at 1000x1424. For the derive path specifically,small.pngwas deleted withbig.pngleft in place and the realget_cover()run against a request-counting local server: 0 GETs,big.pngbyte-identical by SHA, and both small covers rebuilt at the same dimensions as the originals (200x284 and 198x272, covering bothresize_cover_to_smallratio branches). The rebuilt files decode in-page at those sizes and render as clean downscales of the box art. Custom uploaded artwork goes throughstore_artwork(), which this diff doesn't touch.npm run test:e2e→ 1 failed, 19 passed. The failure (e2e/game-media-files.spec.ts:44) is pre-existing: stashing both changed files and re-running against master'sresources_handler.pyreproduces it identically.