Skip to content

Commit 46287bd

Browse files
authored
feat(library): generalize the franchise collection kind to virtual and sync IGDB collections
Generalizes the internal `franchise` collection kind into a single ownerless `virtual` kind carrying a `virtual_type` sub-field, and syncs the two virtual collection types RomM treats as browsable collections: `franchise` (already supported) and IGDB `collection` (RomM's default virtual type — previously not synced). **Why one `virtual` kind, not five:** RomM's virtual collections are one ownerless model with a `type` discriminator; every axis that affects sync — ownership (none), id/ROM-fetch, incremental-stampability (none — the view has no stable `updated_at`), membership shape — is identical across types. Only the display label differs, so the label becomes data (`virtual_type`) and the owner-filter, stamp-exclusion, and ROM-fetch dispatch each stay a single branch. **Scope — `genre`, `company`, `mode` are intentionally excluded.** RomM surfaces `genre`/`company` as ROM filter facets, not browsable collections, and `mode` as neither (its Collections view browses one virtual type at a time, defaulting to IGDB `collection`). The supported set is a single constant, so adding a type later is a one-line change. **Migration:** the `enabled_collections` `franchise` bucket is renamed to `virtual` (settings v10→v11). Existing franchise enables carry over losslessly — a virtual collection's id already encodes its type — so no re-login and no user action. Non-breaking. UI: the "Franchise" sub-tab becomes "Virtual" and lists both types, each row tagged with its type label ("Franchise" / "IGDB Collection"). The collection-settings UI redesign for the growing type set is tracked separately (#1539). Closes #1538.
1 parent 90bf3f5 commit 46287bd

31 files changed

Lines changed: 740 additions & 299 deletions

docs/architecture/backend-architecture.md

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -501,7 +501,7 @@ sibling row holding `shortcut_app_id` is the group's **active version**.
501501
collection.
502502

503503
**Same-named collections union into one Steam collection (#1503).** RomM enforces name uniqueness only per-table and
504-
per-`(name, user_id)`, so two enabled collections can share a display name — a user collection and a smart/franchise
504+
per-`(name, user_id)`, so two enabled collections can share a display name — a user collection and a smart/virtual
505505
collection, or (multi-user) another account's public collection the list endpoints return. Steam's collection namespace
506506
is **by-name** (`RomM: [<name>] (host)`), so both must resolve to the one Steam collection. The finalize accumulator
507507
(`_state.py` `pending_collection_memberships`) is therefore keyed by a collision-free `(collection_kind, collection_id)`
@@ -514,17 +514,34 @@ set of one and is byte-for-byte the pre-#1503 output. The union runs **after** t
514514
foreign collection is dropped from the work queue it never reaches the accumulator, so "Own" narrows what is unioned
515515
rather than changing how the union works.
516516

517+
**Collection kinds — one `virtual` kind covers RomM's browsable virtual types (#1538).** `get_collections` and
518+
`build_work_queue` group collections into three internal kinds: `user`, `smart`, and `virtual`. The `virtual` kind is
519+
RomM's ownerless `VirtualCollection` (base64 id, no `user_id`, no stable `updated_at` — never stamped), and it carries a
520+
`virtual_type` sub-field on each collection dict/setting so the UI can label the row. RomM's `VirtualCollection` has
521+
five `type` values, but the plugin syncs only the **two RomM itself surfaces as browsable collections**: IGDB
522+
`franchise` and the default IGDB `collection` (series). `genre`, `company`, and `mode` are **intentionally excluded**
523+
RomM treats `genre`/`company` as ROM _filter facets_ (not collections) and `mode` as neither, so they never appear in
524+
RomM's Collections view. The supported set is a single constant (`services/library/fetcher._SUPPORTED_VIRTUAL_TYPES`);
525+
the fetcher fetches each supported type (per-type fail-open) and merges them under the one `virtual` bucket. Because the
526+
type is baked into the base64 id, ids are globally unique across types, so one enabled-bucket keyed by id cannot
527+
collide. The owner filter, stamp-exclusion, and per-unit ROM-fetch dispatch all stay a **single `kind == "virtual"`
528+
branch**, not fanned out per type. On disk the enabled-collections bucket was renamed `franchise → virtual` by the
529+
lossless `settings.json` migration **v10 → v11** (`domain/state_migrations._migrate_v10_to_v11`): it renames the bucket
530+
key while preserving every enabled id, so a previously-enabled franchise collection stays enabled and no re-login is
531+
required. (The historical v2 → v3 split still produces the `franchise` bucket; the v10 → v11 step renames it afterwards,
532+
so that frozen step is untouched.)
533+
517534
**Collection owner-scope filter — "Own" is a sync scope, not just a display filter (#1532).** RomM's collection list
518535
endpoints return the signed-in user's own collections plus every other user's _public_ collection. The QAM
519536
`Show
520537
collections` control writes `collection_owner_scope` (`"own"` / `"all"`, default `"all"`); `get_collections` also
521538
tags each row with `is_own` so the frontend can hide foreign ones under "Own". Ownership is a pure predicate
522-
(`domain/collection_owner.is_own_collection`): a collection is own when it is a **franchise/virtual** collection (RomM's
539+
(`domain/collection_owner.is_own_collection`): a collection is own when it is a **virtual** collection (RomM's
523540
`VirtualCollection` model carries no `user_id` column — these are global/derived and belong to no one, so they always
524541
survive), when the plugin's own identity is unknown (the **degrade-to-"All" fallback**), or when the collection's
525542
`user_id` equals the stored `romm_user_id`; only user and smart collections carry a `user_id` to compare.
526543
`build_work_queue` applies the same predicate: under `"own"` **with a known identity** it drops foreign user/smart units
527-
from the queue — so a foreign collection enabled earlier is never synced — while franchise units and every unit under
544+
from the queue — so a foreign collection enabled earlier is never synced — while virtual units and every unit under
528545
`"all"` pass through unchanged. The scope filters **over** the per-kind enable state without mutating it, so switching
529546
back to `"all"` restores the prior enables. Because an **unknown identity never filters**, the feature is non-breaking:
530547
it silently no-ops until `romm_user_id` is stamped (see the ConnectionService lazy-identity note), then activates — no
@@ -622,9 +639,9 @@ weights + planned totals, via `sync_plan`) and the applying frames.
622639
zero persisted rows honestly means "every planned item is a create". On a **collection** it counts the bound members
623640
of the completion stamp's stored `member_rom_ids` (the same member set the skip replays), in one short read UoW
624641
covering every collection unit — no ROM fetch. The two sides are deliberately **asymmetric** on the empty case: a
625-
platform reports `0`, an unstamped or franchise collection is **omitted**. A collection's membership exists only in
626-
its stamp, and franchise collections are never stampable (`CollectionSyncState.stamp` accepts only `user`/`smart`), so
627-
`0` there would claim knowledge that does not exist. Absent and `0` price identically today; the distinction keeps the
642+
platform reports `0`, an unstamped or virtual collection is **omitted**. A collection's membership exists only in its
643+
stamp, and virtual collections are never stampable (`CollectionSyncState.stamp` accepts only `user`/`smart`), so `0`
644+
there would claim knowledge that does not exist. Absent and `0` price identically today; the distinction keeps the
628645
field honest for later consumers, so do not collapse it into consistency. A collection's stored member set may be
629646
**stale** if membership changed since the stamp — accepted and bounded, since this is estimate-only and a freshness
630647
probe would mean network I/O at plan time. A fourth rider, `new_shortcut_count` (#1517), is the create-side complement
@@ -686,8 +703,8 @@ weights + planned totals, via `sync_plan`) and the applying frames.
686703
pricing. Platforms are unaffected — their `bound_count` and `new_shortcut_count` are deliberately not stamp-gated.
687704
This follows directly from the `None`-not-`0` rule above and errs **long**, the safe direction; correcting it would
688705
mean asserting membership the plan does not have. A unit whose `bound_count` is absent (older backend, unstamped or
689-
franchise collection) prices as all creates, the pre-#1511 behaviour. The live-countdown weights are unaffected and
690-
stay `predicted_skip ? 0 : (collapsed_count ?? rom_count)`.
706+
virtual collection) prices as all creates, the pre-#1511 behaviour. The live-countdown weights are unaffected and stay
707+
`predicted_skip ? 0 : (collapsed_count ?? rom_count)`.
691708
- **Measured live countdown (takes over within seconds).** Once the apply is underway, `syncEta.ts` measures the
692709
**real** rate from the applying frames — one throttled sample per second over a ~30 s sliding window — and projects
693710
`remaining = (planned_total − processed) / rate`, rendered rounded **up** ("9 min left") so it never promises less

docs/user-guide/configuration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ it surfaces later as a permissions error on the affected action (save upload, de
9595
| `platforms.read` | read | Listing your platforms |
9696
| `roms.read` | read | Listing and reading ROM metadata (the library) |
9797
| `roms.user.read` | read | Your per-user ROM data — native play-session history |
98-
| `collections.read` | read | Reading your collections (user, smart, franchise) |
98+
| `collections.read` | read | Reading your collections (user, smart, virtual) |
9999
| `firmware.read` | read | Listing and downloading BIOS / firmware |
100100
| `assets.read` | read | Downloading save files |
101101
| `devices.read` | read | Reading your registered devices (device sync) |

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,8 @@ The **Collections** page splits collections into three sub-tabs plus a dedicated
222222
- **My** sub-tab — your other user-created collections
223223
- **Smart** sub-tab — filter-based collections that resolve membership at query time, so syncing always picks up the
224224
current matches
225-
- **Franchise** sub-tab — auto-generated franchise groupings (IGDB)
225+
- **Virtual** sub-tab — auto-generated groupings RomM derives from IGDB metadata: both IGDB **franchise** groupings and
226+
IGDB **collection** (series) groupings. Each row is labelled with its type (Franchise / IGDB Collection).
226227

227228
Each sub-tab shows its visible count in the section header (e.g. `MY COLLECTIONS (4)`) and lets you toggle individual
228229
collections, or use the paired **Enable All** / **Disable All** buttons to bulk-toggle just that sub-tab. The global
@@ -240,12 +241,12 @@ On a **shared RomM server** the collection list includes every other user's _pub
240241
sync, even if one was enabled earlier — switching back to **All** brings your earlier choices back, since the scope
241242
filters over your enable state rather than changing it.
242243

243-
**Franchise collections always sync** under either setting: they are auto-generated groupings that belong to no one, so
244+
**Virtual collections always sync** under either setting: they are auto-generated groupings that belong to no one, so
244245
"Own" never hides them. The filter only becomes active once the plugin knows your account identity, which it learns the
245246
first time you sign in (existing sign-ins pick it up on the next connection check). Until then, **Own** behaves exactly
246247
like **All** — it never hides a collection it can't yet attribute.
247248

248-
If two enabled collections share the same name — for example a personal collection and a smart or franchise collection
249+
If two enabled collections share the same name — for example a personal collection and a smart or virtual collection
249250
called the same thing, or (on a shared server) another account's public collection — they **merge into a single Steam
250251
collection** carrying the combined set of games. RomM allows collections to share a name, but Steam identifies a
251252
collection by its name, so the plugin unions their members rather than dropping one.

py_modules/adapters/persistence.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
from adapters.system_clock import SystemClock
1818

19-
_SETTINGS_VERSION = 10
19+
_SETTINGS_VERSION = 11
2020
_LOCK_EXT = ".lock"
2121

2222

@@ -48,11 +48,11 @@ def time(self) -> float:
4848
# behave like "All" (the non-breaking fallback).
4949
"romm_user_id": None,
5050
"enabled_platforms": {},
51-
"enabled_collections": {"user": {}, "smart": {}, "franchise": {}},
51+
"enabled_collections": {"user": {}, "smart": {}, "virtual": {}},
5252
"collection_create_platform_groups": False,
5353
# QAM collection owner-scope: ``"all"`` (default — every collection the
5454
# server lists) or ``"own"`` (only the signed-in user's own collections;
55-
# franchise/virtual collections have no owner and always survive).
55+
# virtual collections have no owner and always survive).
5656
"collection_owner_scope": "all",
5757
"preferred_region": "auto",
5858
"steam_input_mode": "default",

py_modules/adapters/romm/romm_api.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ def list_collection_roms_updated_after(
211211
# A smart collection filters on ``smart_collection_id``; a user collection
212212
# on ``collection_id`` (RomM's /api/roms accepts either alongside
213213
# ``updated_after`` — 5.0.0 endpoints/roms/__init__.py). Only user/smart
214-
# kinds ever reach here; the skip gate never probes a franchise collection.
214+
# kinds ever reach here; the skip gate never probes a virtual collection.
215215
param = "smart_collection_id" if kind == "smart" else "collection_id"
216216
quoted_after = urllib.parse.quote(updated_after)
217217
return self._client.request(

py_modules/domain/collection_owner.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@
1010
1111
Two invariants make the filter safe and non-breaking:
1212
13-
* **Franchise/virtual collections have no owner.** They are global/derived —
14-
RomM's ``VirtualCollection`` model carries no ``user_id`` column and returns
15-
them identically to every user — so they are always own and always survive
16-
an "Own" filter.
13+
* **Virtual collections have no owner.** They are global/derived — RomM's
14+
``VirtualCollection`` model carries no ``user_id`` column and returns them
15+
identically to every user — so they are always own and always survive an
16+
"Own" filter.
1717
* **Unknown identity degrades to "All".** When the plugin does not yet know its
1818
own user id (never fetched / offline), every collection is treated as own, so
1919
"Own" filters nothing rather than filtering against the wrong identity.
@@ -30,19 +30,19 @@ def is_own_collection(collection_user_id: object, own_user_id: int | None, *, ki
3030
collection_user_id:
3131
The collection's owner id (``user_id``) from the RomM listing dict, or
3232
``None``/absent. Compared by value against *own_user_id*. Only user and
33-
smart collections carry it; franchise/virtual collections never do.
33+
smart collections carry it; virtual collections never do.
3434
own_user_id:
3535
The signed-in user's own id (``settings["romm_user_id"]``), or ``None``
3636
when identity is not yet known.
3737
kind:
38-
``"user"``, ``"smart"`` or ``"franchise"``.
38+
``"user"``, ``"smart"`` or ``"virtual"``.
3939
40-
Returns ``True`` (own) when the collection is a franchise/virtual collection
41-
(no owner), when our own identity is unknown (the non-breaking fallback), or
40+
Returns ``True`` (own) when the collection is a virtual collection (no
41+
owner), when our own identity is unknown (the non-breaking fallback), or
4242
when the collection's owner id equals ours. ``False`` (foreign) only when a
4343
user/smart collection is owned by a different known id.
4444
"""
45-
if kind == "franchise":
45+
if kind == "virtual":
4646
return True
4747
if own_user_id is None:
4848
return True

py_modules/domain/collection_sync_state.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,9 @@
2727
``updated_at`` does not move for that).
2828
3. ``rom_count`` unchanged vs. both the live listing and the stored member set.
2929
30-
Only **user** and **smart** collections carry a stamp — franchise/virtual
31-
collections are auto-generated groupings with no stable ``updated_at`` and are
32-
never stamped (they always full-fetch). A thin record built whole and upserted —
30+
Only **user** and **smart** collections carry a stamp — virtual collections
31+
are auto-generated groupings with no stable ``updated_at`` and are never
32+
stamped (they always full-fetch). A thin record built whole and upserted —
3333
never a partial field mutation — so it carries a single ``stamp`` constructor and
3434
no verb-named mutators. Cleared on the same events that clear platform stamps:
3535
the local destructive flows (shortcut removal / live-shortcut reconcile) drop any

py_modules/domain/state_migrations.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ def migrate_settings(data: dict[str, Any]) -> dict[str, Any]:
3535
new_data = _migrate_v8_to_v9(new_data)
3636
if version < 10:
3737
new_data = _migrate_v9_to_v10(new_data)
38+
if version < 11:
39+
new_data = _migrate_v10_to_v11(new_data)
3840
return new_data
3941

4042

@@ -250,3 +252,36 @@ def _migrate_v9_to_v10(data: dict[str, Any]) -> dict[str, Any]:
250252
data["romm_api_token_source"] = "minted" if data.get("romm_api_token") else None
251253
data["version"] = 10
252254
return data
255+
256+
257+
def _migrate_v10_to_v11(data: dict[str, Any]) -> dict[str, Any]:
258+
"""v<11 → v11: rename the ``enabled_collections`` ``franchise`` bucket to ``virtual``.
259+
260+
The single ownerless virtual-collection kind is now ``virtual`` (it carries
261+
a ``virtual_type`` sub-field distinguishing IGDB ``franchise`` from IGDB
262+
``collection``), so the on-disk enabled bucket keyed ``franchise`` is renamed
263+
to ``virtual`` with every enabled id preserved — a previously-enabled
264+
franchise collection stays enabled and keeps syncing.
265+
266+
Value semantics: the nested ``enabled_collections`` dict is rebuilt rather
267+
than mutated in place. A file that somehow already carries a ``virtual``
268+
bucket has the franchise entries merged into it (franchise ids win on a key
269+
clash) rather than dropped — the two buckets cannot legitimately both exist
270+
before v11, so the union is the loss-free reconciliation.
271+
272+
The historical v2→v3 step still produces the ``franchise`` bucket; this step
273+
renames it afterwards, so the whole chain (v2→v3 → … → v10→v11) lands a
274+
v2-origin install on the ``virtual`` bucket without rewriting frozen history.
275+
"""
276+
collections = data.get("enabled_collections")
277+
if isinstance(collections, dict) and "franchise" in collections:
278+
rebuilt = {k: v for k, v in collections.items() if k != "franchise"}
279+
franchise_bucket = collections["franchise"]
280+
existing_virtual = rebuilt.get("virtual", {})
281+
merged = dict(existing_virtual) if isinstance(existing_virtual, dict) else {}
282+
if isinstance(franchise_bucket, dict):
283+
merged.update(franchise_bucket)
284+
rebuilt["virtual"] = merged
285+
data["enabled_collections"] = rebuilt
286+
data["version"] = 11
287+
return data

py_modules/domain/work_unit.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from typing import Any, Literal
1717

1818
UnitType = Literal["platform", "collection"]
19-
CollectionKind = Literal["user", "smart", "franchise"]
19+
CollectionKind = Literal["user", "smart", "virtual"]
2020

2121

2222
@dataclass(frozen=True)
@@ -36,7 +36,7 @@ class WorkUnit:
3636
# ``CollectionSyncState`` stamp (#742). RomM bumps this on any membership
3737
# add/remove (and a smart-criteria edit), so an equal value is the
3838
# membership-stable signal. ``None`` when the listing omits it (e.g. a
39-
# franchise collection, which is never stamped) — skip-internal, off the wire.
39+
# virtual collection, which is never stamped) — skip-internal, off the wire.
4040
collection_updated_at: str | None = None
4141
# Plan-time estimate riders (#1382 / #1511 / #1517). ``predicted_skip`` is
4242
# the plan's local-conditions guess at the fetch-time wholesale-skip gate's

0 commit comments

Comments
 (0)