Skip to content

Per-platform custom library structure with non-destructive identity#3571

Draft
gantoine wants to merge 6 commits into
masterfrom
claude/kind-goodall-0b77y7
Draft

Per-platform custom library structure with non-destructive identity#3571
gantoine wants to merge 6 commits into
masterfrom
claude/kind-goodall-0b77y7

Conversation

@gantoine

@gantoine gantoine commented Jun 21, 2026

Copy link
Copy Markdown
Member

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.

Supersedes the earlier scan.subfolders flag (also on this branch) — that's been replaced by the template system per maintainer direction.

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.

filesystem:
  structure:
    ps3: "{category}/{gameDir}"    # roms/ps3/Disc/Game/, roms/ps3/PSN/Game/
    snes: "{region}/{gameFile}"    # roms/snes/USA/foo.sfc

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):

  • a braced section is a macro; a bare section is a literal folder matched exactly;
  • the last section must be a 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 — 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:

filesystem:
  structure:
    nes:
      - "{gameFile}"             # loose top-level games
      - "{category}/{gameFile}"  # games inside grouping subfolders (Hacks/, …)

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 LocationChip mirroring HashChip's RTag-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. Accepts str | list[str].
  • fs handler: _discover_structured_roms walks literal/wildcard levels to the terminal; _collect_fs_roms unions across a platform's structures (dedup by full path); _discover_default_roms preserves default behavior. Each ROM records its real fs_path.
  • db: get_roms_by_fs_name / mark_missing_roms key on full path so identically-named files in different folders stay distinct; get_roms_for_relocation for hash matching.

Testing

  • Backend: template parser (valid/invalid, string + list forms), per-platform config loading, structured discovery (wildcard/literal levels, file/dir terminals, depth > 1, hidden-folder skip, cross-folder collisions, mixed loose+grouped union), full-path keying, and hash-based relocation.
  • Suite green except pre-existing, environment-only failures that also fail on master (a CHD test relying on chmod 0o000 denying reads — impossible as root; one auth test needing live Redis; two fulltext-search ranking tests).
  • Frontend: vue-tsc, eslint, full vitest, and i18n parity all pass.

AI disclosure: authored with Claude Code, driven and reviewed by the maintainer.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Sk2dzM3K9qxWBdPAeGb7us

…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
@gantoine gantoine marked this pull request as draft June 21, 2026 21:22
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
@gantoine gantoine changed the title feat: opt-in per-platform subfolder scanning with non-destructive (hash-based) identity feat: per-platform custom library structure (Retrom-style) with non-destructive identity Jun 21, 2026
@androosio

Copy link
Copy Markdown
Contributor

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.

/nes
  game 01
  game 02
  Hacks subfolder
    game 03
    game 04

templates:

  • {gameFile} > the loose top-level games, but nothing inside the grouping folders.
  • {category}/{gameFile} > the grouping folders' contents, but every loose top-level game is dropped.

With the template approach, I'd have to add at least one subfolder to each platform containing just the top-level roms

My scan.subfolders: true in #3565 was the middle-ground option to handle libraries with my structure. Hoping your implementation can keep that flexibility.

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
@gantoine

Copy link
Copy Markdown
Member Author

@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
@github-actions

github-actions Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Test Results (postgresql)

    1 files  ± 0      1 suites  ±0   2m 29s ⏱️ -2s
1 812 tests +25  1 812 ✅ +25  0 💤 ±0  0 ❌ ±0 
1 814 runs  +25  1 814 ✅ +25  0 💤 ±0  0 ❌ ±0 

Results for commit 4df54ce. ± Comparison against base commit 462822c.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Test Results (mariadb)

    1 files  ± 0      1 suites  ±0   2m 12s ⏱️ -15s
1 812 tests +25  1 812 ✅ +25  0 💤 ±0  0 ❌ ±0 
1 814 runs  +25  1 814 ✅ +25  0 💤 ±0  0 ❌ ±0 

Results for commit 4df54ce. ± Comparison against base commit 462822c.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

☂️ Python Coverage

current status: ✅

Overall Coverage

Lines Covered Coverage Threshold Status
19018 13814 73% 0% 🟢

New Files

No new covered files...

Modified Files

File Coverage Status
backend/config/config_manager.py 67% 🟢
backend/endpoints/sockets/scan.py 30% 🟢
backend/handler/database/roms_handler.py 68% 🟢
backend/handler/filesystem/roms_handler.py 89% 🟢
TOTAL 63% 🟢

updated for commit: 4df54ce by action🐍

@androosio

Copy link
Copy Markdown
Contributor

@androosio check "Mixed layouts" section and lmk if that works for ya?

Perfect! thanks very much.

@gantoine gantoine changed the title feat: per-platform custom library structure (Retrom-style) with non-destructive identity Per-platform custom library structure with non-destructive identity Jun 24, 2026
@gantoine gantoine added the on-hold Pending further research or blocked by another issue label Jun 24, 2026
@thekiefs

Copy link
Copy Markdown

@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.

  1. this looks like it scopes templates to within a platform's ROM folder. RomM still resolves the library root → roms_folder → platform directory itself, using the existing conventions. I see {library} and {platform} aren't valid template variables.
  2. this also doesn't cover multiple libraries on different root disks - but I realize this is probably a rarely needed feature

For 1, the path it can handle looks like:
{LIBRARY_BASE_PATH}/roms/Atari - 2600/{whatever the template describes}

But it can't handle:
{LIBRARY_BASE_PATH}/data/games/Atari - 2600

/roms/ is what every rommapper uses today and I understand you'd like to keep existing path resolution, but this might also cause new users some heartburn - especially those with large libraries and existing setups/applications built around their existing directory.

Instead, could you offer a per-platform override that looks something like this:

filesystem:
  structure:
     atari-2600:
      path: "/data/games/Atari - 2600"

@gantoine

Copy link
Copy Markdown
Member Author

@thekiefs totally reasonable, will implement some version of that! (when i circle back to this after 5.0)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

on-hold Pending further research or blocked by another issue

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] Support platform level subfolders

4 participants