fix(scan): let update metadata scans refresh artwork, and record uploads explicitly - #4160
fix(scan): let update metadata scans refresh artwork, and record uploads explicitly#4160sdornan wants to merge 5 commits into
Conversation
Greptile SummaryThis PR makes provider artwork refreshable during update scans, adds durable provenance locks for user-supplied resources, and publishes streamed downloads atomically.
Confidence Score: 3/5The PR should not merge until metadata unmatching clears stale resource locks and failed screenshot refreshes remain retryable. Durable locks can leave an unmatched ROM permanently coverless, and the newly enabled screenshot refresh path can persist missing files without retrying failed transfers; cancellation-related temporary-file leakage is additionally non-blocking. Files Needing Attention: backend/handler/scan_handler.py, backend/endpoints/roms/init.py, backend/handler/filesystem/resources_handler.py, backend/handler/filesystem/base_handler.py Important Files Changed
Prompt To Fix All With AI### Issue 1
backend/handler/scan_handler.py:1074-1079
**Stale lock blocks cover restoration**
When a ROM with an uploaded cover is unmatched, the endpoint clears its cover paths and URL but leaves `locked_fields` intact. The next scan sees the surviving `url_cover` lock and discards the resolved provider URL, leaving the ROM coverless until the user uploads another cover or explicitly supplies a source URL.
### Issue 2
backend/handler/scan_handler.py:1085-1088
**Failed screenshots become non-retryable**
When an update scan resolves changed screenshot URLs and a replacement download fails, `get_rom_screenshots` still records the expected destination path. On later scans the URL set is unchanged, so the failed replacement is not retried and the database continues pointing to a missing screenshot until the URLs change or resources are cleared.
### Issue 3
backend/handler/filesystem/base_handler.py:452-456
**Cancellation leaks atomic-write temp files**
When a streamed resource download is cancelled, `CancelledError` bypasses `_atomic_write`'s `except Exception` cleanup. Each cancelled download therefore leaves a `.romm_tmp_*` file in the resource directory, accumulating unused files across repeated scan cancellations or shutdowns.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Reviews (1): Last reviewed commit: "fix(scan): record hand-supplied artwork ..." | Re-trigger Greptile |
| # download step. | ||
| "url_cover": ( | ||
| rom.url_cover | ||
| if rom.path_cover_s | ||
| "" | ||
| if rom.is_field_locked("url_cover") | ||
| else rom_attrs.get("url_cover") or None | ||
| ), |
There was a problem hiding this comment.
Stale lock blocks cover restoration
When a ROM with an uploaded cover is unmatched, the endpoint clears its cover paths and URL but leaves locked_fields intact. The next scan sees the surviving url_cover lock and discards the resolved provider URL, leaving the ROM coverless until the user uploads another cover or explicitly supplies a source URL.
Knowledge Base Used: ROM Scanning Flow
Prompt To Fix With AI
This is a comment left during a code review.
Path: backend/handler/scan_handler.py
Line: 1074-1079
Comment:
**Stale lock blocks cover restoration**
When a ROM with an uploaded cover is unmatched, the endpoint clears its cover paths and URL but leaves `locked_fields` intact. The next scan sees the surviving `url_cover` lock and discards the resolved provider URL, leaving the ROM coverless until the user uploads another cover or explicitly supplies a source URL.
**Knowledge Base Used:** [ROM Scanning Flow](https://app.greptile.com/romm/-/custom-context/knowledge-base/rommapp/romm/-/docs/rom-scanning-flow.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
Does not reproduce, so leaving this open for a maintainer rather than resolving it myself.
The premise is right: unmatch_metadata clears path_cover_s, path_cover_l and url_cover while leaving locked_fields intact. The conclusion does not follow, because unmatching never deletes the cover files — remove_cover is only called on the remove_cover=true branch of update_rom.
So the next scan holds url_cover at "", get_cover skips the download, and then re-derives both paths from disk:
>>> path_cover_s='roms/1/1/cover/small.png' path_cover_l='roms/1/1/cover/big.png'
>>> files still on disk: ['big.png', 'small.png']
The rom keeps its cover, which is intended: unmatching drops provider matches, and a hand-uploaded cover is not one.
A genuinely coverless rom needs the files gone as well as the paths, which is the deliberate "my upload is mine" behaviour with documented exits (remove the cover, or name a different source). Since this is one remove_cover call away from becoming true, test_cover_with_no_source_url_is_rederived_from_disk now pins it.
Full reasoning in #4160 (comment).
There was a problem hiding this comment.
Thanks for the thorough write-up and for tracing it through. The key point I missed: get_cover re-derives paths from disk, so clearing path_cover_s in the DB doesn't make the cover disappear as long as the files are still there. The lock holds correctly in that state, and the rom keeps its cover.
The test_locked_cover_survives_unmatch test is exactly the right guardrail — it pins the one-remove_cover-call-away cliff edge so that distinction doesn't get accidentally collapsed later. Happy to defer to a maintainer on whether to resolve the original comment.
There was a problem hiding this comment.
Thanks for the detailed walkthrough — the reasoning is sound. The file-vs-path distinction is the key bit I missed: get_cover re-deriving from disk means the lock pointing at url_cover = "" is inert rather than blocking, because the paths come back from the filesystem check regardless.
The pinning test makes the one-remove_cover-call-away edge case explicit, which is exactly the right guard to have here. Happy to mark this resolved.
There was a problem hiding this comment.
Pull request overview
Improves the backend scan and resource pipeline so UPDATE metadata scans can refresh provider artwork URLs, resource downloads are written atomically (avoiding in-place truncation on interruption), and user-supplied artwork/manuals are protected via an explicit locked_fields marker (with migration backfill).
Changes:
- Add
locked_fieldsto ROMs (model + migration backfill) and thread it through scan/update and upload flows to protect user-supplied resources. - Allow UPDATE scans to adopt newly-resolved provider cover and screenshot URLs (while keeping manuals and text fields pinned).
- Make streamed resource downloads atomic by routing
write_file_streamedthrough_atomic_write, updating call sites and tests.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| backend/handler/scan_handler.py | Carries locked_fields, clears locks on COMPLETE, and updates UPDATE scan URL pinning for cover/screenshots. |
| backend/models/rom.py | Adds locked_fields column plus helper methods (is_field_locked, locked_fields_with/without). |
| backend/alembic/versions/0108_roms_locked_fields.py | Adds locked_fields column and backfills existing uploaded covers based on the old inferred marker. |
| backend/handler/filesystem/base_handler.py | Makes write_file_streamed an async context manager using _atomic_write. |
| backend/handler/filesystem/resources_handler.py | Updates streamed-download call sites to the new context manager, removes now-incorrect partial-file cleanup calls. |
| backend/endpoints/roms/init.py | Locks/unlocks cover field on upload/removal and on explicit URL handover. |
| backend/endpoints/roms/manual.py | Locks/unlocks manual field on upload, redownload, and delete. |
| backend/tests/handler/test_fastapi.py | Adds scan-level tests proving UPDATE scans now refresh scraped cover/screenshot URLs while respecting locks. |
| backend/tests/handler/filesystem/test_base_handler.py | Updates streamed-write test to use the new context-manager interface. |
| backend/tests/handler/filesystem/test_resources_handler.py | Refactors disk-full and dropped-connection tests to exercise the real atomic streamed-write path. |
| backend/tests/endpoints/roms/test_rom.py | Adds endpoint tests for cover lock lifecycle (lock on upload, unlock on removal). |
| backend/tests/endpoints/roms/test_manual.py | Adds/updates endpoint tests to assert manual lock lifecycle behavior. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
Thanks @greptileai. Two of the three are fixed in 788a7ac; the third does not reproduce. Reasoning is on each thread.
Backend suite green locally: 2834 passed, 2 skipped. Written with Claude Code, as with the PR. |
|
All three look good. P2 / P1 (screenshots): Both fixes are correct in the diff. The P3 (stale lock after unmatch): The reasoning holds. The critical piece I missed is that The |
|
Thanks @copilot. Both valid, fixed in 8499b9e. Reasoning is on the thread.
Also fixed the Backend suite green locally: 2834 passed, 2 skipped. Written with Claude Code, as with the PR. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.
Suppressed comments (1)
backend/handler/filesystem/resources_handler.py:528
- In get_rom_screenshots(), when overwrite=False but the on-disk set is short, the loop still calls _store_screenshot() for every URL, which overwrites screenshots that already exist. That contradicts the overwrite flag and can cause unnecessary downloads.
# Download and store new screenshots
path_screenshots: list[str] = []
for idx, url_screenshot in enumerate(url_screenshots):
await self._store_screenshot(rom, url_screenshot, idx)
path = self._get_screenshot_path(rom, str(idx))
|
The analysis is correct. Looking at # A single fetch writes both sizes
if url_cover and (overwrite or not has_cover_l):
await self._store_cover(entity, url_cover)
...
# Return paths for existing covers
path_cover_s = (
self._get_cover_path(entity, CoverSize.SMALL)
if self.cover_exists(entity, CoverSize.SMALL)
else None
)When The only scenario where the lock would block restoration is if the files were also deleted — but that's a complete rescan, which already clears
All three items acknowledged — two fixed, one confirmed non-reproducing with a pinning test. The PR looks good to me. |
An update scan pinned url_cover to the stored value whenever a cover file existed, and pinned url_screenshots whenever any were stored. Because scan.py gates the download on `_added_rom.url_cover != rom.url_cover`, forcing those equal made the gate structurally unreachable, so a freshly resolved url could never reach the download step. Uploading artwork clears url_cover while keeping path_cover_s, so that pairing distinguishes a hand-supplied cover from a scraped one. Test for it directly instead of for file existence, which is true of scraped covers too. Screenshots have no upload path, so the fresh set always wins. name, summary and url_manual stay pinned: none of them can yet tell a hand-edited value from a provider-written one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The upload marker was inferred: an artwork upload stores the file and clears url_cover, so a stored cover path with no url meant "user supplied this". That signal cannot survive. get_cover returns no path when the file is missing and the scan writes that straight back to path_cover_s, so a single scan run while the resources volume is unavailable erases it. The next scan reads the row as having no cover at all, adopts the provider url, and replaces the user's cover with provider art once storage returns. A resources volume left out of a container recreate is enough to lose every uploaded cover in a library. path_cover_s cannot carry this. It tracks the filesystem and is reconciled on every scan; provenance has to outlive the file. locked_fields records it durably instead. Uploading artwork or a manual locks the field, and removing it, redownloading it, or naming a source url releases it again. A complete rescan clears the locks along with the resource files it deletes, so a lock can't point at a file that is gone and block its replacement. The migration backfills the old inferred marker, which is the last point at which it can still be read. Without it the first scan after upgrading would replace every uploaded cover. Manuals are deliberately not backfilled: an uploaded manual and a scraped one share a path and neither clears url_manual, so any guess would be wrong for half the rows. Manuals stay pinned by the scan and are marked from here on. Backfill verified on MariaDB and PostgreSQL against rows in the old shape, covering uploaded, scraped, coverless, and null-url cases. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
From review. get_rom_screenshots recorded a path for every url whether or not the download landed, pointing the database at files that were never written. Only record paths that made it to disk, and decide the early return by counting files on disk rather than trusting the recorded list, so a set left short by a failed run is retried instead of being frozen by an unchanged url set. The existing with-urls test asserted the old behaviour with a stand-in that wrote nothing, so it now writes the files it claims to store. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two findings from review of update_rom. The cover and manual blocks each derived their lock change from rom.locked_fields, the pre-update state, and assigned the result. Only one block wrote locks so nothing was lost yet, but the second write to land would have discarded the first. Both now mutate one running set that is written back once, so the shape no longer depends on which blocks happen to touch locks. Naming a source url released the cover lock but had no equivalent for manuals, contradicting the described behaviour. Releasing on "a url was sent" would have been wrong: the client posts the stored urls on every save, and an upload leaves url_manual populated, so any edit to an unrelated field would have released the manual lock. Both fields now release only when the url actually changes, which is also what makes the cover case correct rather than merely accidental (an uploaded cover has an empty url, so it could never have matched the old test). Also corrects a store_ra_badge transport error that logged "fetch cover" while downloading a badge. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The name claimed a locked cover surviving an unmatch, but the test sets no lock and performs no unmatch. It cannot: get_cover takes url_cover as an argument and never reads locked_fields, so an empty url is how a lock reaches this layer. Setting one on the mock would imply a coupling that does not exist. Renamed to describe the behaviour it does cover, and the comment now points at the test carrying the other half of the chain. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
dbd3492 to
a2a2c09
Compare
Description
Part of #4002, and deliberately only part of it. Two related fixes to the
artwork path on an update metadata scan, smaller first. No provider-specific
machinery, no media hashing, no response schema touched, so no OpenAPI
regeneration.
The short version: an update scan structurally cannot replace provider-written
artwork, and the one signal protecting hand-uploaded covers can be destroyed by
an ordinary scan.
The atomic-write half that was originally here has shipped separately as #4230,
which is merged. This branch is rebased on top of it, so what remains is the
provenance work and nothing else.
Scope, on an update metadata scan:
scan.priority.cover/scan.priority.artworkchangedname/summaryThat last row is the case #4028 was about and this PR does not address it.
ScreenScraper serves media from fixed endpoints, so the url is byte-identical
after new art is uploaded and the url comparison cannot see the change. It stays
reachable the way it already is, by clearing the resource files and rescanning,
which works precisely because the url is stable. Making that cheap needs the
per-media hashes ScreenScraper returns, and that is worth proposing separately
once the pipeline underneath actually refreshes.
1. Update scans can replace provider-written artwork urls
scan_handler.pypinnedurl_coverto the stored value whenever a cover fileexisted, and pinned
url_screenshotswhenever any were stored:The download step immediately below gates on whether the url changed
(
endpoints/sockets/scan.py:604onmaster):Because the block above forces those two equal, that comparison is
structurally always false. The freshly resolved url is discarded before the
gate ever sees it, so no update scan can move a cover regardless of which
source now wins.
The comment on that block says "Don't overwrite existing manually uploaded
cover image", but
path_cover_sis set for any scraped cover too, so itprotects everything rather than just uploads.
This is provider-agnostic: it fixes IGDB, MobyGames, LaunchBox and the rest
just as much as ScreenScraper. It also fixes something easier to hit than stale
art: changing
scan.priority.coverorscan.priority.artworknever appliesto already-scanned ROMs. Concretely, enabling
box2d_back/box2d_sideforthe interactive 3D box and setting
ssfirst for covers, so the front facematches the SS back and spine, currently appears to do nothing.
Screenshots have no upload path at all, so every stored url is
provider-written and the fresh set wins. Recording them was itself lossy:
get_rom_screenshotsrecorded a path per url whether or not the downloadlanded, and decided its early return from that recorded list, so a set left
short by a failed run was frozen in place by an unchanged url set. It now
records only what reached the disk and counts files on disk to decide, so a
short set is retried. That is reachable on master through new and complete
scans; this PR adds update scans as a third trigger, which is why it is fixed
here.
2. Hand-supplied artwork is recorded explicitly
The first fix needs to tell an uploaded cover from a scraped one. There is a
signal today, since uploading artwork stores the file and clears the url
(
endpoints/roms/__init__.py:1862onmaster), so a stored path with no url means theuser supplied it.
That signal cannot survive, and this is a live bug on master rather than
something the first fix introduces.
path_cover_sis doing two incompatiblejobs. It is where the file is, which the scan reconciles against disk on every
run, and it is the provenance marker, which has to outlive the file.
get_coverreturns no path when the cover is unreadable, and
endpoints/sockets/scan.py:624onmasterwrites that straight back. So:
path_cover_sis cleared for all of them.adopts the provider url, and downloads over the user's uploaded artwork.
A resources volume left out of a container recreate is enough to lose every
uploaded cover in a library, with no user-visible cause. Verified against both
masterand this branch: identical behaviour, so it predates these changes.locked_fieldsrecords provenance durably instead, independent of what is ondisk. Uploading artwork or a manual locks the field; removing the cover,
deleting or redownloading the manual, and naming an explicit source url all
release it again. A complete rescan clears the locks along with the resource
files it deletes, so a lock can never point at a file that is gone and block
its replacement.
This is the smallest honest version of the explicit locking direction from the
review on #4028, without the user-facing half. Nothing is exposed on the rom
response yet and there is no toggle, so it replaces a hidden inference with a
hidden fact and adds no new concept for users. The lock UI, and with it
nameandsummary, can build on the same column without another migration.The migration backfill is the load-bearing part. Existing uploads are
recognisable only by the old inferred marker, and migration time is the last
point at which it can still be read. Without the backfill, the first scan after
upgrading would replace every uploaded cover in every library.
Releasing a lock
Only hand-supplied artwork is ever locked, so a lock is the one thing an update
scan could overwrite that no provider can give back. Releasing one is therefore
an explicit act rather than a scan option, and every route already exists in the
UI:
dialog.
everything else.
Naming a source releases the lock only when the url actually changes, not
merely when one is sent. The client posts the stored urls on every save, and an
upload leaves
url_manualpopulated with whatever was scraped before, so alooser test would release a manual lock the first time the user edited anything
else on the rom. Saving a dialog without choosing a different source never
releases anything.
There is deliberately no scan-level "ignore locks" switch. It would be one
global action over unrecoverable user files with no preview, and it would make
a lock untrustworthy afterwards, since no locked cover could be assumed to still
be the user's. A bulk unlock action is the composable version of that and
belongs with the lock UI.
What this does not do
nameandsummarystay pinned. Protecting a hand-edited title needs a lockthe user can set, and marking a field on edit is the
user_edited_fieldsapproach that was rejected on fix(scan): refresh stale ScreenScraper data on update metadata scans #4028. They join once there is a UI.
one share a path and neither clears
url_manual, so nothing distinguishesthe existing ones and any guess would be wrong for half the rows. New uploads
are marked from here on, so manuals can be unpinned later.
Follow-ups on #4002
#4002 stays open. This PR is the groundwork rather than the whole thing, and
each remaining piece is separable and can be judged on its own:
locked_fieldson the rom response, add a per-fieldtoggle and a bulk unlock action. That is the user-facing half of the explicit
locking direction from the fix(scan): refresh stale ScreenScraper data on update metadata scans #4028 review, and it needs no further migration.
nameandsummaryunpinned, once (1) gives users a way to protect anedited title. This is the half of [Bug] ScreenScraper "Update metadata" scan only fills in missing data, it never refreshes stale data #4002 about text fields never refreshing.
distinction to be safe.
per-media
md5injeuInfosturns "is my copy stale" into a comparisoninstead of a redownload, and ScreenScraper's
md5=request parameter mayremove even the need to store hashes. Worth proposing on its own merits as an
efficiency change, now that it is no longer carrying a correctness fix.
Happy to take these in whatever order suits, or to stop here if the groundwork
is all that is wanted.
Files modified
backend/handler/scan_handler.pylocked_fieldsthrough the scan and clear it on a complete rescan.backend/models/rom.pylocked_fieldscolumn and its read/with/without helpers.backend/alembic/versions/0108_roms_locked_fields.pybackend/handler/filesystem/resources_handler.pybackend/endpoints/roms/__init__.py,.../manual.pybackend/tests/**Testing notes
2 skipped. The 4 failures in my run are pre-existing and environmental,
and reproduce identically on
master: threeTestPeriodicTaskcases need alocal Redis, and one Hypothesis case
(
test_valid_iso_dates_parse_to_timestamp) falsifies on an ambiguousDST-fold local time.
blackandruffclean on every changed file.New tests were each checked against
masterto confirm they fail for the rightreason rather than passing vacuously:
test_update_scan_replaces_scraped_cover_urlandtest_update_scan_replaces_screenshot_urlsfail onmaster.test_update_scan_keeps_locked_cover_with_no_stored_pathis the regressiontest for the data loss in section 2.
test_update_scan_keeps_uploaded_coverandtest_update_scan_keeps_name_summary_and_manualpass both ways on purpose:they are guardrails proving the scope did not widen to uploads or text fields.
test_failed_screenshot_is_not_recordedandtest_short_screenshot_set_is_retriedcover the screenshot recording fix.test_update_rom_artwork_locks_the_cover,test_remove_cover_releases_the_lock,test_saving_without_changing_urls_keeps_locksandtest_naming_new_source_urls_releases_both_lockscover the lock lifecycle.The backfill was verified against real databases, on both supported
dialects. A fresh test database has no rows in the old shape, so the suite
would never exercise it. A scratch database was migrated to
0107, populatedwith rows in the pre-migration shape, then migrated to
0108, coveringuploaded, scraped, coverless, url-but-no-file, and uploaded-with-
NULL-urlrows. All five land correctly on MariaDB and on PostgreSQL, the latter checked
separately because
JSONBserialises differently fromJSON.One reported issue did not reproduce: a lock surviving
unmatch_metadatadoesnot leave the rom coverless, because unmatching clears the stored paths but
never deletes the files, so
get_coverre-derives them from disk.test_cover_with_no_source_url_is_rederived_from_diskpins that, since it isone
remove_covercall away from becoming true.Checklist
Please check all that apply.
AI assistance
This PR was written primarily by Claude Code (Opus 5), including the code, the
tests and this description. I reviewed the changes. The test suite and the
migration backfill checks described above were run locally against MariaDB and
PostgreSQL.