Compute the "new platforms" totals from the platforms already loaded via
get_platforms() instead of issuing one get_platform_by_fs_slug query per
platform. Fix the test fixture to report the existing platform through
get_platforms() so the mocked data matches the code path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The scan tracker computed total_platforms and total_roms over every
filesystem platform, ignoring both the selected platforms and the scan
type. For a "new platforms" scan, existing platforms are skipped inside
_identify_platform, so their ROMs never count toward scanned_roms, yet
they were all included in total_roms. This made the tracker wildly
overcount (the whole library instead of just the new platform).
Resolve the platform list before computing totals and, for NEW_PLATFORMS
scans, exclude platforms that already exist in the database so the totals
match what is actually processed.
Fixes#3599
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0158RJnc7MmAbwRz6Qiyrj5a
Removing the aspect_ratio body field left update_platform with a single
scalar Body() param. FastAPI stops embedding a lone scalar body, so the
endpoint began expecting a bare JSON string while the frontend keeps
sending {"custom_name": "..."}, producing a 422 when editing a
platform's display name in v2.
Restore the embedded-key contract with Body(embed=True), matching the
frontend payload and every sibling update endpoint. Regenerate the
frontend types (restores the Body_update_platform model) and add an
endpoint regression test.
AI assistance: written with Claude Code (Opus 4.8).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- device/init returns a relative verification_path; the client joins it
with its own origin
- Render the v2 approval screen via the named v2 router outlet (was blank)
- DevicePair: RSpinner, keyboard-accessible scope chips, scrollable scopes;
DevicePairShell uses always-light overlay tokens (no hex)
- main.py: await the cancelled log-forwarder task (suppressing
CancelledError) so its pubsub/lock cleanup finishes before shutdown.
- forwarder: only heartbeat the Redis lock while we still own it; if a
stall let another worker take it, relinquish forwarding to avoid
duplicate lines (the outer loop re-contends).
- endpoints/logs.py: derive MAX_LOG_LIMIT from LOG_BUFFER_SIZE so the
REST backfill never drifts from the producer's ring buffer.
- Logs.vue: append the download <a> to the DOM before click() (matches
the Patcher pattern) for cross-browser reliability.
- Add tests/endpoints/test_logs.py: non-admin 403, limit clamping to
[1, MAX_LOG_LIMIT], oldest-first ordering, and malformed-entry skip.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drop the name_sort_key_custom flag/migration in favour of a flagless rule: a
key is "custom" when it no longer equals compute(name). Apply that consistently
across all three write paths so a manual sort key survives renames while a
derived key keeps following the name:
- @validates re-derives on name assignment only when the stored key still
matches the derived value; direct name_sort_key assignment stores a
normalized custom key (or reverts to derived when cleared). Handles both
kwarg orders at construction.
- update_rom mirrors the same check for the bulk update() path it bypasses.
- The edit endpoint only writes the key when the user actually changed the
field, delegating the untouched case to update_rom.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Collapse the separate `sort_name` column into `name_sort_key`, which is now
the single user-settable sort field: always normalized and indexed for fast
ordering, derived from `name` by default, and overridable. A new
`name_sort_key_custom` boolean marks user/metadata overrides so they survive
renames and rescans.
- Drop the `roms.sort_name` column; repurpose migration 0085 to add
`name_sort_key_custom`.
- Derive the key via `@validates("name")` unless pinned custom; the edit
dialog, unmatch flow, and ES-DE gamelist <sortname> set custom keys.
- update_rom / scan_rom keep the columns in sync explicitly (bulk update and
construction bypass / reorder the validator).
- Frontend: edit field drives name_sort_key (empty when auto), api sends the
override only when custom, regenerated types updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Added RDropzone component for handling file uploads with a customizable interface.
- Integrated RDropzone into Patcher and Upload views, replacing previous drop zone implementations.
- Enhanced ScreenshotsTab with additional functionality for community screenshots, including visibility toggles and owner display.
- Updated styles for improved user experience and responsiveness.
- Created Storybook stories for RDropzone to demonstrate its usage and interaction.
- Implemented screenshot upload feature in the ROM management section.
- Added new API methods for uploading and removing screenshots.
- Enhanced UI to support drag-and-drop for screenshots and display uploaded images.
- Updated localization files for Russian, Simplified Chinese, and Traditional Chinese to include new screenshot-related strings.
- Improved the FilesTab and MediaTab components to manage screenshots effectively.
- Added delete functionality for user-uploaded screenshots with confirmation prompts.
- Updated styling for screenshot elements to improve user experience.
Implements RFC 8628-style device authorization so clients
(argosy-launcher, grout) can pair by display instead of manually
copying tokens. Device posts to an open /api/auth/device/init with
its identifier and requested scopes; the server returns device_code
+ user_code + QR URL. User scans QR, lands at /pair/device, approves
(optionally editing name/scopes/expiry); the device's next poll on
/api/auth/device/token returns a ClientToken bound 1:1 to a newly-
created (or deduped) Device record. Downstream endpoints
(/play-sessions, /sync/negotiate) infer device_id from the bound
token so the client doesn't have to ship it on every call.
- Migrations 0080/0081: devices.client_device_identifier (unique
per user) and client_tokens.device_id FK (ON DELETE SET NULL)
- Five new endpoints under /api/auth/device (init/pending/approve/
deny/token) with Redis-backed state, per-IP rate limits, and
RFC-compliant error codes (authorization_pending, slow_down,
expired_token, access_denied)
- HybridAuthBackend surfaces bound device_id on request.state and
bumps devices.last_seen with a 5-minute debounce
- /api/users/me returns current_device_id for bound tokens so a
device can identify itself from its token alone
- Frontend approval screen at /pair/device with editable scopes/
name/expiry (defaults to Never), 3s auto-close countdown
- ClientApiTokens settings list shows bound-device chip
- 20 i18n keys added to all 17 locales; generated models updated
- 52 new tests across 13 classes; full suite 1334 passed
Planning and review assisted by Claude Code.
- Added "esbuild" version "^0.28.1" to frontend package overrides.
- Updated "exclude-newer-package" in pyproject.toml to include "vcrpy" with a date of "2026-06-17".
- Modified uv.lock to reflect the new "vcrpy" version "8.2.1" and removed platform-specific markers for dependencies.
Adopt master's ROM schema design (sibling_roms + files, batched
get_files_for_roms / get_siblings_for_roms) while preserving the v2-branch
features master lacks: per-user is_main_sibling on siblings and audio_meta
on rom files.
Conflict resolution:
- responses/rom.py: keep master's sibling_roms/files fields; re-graft
is_main_sibling via SiblingRomSchema.from_rom(rom, is_main_sibling=...);
restore the eager-relationship fallback in
SimpleRomSchema.from_orm_with_request (None sentinel) so the v2
/{id}/simple endpoint still returns siblings/files.
- roms_handler.py: get_siblings_for_roms now left-joins RomUser and returns
(Rom, is_main_sibling) tuples; keep both branch and master file helpers.
- drop the redundant branch-only sibling_ids field and
get_sibling_data_for_roms.
- generated types resolved to match (sibling_roms + files; RomFileSchema
keeps audio_meta and gains archive_members).
- update v2 components and the RelatedGameCard mock to read sibling_roms.
- fix stale exclude={"siblings"} -> "sibling_roms" in scan emit payloads.
- re-chain the audio_meta migration as 0083 (after master's 0082) to keep a
single Alembic head.
- package.json: union of branch tooling + master dependency bumps; lock
regenerated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
saves responses now include one device_syncs entry per device that has
synced a save, not just the caller's, so clients can tell which devices
hold a save. is_current is computed per entry and the caller's own entry
is ordered first for backward compatibility.
add a saves.origin_device_id column (migration 0081) recording the
device that created a save, set on initial upload only, surfaced as
origin_device_id on the save schema.
Renaming a ROM gives its file a new internal id, but the EmulatorJS
player keeps a remembered file id ("disc") in localStorage and reuses
it on the next launch. After a rename that id is stale, so the content
download endpoint matched zero files and fell through to its multi-file
ZIP path, producing a download whose only entry was an empty .m3u
playlist. nginx's mod_zip decode step rejects the blank value (HTTP
400) and aborts the response, sending 0 bytes — which EmulatorJS
surfaces as a generic "network error" (issue #3470).
The frontend half (validating the remembered disc against the ROM's
current files) already landed on master in d1696cd04. This is the
backend half: when no files match the request, raise a clean 404
instead of building a broken empty-.m3u ZIP. This also covers a ROM
with zero files.
Add endpoint tests (auth, single-file, valid file id, stale file id
-> 404, missing rom -> 404) plus a `rom_file` fixture.
Written primarily by Claude Code.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The update_rom path already re-parses filename tags when fs_name
changes (master commit d7a896b5da), but the headline scenario from
issue #3471 — an untagged ROM renamed to add (Europe) so the region
flag appears — was never asserted; existing coverage only exercised
the tag-removal direction.
Add a test that renames the untagged rom fixture to "test_rom
(Europe).zip" and asserts regions == ["Europe"], locking down the
add-region direction described in the issue.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The five pkgj feed tests created a ROM but no RomFile, so the per-file
feeds emitted only a header and the "in response.text" assertions never
actually verified output (pre-existing failures, also red on master).
Add a top-level `.pkg` GAME file (games feeds) or a DLC-category file
(dlc feeds), mirroring the pkgi_ps3 test, so the feeds produce rows.
This also gives real coverage of the new `include_files=True` path that
these feeds rely on.
https://claude.ai/code/session_01PSXKmejPRzdxLFMN6P2QQ4
Add a shared `multi_file_rom` fixture (a game folder with multiple
RomFile rows) and an endpoint-level test that downloads it via
`GET /api/roms/{id}/content/{file_name}`. This exercises the multi-file
download path end-to-end, which builds each mod_zip manifest entry from
`file.rom.full_path` after the handler session has closed — the exact
path that 500'd with `DetachedInstanceError` before the backref fix.
The download endpoint had no test coverage for multi-file ROMs (the
`rom` fixture has no RomFile rows), which is why the regression slipped
through. Reuse the new fixture in the handler-level regression test too.
https://claude.ai/code/session_01PSXKmejPRzdxLFMN6P2QQ4
The server datetime-tags every slot upload's filename (archival spec), so a
slot accrues many rows and the stored file_name never equals the client's
untagged canonical name. Keying negotiate's server-save map on file_name meant
every client save missed -> perpetual "upload", and every tagged server row
went unmatched -> perpetual "download", with save rows growing unbounded.
Pair on (rom_id, slot), collapsing each slot to its newest row, so
compare_save_state actually runs and content hashes decide the action.
Tests: real upload->negotiate round-trip (lets _apply_datetime_tag run, client
reports the untagged name) and a 3-device convergence test; both fail against
the old file_name keying.
Cleanup pass on save-sync addressing three independent failure modes
that interact in production data: content_hash drift between client
and server, null-slot archival saves leaking into sync flows, and
content-hash dedupe collapsing legitimately-distinct slots.
Bug fixes
- compute_content_hash dispatched on zipfile.is_zipfile(relative_path),
which silently returned False whenever the process's CWD wasn't
ASSETS_BASE_PATH. Every zip save fell through to the raw-MD5 branch,
persisting hashes that disagreed with clients computing the intended
per-entry zip-hash. Resolve to a full path before the dispatch.
- _build_negotiate_plan, sync_push_pull_task, and sync_watcher all
treated null-slot saves as sync-eligible. Null-slot saves represent
web-UI / archival uploads; including them in negotiate plans matched
them against device pushes by filename and overwrote archival data.
Filter null-slot saves at all three call sites.
- get_save_by_content_hash matched on (rom_id, user_id, content_hash)
only, so identical bytes uploaded to different slots collapsed into
one record. Scope the lookup by slot when provided so clone-save-
to-new-slot creates a distinct row per slot.
- get_save_by_filename matched on (rom_id, user_id, file_name) only.
When two uploads to different slots happened in the same wall-clock
second (the datetime tag is per-second), the second upload UPDATED
the first record's slot instead of creating a distinct row. Scope
the filename lookup by slot too.
One-shot recovery
- New recompute_save_content_hashes manual task walks every Save row,
recomputes via the fixed dispatch, and updates rows whose values
differ. Idempotent; safe to re-run.
- Backend startup runs a COUNT(content_hash IS NULL) query and, if
any rows exist, enqueues the recompute task on the low-priority
RQ queue. The API process moves on; the worker handles the
recompute out-of-band. Subsequent restarts find zero NULL hashes
and skip. Admins can also trigger the task manually.
Test infrastructure
- Added tests/_zipfile_shim.reload_zipfile() mirroring the pattern
from utils/zip_cache.py for the same zipfile-inflate64 + CPython
3.13.5 incompatibility. Test fixtures that build ZIPs call it
immediately before opening the archive.
Adds rejection + acceptance tests for update_rom, add_collection, and
update_collection artwork uploads, mirroring the existing avatar tests:
non-image content returns 400, and a real PNG uploaded under a misleading
filename like payload.html is stored with the trusted .png extension.
Also fixes two `return HTTPException(...)` → `raise` in raw.py so the 404
path actually surfaces instead of silently returning the exception object.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Avatar, ROM artwork, and collection artwork uploads now sniff the file
header with libmagic and reject anything that isn't PNG/JPEG/WebP/GIF,
saving the file with an extension derived from the detected MIME rather
than the user-supplied filename. Pairs with the raw asset endpoint,
which decides inline vs attachment from the on-disk extension.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Renaming a rom via PUT /roms/{id} only updated fs_name and its
derivatives, leaving regions/languages/tags/revision/version stale
against the new filename. Re-parse them whenever fs_name changes so
edits like "patapon (Fr En)" -> "Patapon (Fr, En)" are reflected in
the database.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>