Skip to content

Commit 4e54167

Browse files
authored
feat(collections): add a collection naming mode to keep same-named types separate (#1569)
Now that franchise and IGDB-collection virtual types both sync, collections of different types that share a name silently merge into one Steam collection (#1503's by-name union) — two enabled rows collapse into one Steam collection with no way to tell them apart. This adds a user setting to opt out of that merge, and fixes a related case-collision that could drop games. ## `collection_naming_mode` A new setting in Settings → Library, default `merge` (unchanged behaviour): - **`merge`** (default) — same-named collections union into one `RomM: [<name>] (<host>)`. - **`by_label`** — each Steam collection name carries its type label so distinct groupings stay separate: `RomM: [<name> (Franchise)] (<host>)`, `RomM: [<name> (IGDB Collection)] (<host>)`, `RomM: [<name> (Smart)] (<host>)`, `RomM: [<name> (Standard)] (<host>)`. Collections that share both name and type still union. The label is injected at the reporter's union key, so the frontend create-name and the stale-collection reconcile stay derived from a single key. Switching modes renames every collection on the next normal sync — the existing complete-set reconcile deletes the old-named collections and recreates them under the new scheme; no Force Full Sync is needed. Toggle copy: "Distinguish collection types in Steam names — Adds the collection type (e.g. Franchise, IGDB Collection) to the Steam collection name so collections that share a name stay separate instead of merging into one. Applies on the next sync." ## Case-insensitive name identity Collections whose names differ only by case previously produced two separate keys under `merge`, which Steam then collapsed by its own case-insensitive collection identity — silently overwriting one with the other and dropping its games. Collection and platform name identity is now case-insensitive throughout (the reporter union, the frontend create/find, the stale-collection reconcile, and the cleanup paths), so case-variants union into a single collection instead of clobbering each other. First-seen casing is kept for display (Steam uppercases the name regardless). ## Migrations - Settings migrate to v13: the `enabled_collections` bucket keyed `user` is renamed to `standard` (lossless; merged into an existing `standard` bucket if present). - A database migration renames the stored kind on existing collection sync-state rows, so no standard collection is needlessly re-fetched after the update. Docs (syncing guide, Steam-shortcuts architecture) and the glossary are updated in the same change. Closes #1539.
1 parent a10e2ab commit 4e54167

33 files changed

Lines changed: 1139 additions & 54 deletions

CONTEXT.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,3 +294,15 @@ The ownership filter over the collection list on a shared RomM server — the `c
294294
collections). Orthogonal to the collection kind axis: it filters by owner (`is_own` / `user_id` vs `romm_user_id`), not
295295
by kind, and virtual collections have no owner so they always survive. The stored value stays `own` / `all`; only the
296296
QAM label is **Mine** / **All** (the value was left `own` to avoid a settings migration, #1539).
297+
298+
### Collection naming mode (merge / by_label)
299+
300+
How the Steam-collection **name** is formed when RomM collections share a display name across kinds — the
301+
`collection_naming_mode` setting valued `merge` (default) or `by_label`. Under **`merge`**, same-named collections union
302+
into one `RomM: [<name>] (<host>)` Steam collection (#1503). Under **`by_label`**, each name carries its **fine type
303+
label** so distinct groupings stay separate: `RomM: [<name> (Franchise)]`, `RomM: [<name> (IGDB Collection)]`,
304+
`RomM: [<name> (Smart)]`, `RomM: [<name> (Standard)]`. The label is the fine label, not the coarse kind — franchise and
305+
IGDB-collection are both `kind="virtual"`, distinguished by `virtual_type` (see the **Collection kind** entry above).
306+
Computed backend-side at the reporter's union key (`domain/collection_label.py`), so the wire payload stays name→appIds
307+
and the frontend needs no change; the mode flip is applied by the ordinary complete-set reconcile on the next normal
308+
sync (no Force Full Sync). Same-name-**within-one-label** still unions.

docs/architecture/steam-non-steam-shortcuts.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,50 @@ wipe the entire library organization. The additive create/update path stays unga
279279
still get their collections; only the destructive deletion is skipped on cancel. Steam collections are not backed up, so
280280
the safe behavior on a partial/cancelled run is to delete nothing.
281281

282+
### Collection naming mode — `merge` vs `by_label` (#1539)
283+
284+
The `romm_collection_app_ids` wire payload is `name → appIds` **only** — kind/virtual_type are collapsed away before the
285+
frontend sees them, and the frontend simply wraps each key as `RomM: [<key>] (<hostname>)`. So the Steam-collection name
286+
is decided **entirely by the reporter's dict key**, computed backend-side in
287+
`SyncReporter._resolve_collection_memberships` (`services/library/reporter.py`) from the `collection_naming_mode`
288+
setting:
289+
290+
- **`merge`** (default) — the key is the bare collection display name. Same-named RomM collections of any kind union
291+
into one `RomM: [<name>]` Steam collection (RomM permits same-named collections across kinds/users, #1503).
292+
- **`by_label`** (opt-in) — the key is `"<name> (<FineLabel>)"`, where the fine label comes from the pure
293+
`domain/collection_label.py` kernel: `Standard` / `Smart` for those kinds, and the virtual sub-type label (`Franchise`
294+
/ `IGDB Collection`) for `kind == "virtual"`. So a franchise and an IGDB collection that share a name become
295+
`RomM: [<name> (Franchise)]` and `RomM: [<name> (IGDB Collection)]` — separate Steam collections. Two collections that
296+
share **both** name and label still union.
297+
298+
The label strings **must** match the frontend collection-type vocabulary (`SUB_TAB_LABELS` / `VIRTUAL_TYPE_LABELS` in
299+
`src/components/LibraryPage.tsx`) so the type a user sees on the Collections page is the type baked into the Steam name.
300+
The reporter needs the kind/virtual_type at its union key, so `WorkUnit.virtual_type` and `CollectionMembership.kind` +
301+
`CollectionMembership.virtual_type` thread that identity through the fetcher → orchestrator → reporter.
302+
303+
**Label-format constraint:** the reconcile parses the collection name with `/^RomM: \[([^\]]+)\]/` (`src/index.tsx`), so
304+
a label must contain **no** `]` character — it sits inside the single existing bracket pair. Parens (`(Franchise)`) are
305+
safe; a bracket would truncate the parsed name and orphan the collection. Every produced label is bracket-free (asserted
306+
in `tests/domain/test_collection_label.py`).
307+
308+
**No Force Full Sync on a mode flip.** Because the create-name and the reconcile's `activeNames` both derive from the
309+
same `romm_collection_app_ids` keys, flipping the mode is applied by the ordinary **complete-set reconcile** on the next
310+
normal sync: the reporter re-emits the complete set of enabled collections under the new keys, `onSyncComplete` creates
311+
the new-named collections and deletes any old-named collection absent from the new complete set. No skip-state
312+
invalidation is involved (same mechanism owner-scope reshaping uses).
313+
314+
**Name identity is case-insensitive (#1569).** Steam collapses collection names by a **case-insensitive** identity — two
315+
collections whose display names differ only in case (`RomM: [7 up]` vs `RomM: [7 Up]`) are the same Steam collection, so
316+
creating the second silently overwrites the first and loses its games. To match, collection **and** platform name
317+
identity is treated case-insensitively **everywhere** the plugin compares names: the reporter groups both
318+
`romm_collection_app_ids` and `platform_app_ids` by a case-folded key (`str.casefold()`), keeping the first-seen
319+
original casing for display (which exact casing wins is irrelevant — Steam uppercases collection names anyway); the
320+
frontend create/find (`createOrUpdateCollections` / `createOrUpdateRomMCollections`), the cleanup matchers
321+
(`clearPlatformCollection` / `clearAllRomMCollections`), and the `onSyncComplete` stale-delete comparisons all match by
322+
`toLowerCase()`. This is always safe precisely because Steam's identity is case-insensitive: two collections differing
323+
only by case can never coexist, so there is never an ambiguous match to disambiguate. The DB is unaffected —
324+
`collection_sync_state` is keyed by `(collection_id, collection_kind)`, never by name — so there is no migration.
325+
282326
## App IDs and Artwork
283327

284328
`SteamClient.Apps.AddShortcut()` returns the real `appId`, so the plugin does **not** compute shortcut app IDs itself —

docs/user-guide/syncing-your-library.md

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -263,10 +263,24 @@ On a **shared RomM server** the collection list includes every other user's _pub
263263
first time you sign in (existing sign-ins pick it up on the next connection check). Until then, **Mine** behaves exactly
264264
like **All** — it never hides a collection it can't yet attribute.
265265

266+
#### Collections that share a name
267+
266268
If two enabled collections share the same name — for example a personal collection and a smart or virtual collection
267-
called the same thing, or (on a shared server) another account's public collection — they **merge into a single Steam
268-
collection** carrying the combined set of games. RomM allows collections to share a name, but Steam identifies a
269-
collection by its name, so the plugin unions their members rather than dropping one.
269+
called the same thing, or (on a shared server) another account's public collection — what happens depends on the
270+
**Distinguish collection types in Steam names** setting on the **Settings** page under **Library**:
271+
272+
- **Off** (default) — the same-named collections **merge into a single Steam collection** carrying the combined set of
273+
games. RomM allows collections to share a name, but Steam identifies a collection by its name, so the plugin unions
274+
their members rather than dropping one. Names that differ only in **capitalisation** ("7 up" vs "7 Up") count as the
275+
same name and merge too — Steam itself treats collection names case-insensitively.
276+
- **On** — the plugin appends the collection **type** to the Steam name, so same-named collections of different types
277+
stay **separate**. A franchise and an IGDB collection that share a name become `RomM: [<name> (Franchise)]` and
278+
`RomM: [<name> (IGDB Collection)]` — matching the Franchise / IGDB Collection / Smart / Standard labels you see on the
279+
Collections page. Collections that share both a name **and** a type still merge.
280+
281+
The setting applies on the **next normal sync** — no Force Full Sync is needed. After flipping it, run a sync and the
282+
plugin renames the affected Steam collections (and removes the old-named ones) as part of its normal end-of-sync
283+
housekeeping.
270284

271285
## Artwork
272286

main.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,9 @@ async def save_collection_platform_groups(self, enabled):
346346
async def set_collection_owner_scope(self, scope):
347347
return self._settings_service.set_collection_owner_scope(scope)
348348

349+
async def set_collection_naming_mode(self, mode):
350+
return self._settings_service.set_collection_naming_mode(mode)
351+
349352
@migration_blocked
350353
async def start_sync(self):
351354
return self._sync_service.start_sync()

py_modules/adapters/persistence.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,12 @@ def time(self) -> float:
5454
# server lists) or ``"own"`` (only the signed-in user's own collections;
5555
# virtual collections have no owner and always survive).
5656
"collection_owner_scope": "all",
57+
# Steam-collection naming mode: ``"merge"`` (default — same-named collections
58+
# of any kind union into one ``RomM: [<name>]`` collection) or ``"by_label"``
59+
# (the fine type label is appended, ``RomM: [<name> (Franchise)]``, so
60+
# same-named collections of different types stay separate). Applies on the
61+
# next normal sync via the reporter key + complete-set reconcile.
62+
"collection_naming_mode": "merge",
5763
"preferred_region": "auto",
5864
"steam_input_mode": "default",
5965
"steamgriddb_api_key": "",
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""Fine display label for a RomM collection, keyed by kind + virtual type.
2+
3+
The pure kernel behind the ``by_label`` Steam-collection naming mode: it maps a
4+
collection's ``kind`` (``standard`` / ``smart`` / ``virtual``) and, for the
5+
virtual kind, its ``virtual_type`` (``franchise`` / ``collection``) onto the
6+
short human label the reporter appends to a collection name so same-named
7+
collections of different types stay separate Steam collections
8+
(``RomM: [<name> (Franchise)] (host)``). No I/O, no state.
9+
10+
The label strings MUST match the frontend vocabulary exactly — ``SUB_TAB_LABELS``
11+
and ``VIRTUAL_TYPE_LABELS`` in ``src/components/LibraryPage.tsx`` — so the type a
12+
user sees on the Collections page is the type baked into the Steam name. Keep the
13+
two in sync.
14+
15+
Label-format safety: a label is appended inside the single bracket pair of
16+
``RomM: [<name> (<label>)]``, and the frontend reconcile parses that name with
17+
``/^RomM: \\[([^\\]]+)\\]/`` (``src/index.tsx``). So a label must contain **no**
18+
``]`` character (parens are safe, brackets would truncate the parsed name and
19+
orphan the collection). Every label below is bracket-free; the fallback
20+
capitalises a controlled kind literal, which is too.
21+
"""
22+
23+
from __future__ import annotations
24+
25+
# Coarse kind → label. Mirrors ``SUB_TAB_LABELS`` in LibraryPage.tsx.
26+
_KIND_LABELS: dict[str, str] = {
27+
"standard": "Standard",
28+
"smart": "Smart",
29+
"virtual": "Virtual",
30+
}
31+
32+
# Virtual sub-type → label. Mirrors ``VIRTUAL_TYPE_LABELS`` in LibraryPage.tsx:
33+
# "IGDB Collection" (not "Collection") disambiguates from the Collections page
34+
# it lives inside.
35+
_VIRTUAL_TYPE_LABELS: dict[str, str] = {
36+
"franchise": "Franchise",
37+
"collection": "IGDB Collection",
38+
}
39+
40+
41+
def collection_label(kind: str, virtual_type: str | None) -> str:
42+
"""Return the fine display label for a collection of ``kind`` / ``virtual_type``.
43+
44+
``standard`` → ``"Standard"``, ``smart`` → ``"Smart"``. A ``virtual``
45+
collection resolves to its virtual-type label — ``franchise`` →
46+
``"Franchise"``, ``collection`` → ``"IGDB Collection"`` — falling back to
47+
``"Virtual"`` when the type is missing or unrecognised (an older backend, a
48+
virtual type the plugin does not sync). An unknown ``kind`` degrades to the
49+
capitalised kind string (never expected for the ``standard`` / ``smart`` /
50+
``virtual`` set), and an empty kind to ``"Collection"``. Every branch is
51+
free of ``]`` so the reconcile name-parse stays intact.
52+
"""
53+
if kind == "virtual":
54+
if virtual_type is not None and virtual_type in _VIRTUAL_TYPE_LABELS:
55+
return _VIRTUAL_TYPE_LABELS[virtual_type]
56+
return _KIND_LABELS["virtual"]
57+
if kind in _KIND_LABELS:
58+
return _KIND_LABELS[kind]
59+
return kind.capitalize() if kind else "Collection"

py_modules/domain/work_unit.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,13 @@ class WorkUnit:
3131
# Collection-only: dispatches the correct list-roms endpoint at fetch time.
3232
# ``None`` is only valid when ``type == "platform"``.
3333
collection_kind: CollectionKind | None = None
34+
# Virtual-collection-only: which virtual type this unit is (``"franchise"``
35+
# or ``"collection"``), stamped from the query type at fetch time. Threaded
36+
# so the reporter can build the fine display label for the ``by_label``
37+
# Steam-collection naming mode (a ``kind == "virtual"`` unit needs the
38+
# sub-type to distinguish Franchise from IGDB Collection). ``None`` for a
39+
# platform or a standard/smart collection (their kind alone names them).
40+
virtual_type: str | None = None
3441
# Collection-only: the collection's server ``updated_at`` from the listing,
3542
# threaded so the incremental-skip gate can compare it against the stored
3643
# ``CollectionSyncState`` stamp (#742). RomM bumps this on any membership

py_modules/services/library/_state.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,18 @@ class CollectionMembership:
5454
memberships and UNIONs their resolved appIds into the one
5555
``RomM: [<name>] (host)`` Steam collection (RomM permits same-named collections
5656
across kinds/users, so the plugin merges rather than owner-filters, #1503).
57+
58+
``kind`` (``"standard"`` / ``"smart"`` / ``"virtual"``) and — for the virtual
59+
kind — ``virtual_type`` (``"franchise"`` / ``"collection"``) ride here too so
60+
the reporter can build the fine display label under the ``by_label`` naming
61+
mode (``domain.collection_label``); in the default ``merge`` mode they are
62+
unused and same-named collections still union by name.
5763
"""
5864

5965
name: str
6066
rom_ids: list[int]
67+
kind: str
68+
virtual_type: str | None = None
6169

6270

6371
@dataclass(frozen=True)

py_modules/services/library/fetcher.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ def _collection_units(
9797
enabled_ids: set[str],
9898
kind: CollectionKind,
9999
*,
100+
virtual_type: str | None = None,
100101
own_user_id: int | None = None,
101102
filter_to_own: bool = False,
102103
) -> list[WorkUnit]:
@@ -107,6 +108,12 @@ def _collection_units(
107108
queue even if it is enabled, so a scope selected over an earlier enable never
108109
syncs someone else's collection. Virtual collections have no owner and
109110
always survive (:func:`is_own_collection`).
111+
112+
*virtual_type* stamps the unit's virtual sub-type (``"franchise"`` /
113+
``"collection"``) for the ``kind == "virtual"`` caller, which fetches one
114+
type at a time and so knows it authoritatively — the same source the QAM
115+
listing uses. ``None`` for standard/smart callers (their kind alone labels
116+
them).
110117
"""
111118
units: list[WorkUnit] = []
112119
for c in collections:
@@ -123,6 +130,7 @@ def _collection_units(
123130
slug=c.get("slug", ""),
124131
rom_count=int(c.get("rom_count", len(c.get("rom_ids", [])))),
125132
collection_kind=kind,
133+
virtual_type=virtual_type,
126134
# RomM bumps the collection's updated_at on any membership change
127135
# (#742). Threaded so the skip gate compares it against the stamp;
128136
# ``None`` for a listing that omits it (e.g. virtual, never
@@ -610,15 +618,17 @@ async def _build_virtual_collection_units(self, enabled_ids: set[str]) -> list[W
610618
"""
611619
if not enabled_ids:
612620
return []
613-
collections: list[dict[str, Any]] = []
621+
units: list[WorkUnit] = []
614622
for virtual_type in _SUPPORTED_VIRTUAL_TYPES:
615623
try:
616-
collections.extend(
617-
await self._loop.run_in_executor(None, self._romm_api.list_virtual_collections, virtual_type)
624+
collections = await self._loop.run_in_executor(
625+
None, self._romm_api.list_virtual_collections, virtual_type
618626
)
619627
except Exception as e:
620628
self._logger.warning(f"Failed to fetch {virtual_type} collections for work queue: {e}")
621-
return _collection_units(collections, enabled_ids, "virtual")
629+
continue
630+
units.extend(_collection_units(collections, enabled_ids, "virtual", virtual_type=virtual_type))
631+
return units
622632

623633
async def _attach_plan_estimates(self, platform_units: list[WorkUnit]) -> list[WorkUnit]:
624634
"""Stamp each platform unit with its plan-time estimate riders (#1382).

0 commit comments

Comments
 (0)