Per-platform custom library structure with non-destructive identity#3571
Per-platform custom library structure with non-destructive identity#3571gantoine wants to merge 6 commits into
Conversation
…ntity Adds opt-in, per-platform subfolder scanning (closes #2050). When enabled for a platform, RomM recurses that platform's subfolders and treats each nested file as its own ROM, instead of collapsing a subfolder into one multi-file ROM — so libraries organized into Hacks/, Translations/, Homebrew/, etc. are scanned correctly. Enable per platform in config.yml (default off, existing setups unaffected): scan: subfolders: nes: true # recurse every subfolder snes: ["Hacks", "Translations"] # recurse only these; keep others whole Key difference from a naive path-identity approach: identity is reconciled by content hash on scan. Moving or renaming a ROM between subfolders is detected by its hash against a now-missing ROM and relocated in place, so saves, play history, favorites and collection membership follow it. Falls back to path identity when hashing is unavailable (skip_hash_calculation or a non-hashable platform). A cheap size pre-filter avoids hashing every newly-seen file (e.g. on first enable). Details: - Per-platform opt-in via scan.subfolders (fs_slug -> bool | list[str]); no DB schema change. - Recursion skips hidden (dot-prefixed) folders, and keeps a folder whole as a single multi-file ROM when it holds a disc/playlist descriptor (.m3u/.cue/.gdi/.ccd/.toc) — covers cue+bin / multi-disc games. - Path-based keying for the steady-state lookup (get_roms_by_fs_name and mark_missing_roms key on full path) so identically-named files in different subfolders stay distinct; the (platform_id, fs_name) index is non-unique. - File resolution (download/delete/hash) uses the ROM's stored fs_path. - Scan log flags entries that became "missing" because a folder is now recursed, so stale entries are easy to clean up. - Frontend: the Files tab shows a ROM's on-disk Location (click-to-copy), built as a shared LocationChip mirroring HashChip's RTag-based pattern. New i18n keys added to all locales. Tests: backend unit tests for recursion (collisions, hidden folders, descriptor dirs kept whole, named-list form), full-path keying in get_roms_by_fs_name / mark_missing_roms, and hash-based relocation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sk2dzM3K9qxWBdPAeGb7us
Generalizes the opt-in subfolder scanning into a per-platform custom
library structure template, modeled on Retrom's. Replaces the
`scan.subfolders` (bool | list) flag with `filesystem.structure`
(fs_slug -> template); platforms without a template keep RomM's default
discovery (top-level files and folders), so existing setups are unaffected.
A template is relative to the platform's ROM folder (RomM already resolves
the library root, roms_folder and platform directory) and is a sequence of
`/`-separated path sections:
- a braced section is a macro, a bare section is a literal folder matched
exactly;
- the last section must be the terminal {gameFile} (each file is a game) or
{gameDir} (each folder is a single multi-file game);
- any other braced section ({region}, {category}, ...) is a wildcard
directory level that matches any folder (organizational only).
Examples:
filesystem:
structure:
nes: "{category}/{gameFile}" # roms/nes/Hacks/foo.nes
ps3: "{category}/{gameDir}" # roms/ps3/PSN/Game/
snes: "{region}/{gameFile}" # roms/snes/USA/foo.sfc
Declaring {gameFile} vs {gameDir} removes the previous disc-descriptor
guessing entirely — the user states file-vs-folder. Hidden (dot-prefixed)
folders are never descended into. Hash-based non-destructive identity
(relocate a moved/renamed game in place, preserving saves/history/favorites/
collections) is retained and now triggers for any platform with a custom
structure.
- config: parse + validate templates at load (parse_library_structure,
LibraryStructure); reject {platform}/{library} (RomM resolves those) and
malformed templates.
- fs handler: _discover_structured_roms walks literal/wildcard levels to the
terminal; _discover_default_roms preserves the default behavior.
- Tests: template parser (valid/invalid), per-platform config loading, and
structured discovery (wildcard/literal levels, file/dir terminals, depth>1,
hidden-folder skip, cross-folder name collisions). Subfolder-flag tests
replaced.
- Docs: config.example.yml documents filesystem.structure; scan.subfolders
removed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sk2dzM3K9qxWBdPAeGb7us
|
Hey @gantoine, the hash identity is clearly a better approach than mine 👍 However, the templating is a bit too restrictive in its current form and doesn't take into account libraries that contain roms in the main platform folder and 'grouping' subfolders below that. I'm sure I'm not the only one with a setup like this, e.g. templates:
With the template approach, I'd have to add at least one subfolder to each platform containing just the top-level roms My |
A single fixed-depth template can't describe a platform that holds loose
games in its root AND organizes others into grouping subfolders — `{gameFile}`
drops the grouped games, `{category}/{gameFile}` drops the loose ones. Let a
platform's `filesystem.structure` value be a list of templates; discovery is
their union, deduplicated by full path:
structure:
nes:
- "{gameFile}" # loose top-level games
- "{category}/{gameFile}" # games inside grouping subfolders
- config: accept str | list[str]; platform_structure() returns a tuple of
parsed structures; parse_platform_structures() + validation handle both forms.
- fs handler: _collect_fs_roms unions each structure's discovery, dedup by
(fs_path, fs_name).
- tests: list parsing, per-platform list config loading, and the mixed
loose+grouped discovery case.
- docs: config.example.yml leads with the mixed-layout list example.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sk2dzM3K9qxWBdPAeGb7us
|
@androosio check "Mixed layouts" section and lmk if that works for ya? |
…-0b77y7 # Conflicts: # frontend/src/locales/bg_BG/rom.json # frontend/src/locales/cs_CZ/rom.json # frontend/src/locales/de_DE/rom.json # frontend/src/locales/en_GB/rom.json # frontend/src/locales/en_US/rom.json # frontend/src/locales/es_ES/rom.json # frontend/src/locales/fr_FR/rom.json # frontend/src/locales/hu_HU/rom.json # frontend/src/locales/it_IT/rom.json # frontend/src/locales/ja_JP/rom.json # frontend/src/locales/ko_KR/rom.json # frontend/src/locales/pl_PL/rom.json # frontend/src/locales/pt_BR/rom.json # frontend/src/locales/ro_RO/rom.json # frontend/src/locales/ru_RU/rom.json # frontend/src/locales/zh_CN/rom.json # frontend/src/locales/zh_TW/rom.json
☂️ Python Coverage
Overall Coverage
New FilesNo new covered files... Modified Files
|
Perfect! thanks very much. |
|
@gantoine thanks much for pushing this out way faster than I could get to it! I started something here: https://github.com/thekiefs/romm/tree/feature/dynamic-libraries ... but I think your hash approach is much more elegant :) Slight note, I was designing for two things I don't think this PR covers.
For 1, the path it can handle looks like: But it can't handle:
Instead, could you offer a per-platform override that looks something like this: |
|
@thekiefs totally reasonable, will implement some version of that! (when i circle back to this after 5.0) |
Closes #2050. A per-platform custom library structure system, modeled on Retrom's, with content-hash (non-destructive) identity so reorganizing a library doesn't orphan saves/favorites/history/collections.
What it does
By default a platform's ROM folder is scanned one level deep (each top-level file = a game, each top-level folder = one multi-file game). A template lets you describe a deeper/organized layout instead. Opt-in per platform by
fs_slug; platforms left out keep the default behavior, so existing setups are unaffected. No DB schema change.Template syntax (Retrom-style)
A
/-separated sequence of path sections, relative to the platform's ROM folder (RomM already resolves the library root,roms_folder, and platform directory — so{library}/{platform}aren't used here and are rejected with a helpful message):{gameFile}(each file is a game) or{gameDir}(each folder is a single multi-file game);{region},{category}, …) is a wildcard directory level — matches any folder, purely organizational.Declaring
{gameFile}vs{gameDir}removes the disc-descriptor guesswork (the cue/bin ambiguity from #3565): the user states file-vs-folder explicitly. Hidden (dot-prefixed) folders are never descended into.Mixed layouts (list of templates)
A single fixed-depth template can't describe a platform that has loose games at the root and grouping subfolders below it — a very common setup. So a platform's value may be a list of templates; discovery is their union, deduplicated by full path:
This composes freely (mix file/dir terminals, multiple depths) without reintroducing arbitrary recursion.
Non-destructive identity
Identity is reconciled by content hash during the scan: moving or renaming a game within the structure is matched against a now-missing ROM and relocated in place, so its saves, play history, favorites and collection membership follow it. Falls back to path identity when hashing is unavailable (
skip_hash_calculation/ non-hashable platform). A cheap size pre-filter avoids hashing unrelated files. Only runs for platforms with a custom structure.Frontend
The Files tab shows a ROM's on-disk Location (click-to-copy) — useful for nested layouts. Built as a shared
LocationChipmirroringHashChip'sRTag-based pattern. New i18n keys in all locales; parity check passes.Implementation
config:parse_library_structure/parse_platform_structures/LibraryStructure, validated at load;platform_structure()returns the tuple of structures to union. Acceptsstr | list[str].fs handler:_discover_structured_romswalks literal/wildcard levels to the terminal;_collect_fs_romsunions across a platform's structures (dedup by full path);_discover_default_romspreserves default behavior. Each ROM records its realfs_path.db:get_roms_by_fs_name/mark_missing_romskey on full path so identically-named files in different folders stay distinct;get_roms_for_relocationfor hash matching.Testing
master(a CHD test relying onchmod 0o000denying reads — impossible as root; one auth test needing live Redis; two fulltext-search ranking tests).vue-tsc, eslint, fullvitest, and i18n parity all pass.🤖 Generated with Claude Code
https://claude.ai/code/session_01Sk2dzM3K9qxWBdPAeGb7us