Extract has_structure_path_a as its own cached property and have
has_structure_path_b delegate to it, removing duplicated isdir checks.
detect_library_structure and get_platforms_directory now read the named
properties instead of re-implementing the roms-path check inline.
Keep the inconclusive/bad-structure fallback defaulting to Structure A so
a malformed library raises FolderStructureNotMatchException rather than
listing the bare library root as a flat list of platforms.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When a top-level `roms/` folder exists (Structure A), never detect the
library as Structure B, even if individual `<platform>/roms/` directories
also exist. This prevents existing Structure A libraries from being broken
after upgrading to 4.9.0.
- `has_structure_path_b` in `config_manager.py` now returns `False` early
when `{LIBRARY_BASE_PATH}/{ROMS_FOLDER_NAME}` is an existing directory
- `detect_library_structure()` in `platforms_handler.py` now explicitly
checks Structure A (`os.path.exists(roms_path)`) before consulting
`cnfg.has_structure_path_b`
- Updated test to assert Structure A wins when both layouts coexist
Co-authored-by: gantoine <3247106+gantoine@users.noreply.github.com>
Match the json-patch camelCase key names the Hasheous endpoint expects,
and update the lookup_rom test assertions accordingly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Folder-stored disc games (the standard Redump .cue + .bin layout, .gdi
sets, multi-bin, etc.) never matched RetroAchievements. roms_handler
builds an `ra_path` ending in `/*` when the folder has no .chd, and
RAHasherService only rescued that glob for folders containing archives.
A folder of plain uncompressed tracks fell through, so the literal `*`
reached RAHasher via create_subprocess_exec (no shell to expand it),
which failed with "Could not open track" and stored an empty ra_hash.
Resolve the `/*` glob to a single real file before spawning RAHasher:
prefer a disc descriptor (.cue/.gdi/.m3u), which RAHasher follows to the
referenced tracks, otherwise fall back to the largest file in the folder
(raw .iso/.bin, or the main file of a multi-file cartridge set). This
mirrors the existing "pick the largest .chd" handling for CHD folders.
Fixes#3497.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Annotate request_kwargs as dict[str, Any] to accept the list json payload,
and file_hashes as dict[str, str | None] so the chd_sha1_hash branch and the
md5/sha1/crc branch unify cleanly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Hasheous ByHash endpoint now accepts an array of hash objects rather
than a single one. Send the hashes of every top-level file (using
chd_sha1_hash exclusively for files that have one) to improve lookup
accuracy, instead of picking only the largest file.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The IGDB service acquires a rate-limiter slot before each request like the
other metadata services, but unlike them had no direct unit test asserting it
(IGDB is otherwise exercised via cassette-backed handler tests). Add a focused
test so the limiter call can't be silently removed or moved after the request.
https://claude.ai/code/session_01133QQuWvq8Zm25DZMP9PVr
ScreenScraper enforces a per-account *thread* (concurrency) cap rather than
a request rate. Requests can take several seconds, so spacing out request
starts at 1/s could still leave multiple requests overlapping in flight,
exceeding the cap and getting rejected with ScreenScraper's custom HTTP codes.
- Add ConcurrencyLimiter: a runtime-resizable, loop-agnostic limiter that
bounds simultaneous in-flight operations (held for the whole request via
async context manager), instead of spacing request starts.
- Switch the ScreenScraper service from the req/s RateLimiter to a module-level
ConcurrencyLimiter defaulting to a single thread.
- Recognize contributor/donor accounts: parse `ssuser.maxthreads` from each
response and raise the concurrency allowance to match, so supporters scrape
with their full thread count instead of the conservative default.
Adds unit tests for the limiter (blocking, wake-on-release, runtime resize)
and for the ScreenScraper slot-holding and thread-allowance updates.
https://claude.ai/code/session_01133QQuWvq8Zm25DZMP9PVr
Wire the existing RateLimiter into the IGDB, ScreenScraper, MobyGames and
RetroAchievements services so requests are spaced under each provider's
documented req/s cap, instead of only reacting to HTTP 429 after the fact.
- IGDB: 4 req/s (documented hard limit)
- MobyGames: 1 req/s (free-tier burst cap)
- ScreenScraper: 1 req/s (free-tier throttle)
- RetroAchievements: 4 req/s (conservative; no published hard limit)
A slot is acquired before both the initial request and the timeout/429
retry. The reactive 2s 429 backoff is kept as a fallback. Tests neutralize
the shared limiters via an autouse fixture and assert acquire() is awaited.
https://claude.ai/code/session_01133QQuWvq8Zm25DZMP9PVr
PR #3388 added hardlink-based asset import/export (os.link with a
shutil.copy2 fallback) to avoid duplicating disk space. The Dockerfile
VOLUME instruction listed each /romm subdirectory (resources, library,
assets, config, sync) separately, which makes Docker create an
independent mount point for each one — even when the user bind-mounts a
single parent at /romm. Each mount point is its own st_dev, so every
cross-directory os.link() failed with EXDEV and silently fell back to a
full copy, defeating the optimization.
Declare the parent /romm directory instead so all subdirectories share a
single filesystem and hardlinks can succeed when paths reside on the same
underlying host filesystem.