3737 from domain .collection_sync_state import CollectionSyncState
3838 from domain .platform_sync_state import PlatformSyncState
3939 from domain .sync_run import SyncRun
40- from services .library ._state import LibrarySyncStateBox
40+ from services .library ._state import CollectionMembership , LibrarySyncStateBox
4141 from services .protocols import (
4242 ArtworkManager ,
4343 Clock ,
@@ -117,7 +117,7 @@ def _build_collection_app_ids(
117117 self ,
118118 uow : UnitOfWork ,
119119 pending_platform_rom_ids : set [int ] | None ,
120- pending_collection_memberships : dict [str , list [ int ] ],
120+ pending_collection_memberships : dict [tuple [ str , str ], CollectionMembership ],
121121 platform_names : dict [str , str ],
122122 ) -> tuple [dict [str , list [int ]], dict [str , list [int ]]]:
123123 """Build platform_app_ids and romm_collection_app_ids from ``uow.roms``.
@@ -128,14 +128,17 @@ def _build_collection_app_ids(
128128 resolved from *platform_names* (the work-queue), falling back to
129129 the slug when absent.
130130
131- RomM collections keep the per-run membership accumulator and resolve
132- each member ``rom_id`` to a Steam appId with a **sibling-group
133- fallback** (ADR-0021): a bound member uses its own binding; an unbound
134- member maps to its group's bound sibling's appId, so favouriting /
135- collecting ANY version of a game puts the game's single shortcut into
136- the Steam collection. Per-collection appIds are de-duplicated — several
137- siblings of one group collapse onto the one shortcut. The platform loop
138- still excludes rows whose ``shortcut_app_id`` is ``None``.
131+ RomM collections keep the per-run membership accumulator (keyed by a
132+ collision-free ``(collection_kind, collection_id)`` identity, name in the
133+ value) and resolve each member ``rom_id`` to a Steam appId with a
134+ **sibling-group fallback** (ADR-0021): a bound member uses its own
135+ binding; an unbound member maps to its group's bound sibling's appId, so
136+ favouriting / collecting ANY version of a game puts the game's single
137+ shortcut into the Steam collection. Per-collection appIds are
138+ de-duplicated — several siblings of one group collapse onto the one
139+ shortcut — and same-named collections UNION into the one by-name Steam
140+ collection (#1503). The platform loop still excludes rows whose
141+ ``shortcut_app_id`` is ``None``.
139142 """
140143 platform_app_ids , group_bound_app_id = self ._scan_bound_rows (uow , pending_platform_rom_ids , platform_names )
141144 romm_collection_app_ids = self ._resolve_collection_memberships (
@@ -179,27 +182,33 @@ def _note_group_binding(group_bound: dict[str, tuple[int, int]], rom: Rom) -> No
179182 def _resolve_collection_memberships (
180183 self ,
181184 uow : UnitOfWork ,
182- pending_collection_memberships : dict [str , list [ int ] ],
185+ pending_collection_memberships : dict [tuple [ str , str ], CollectionMembership ],
183186 group_bound_app_id : dict [str , int ],
184187 ) -> dict [str , list [int ]]:
185- """Resolve each collection's member rom_ids to de-duplicated appIds.
186-
187- A bound member uses its own binding; an unbound member falls back to
188- its sibling group's bound appId (ADR-0021). Collections that resolve
189- to no appId are omitted.
188+ """Resolve each collection's member rom_ids to de-duplicated appIds, UNION by name.
189+
190+ A bound member uses its own binding; an unbound member falls back to its
191+ sibling group's bound appId (ADR-0021). Steam's collection namespace is
192+ by-name, so same-named RomM collections (permitted across kinds/users,
193+ #1503) merge into one entry: their resolved appIds are UNIONed,
194+ order-preserving and de-duplicated ACROSS collections (each collection's
195+ own resolution already dedups within). The accumulator is keyed by a
196+ collision-free identity, so a same-named pair never overwrote either
197+ member set upstream. Names that resolve to no appId are omitted; the
198+ common single-collection case unions a set of one and is byte-for-byte
199+ identical to the pre-#1503 output.
190200 """
191201 romm_collection_app_ids : dict [str , list [int ]] = {}
192- for coll_name , rom_ids in pending_collection_memberships .items ():
193- seen : set [int ] = set ()
194- app_ids : list [int ] = []
195- for rid in rom_ids :
202+ seen_by_name : dict [str , set [int ]] = {}
203+ for membership in pending_collection_memberships .values ():
204+ app_ids = romm_collection_app_ids .setdefault (membership .name , [])
205+ seen = seen_by_name .setdefault (membership .name , set ())
206+ for rid in membership .rom_ids :
196207 app_id = self ._member_app_id (uow , rid , group_bound_app_id )
197208 if app_id is not None and app_id not in seen :
198209 seen .add (app_id )
199210 app_ids .append (app_id )
200- if app_ids :
201- romm_collection_app_ids [coll_name ] = app_ids
202- return romm_collection_app_ids
211+ return {name : app_ids for name , app_ids in romm_collection_app_ids .items () if app_ids }
203212
204213 @staticmethod
205214 def _member_app_id (uow : UnitOfWork , rid : int , group_bound_app_id : dict [str , int ]) -> int | None :
@@ -217,7 +226,7 @@ def _member_app_id(uow: UnitOfWork, rid: int, group_bound_app_id: dict[str, int]
217226
218227 def _finalize_per_unit_run_io (
219228 self ,
220- pending_collection_memberships : dict [str , list [ int ] ],
229+ pending_collection_memberships : dict [tuple [ str , str ], CollectionMembership ],
221230 pending_platform_rom_ids : set [int ] | None ,
222231 platform_names : dict [str , str ],
223232 stale_rom_ids : list [int ] | None = None ,
@@ -263,7 +272,7 @@ def _finalize_per_unit_run_io(
263272
264273 async def finalize_per_unit_run (
265274 self ,
266- pending_collection_memberships : dict [str , list [ int ] ],
275+ pending_collection_memberships : dict [tuple [ str , str ], CollectionMembership ],
267276 pending_platform_rom_ids : set [int ] | None ,
268277 platform_names : dict [str , str ] | None = None ,
269278 stale_rom_ids : list [int ] | None = None ,
0 commit comments