Skip to content

Commit a303ba7

Browse files
authored
feat(ui): show Space Required size on uninstalled games before download (#1395) (#1526)
Uninstalled games now show a "Space Required" cell in the play section next to the download button, so the download footprint is visible before deciding to download — matching Steam's native game detail. The size comes from RomM's per-ROM `fs_size_bytes`, persisted locally so it is available offline: it rides the library sync UPSERT like the other server-derived facts, a completed download or a version switch tops it up between syncs, and it is surfaced in the cached game-detail payload. The cell shows only while the ROM is uninstalled and its size is known, and it leads the info row so it is never clipped. Closes #1395
1 parent f67faae commit a303ba7

28 files changed

Lines changed: 856 additions & 61 deletions

.size-limit.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,6 @@
33
"name": "dist/index.js (raw)",
44
"path": "dist/index.js",
55
"brotli": false,
6-
"limit": "775 KB"
6+
"limit": "800 KB"
77
}
88
]

docs/architecture/database-design.md

Lines changed: 20 additions & 12 deletions
Large diffs are not rendered by default.

docs/user-guide/managing-games.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ shows:
1515
- **Region / Languages** — attributes of the active version, shown in the Game Info tab when RomM has them (see below)
1616
- **BIOS status** — whether required BIOS files are present (see [BIOS Management](bios-management.md))
1717
- **Save sync status** — last sync time, conflict count, and playtime (see [Save Sync](save-sync.md))
18+
- **Space required** — for a game that isn't downloaded yet, a "Space Required" cell next to the Play button shows how
19+
much disk space the download needs. It disappears once the game is installed (mirroring how Steam hides it for
20+
installed games); it's also omitted when RomM doesn't report the size.
1821
- **Action buttons** — Download, Pause/Resume, Uninstall, Cancel, or Refresh Metadata depending on state
1922

2023
![Game detail page showing the RomM Sync panel for an installed game](../assets/screenshot-game-detail.jpg)
@@ -84,7 +87,8 @@ versions, and a version with no region/language detail simply omits the rows. Th
8487

8588
## Downloading ROMs
8689

87-
Games appear as shortcuts in your library even before the ROM file is downloaded. To download:
90+
Games appear as shortcuts in your library even before the ROM file is downloaded. Before downloading, the panel shows a
91+
**Space Required** cell next to the Play button so you know how much disk space the ROM needs. To download:
8892

8993
1. Open the game's detail page in the Steam Library
9094
2. In the RomM Sync panel, tap **Download**

py_modules/adapters/repositories/rom.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@
4242
"revision",
4343
"tags",
4444
"is_main_sibling",
45+
# The server-reported ROM size in bytes (#1395). Like the version dimensions
46+
# above (and unlike the user pins), it is a server-derived fact that rides
47+
# the UPSERT and refreshes every sync — set directly from the fetched dict,
48+
# no confirmed-else-preserved merge. NULL = unknown.
49+
"fs_size_bytes",
4550
# The fetch generation that last saw this row (#1504). Server-independent
4651
# like the pins, but it rides the UPSERT: the reporter merges it
4752
# confirmed-else-preserved before save(), so a platform apply advances it
@@ -84,6 +89,7 @@ def _row_to_rom(row: sqlite3.Row) -> Rom:
8489
revision=row["revision"],
8590
tags=tuple(json.loads(row["tags"])),
8691
is_main_sibling=bool(row["is_main_sibling"]),
92+
fs_size_bytes=row["fs_size_bytes"],
8793
last_fetch_id=row["last_fetch_id"],
8894
)
8995

@@ -155,6 +161,7 @@ def save(self, rom: Rom) -> None:
155161
rom.revision,
156162
json.dumps(list(rom.tags)),
157163
int(rom.is_main_sibling),
164+
rom.fs_size_bytes,
158165
rom.last_fetch_id,
159166
),
160167
)
@@ -218,6 +225,22 @@ def clear_all_applied_launch_options(self) -> None:
218225
"""
219226
self._conn.execute("UPDATE roms SET applied_launch_options = NULL")
220227

228+
def set_fs_size_bytes(self, rom_id: int, size: int | None) -> None:
229+
"""Write the server-reported ROM size for ``rom_id`` — the download write-back (#1395).
230+
231+
A between-syncs freshness top-up: a completed download stamps the size
232+
from the ROM detail it already fetched, so the game-detail UI shows the
233+
downloaded ROM's size without waiting for the next sync. Unlike the pin
234+
columns (``emulator_override`` / ``selected_disc`` / ``applied_launch_options``),
235+
``fs_size_bytes`` ALSO rides the sync UPSERT in :meth:`save`, so a later
236+
re-sync re-writes the same authoritative server value — the two write
237+
paths never conflict. ``size`` is the byte count, or ``None`` for SQL NULL.
238+
"""
239+
self._conn.execute(
240+
"UPDATE roms SET fs_size_bytes = ? WHERE rom_id = ?",
241+
(size, rom_id),
242+
)
243+
221244
def delete(self, rom_id: int) -> None:
222245
self._conn.execute("DELETE FROM roms WHERE rom_id = ?", (rom_id,))
223246

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
-- =============================================================================
2+
-- 021_add_rom_fs_size.sql — persist a ROM's server-reported byte size
3+
-- #1395 (show download size / space required before a ROM is downloaded)
4+
-- =============================================================================
5+
--
6+
-- RomM returns ``fs_size_bytes`` — the total on-server size of a ROM's file(s)
7+
-- — on both its list and detail responses, the same value in each. The plugin
8+
-- needs it at the frontend before a download so the game-detail UI can show how
9+
-- much space the download will take. This column carries that fact locally.
10+
--
11+
-- * roms.fs_size_bytes — the server-reported ROM size in bytes.
12+
--
13+
-- Nullable because the value is not always known:
14+
--
15+
-- * A pre-migration row has no size until the next sync re-writes it.
16+
-- * A platform the current session wholesale-skipped is not re-applied, so a
17+
-- row it never touched keeps whatever it had (NULL for a fresh row).
18+
--
19+
-- ``NULL`` = unknown; the frontend treats it as "size unknown, hide it".
20+
--
21+
-- Backfill needs no data-migration pass. The size is a server-derived fact, so
22+
-- it rides the sync UPSERT exactly like the version-metadata columns (regions /
23+
-- sibling_group_key / …, migration 008) — set directly from the fetched dict,
24+
-- no confirmed-else-preserved merge — and every applying sync re-writes the
25+
-- authoritative server value onto each row it touches. Between syncs a completed
26+
-- download also tops the value up (the download write-back reads the ROM detail
27+
-- it already fetched), so a freshly downloaded ROM shows its size without waiting
28+
-- for the next sync. The two paths never conflict: both write the same
29+
-- server-reported number.
30+
--
31+
-- Transaction-safe DDL only — the runner (adapters/sqlite_migrations.py) wraps
32+
-- BEGIN/COMMIT and stamps PRAGMA user_version = 21.
33+
-- -----------------------------------------------------------------------------
34+
ALTER TABLE roms ADD COLUMN fs_size_bytes INTEGER; -- server-reported ROM size in bytes; NULL = unknown

py_modules/domain/rom.py

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,23 @@
1010
from __future__ import annotations
1111

1212
from domain._aggregate import cosmic_aggregate
13+
from domain.version_metadata import VersionMetadata
14+
15+
# The neutral "no version metadata known" value. Shared as the ``synced`` default
16+
# because ``VersionMetadata`` is frozen/immutable, so a single instance is safe.
17+
_EMPTY_VERSION_METADATA = VersionMetadata()
1318

1419

1520
@cosmic_aggregate
1621
class Rom:
1722
"""One ROM as the plugin tracks it locally (identity + shortcut binding).
1823
1924
``sibling_group_key`` and the version dimensions (``regions`` / ``languages``
20-
/ ``revision`` / ``tags`` / ``is_main_sibling``) are server-derived facts
21-
RomM supplies per ROM — the sibling group this dump belongs to and how it
22-
differs from its siblings (region/language/revision variants). They are set
23-
at construction from the fetch and refreshed on every sync (they ride the
25+
/ ``revision`` / ``tags`` / ``is_main_sibling``), plus ``fs_size_bytes`` (the
26+
server-reported ROM size, #1395), are server-derived facts RomM supplies per
27+
ROM — the sibling group this dump belongs to, how it differs from its
28+
siblings (region/language/revision variants), and how large it is. They are
29+
set at construction from the fetch and refreshed on every sync (they ride the
2430
sync UPSERT, unlike the user-pin ``emulator_override`` / ``selected_disc``);
2531
no mutation verbs, as no local flow changes them independently of a sync.
2632
"""
@@ -46,6 +52,7 @@ class Rom:
4652
revision: str = ""
4753
tags: tuple[str, ...] = ()
4854
is_main_sibling: bool = False
55+
fs_size_bytes: int | None = None
4956

5057
@classmethod
5158
def synced(
@@ -58,18 +65,19 @@ def synced(
5865
shortcut_app_id: int | None,
5966
synced_at: str,
6067
igdb_id: int | None = None,
61-
sibling_group_key: str | None = None,
62-
regions: tuple[str, ...] = (),
63-
languages: tuple[str, ...] = (),
64-
revision: str = "",
65-
tags: tuple[str, ...] = (),
66-
is_main_sibling: bool = False,
68+
version: VersionMetadata = _EMPTY_VERSION_METADATA,
69+
fs_size_bytes: int | None = None,
6770
) -> Rom:
6871
"""Build a Rom synced from RomM at ISO timestamp ``synced_at``.
6972
7073
``shortcut_app_id`` is ``None`` for a non-representative sibling — every
7174
fetched ROM is persisted for identity + version metadata (ADR-0021), but
7275
only the group's representative carries a Steam-shortcut binding.
76+
77+
``version`` bundles the ADR-0021 server-derived version facts (sibling
78+
group key + region/language/revision/tag dimensions); it is unpacked into
79+
the ROM's flat fields here. The default empty instance is the "no version
80+
metadata known" state, so a minimal-arg sync omits it.
7381
"""
7482
if rom_id <= 0:
7583
raise ValueError("rom_id must be positive")
@@ -83,12 +91,13 @@ def synced(
8391
shortcut_app_id=shortcut_app_id,
8492
last_synced_at=synced_at,
8593
igdb_id=igdb_id,
86-
sibling_group_key=sibling_group_key,
87-
regions=regions,
88-
languages=languages,
89-
revision=revision,
90-
tags=tags,
91-
is_main_sibling=is_main_sibling,
94+
sibling_group_key=version.sibling_group_key,
95+
regions=version.regions,
96+
languages=version.languages,
97+
revision=version.revision,
98+
tags=version.tags,
99+
is_main_sibling=version.is_main_sibling,
100+
fs_size_bytes=fs_size_bytes,
92101
)
93102

94103
def record_fetch_generation(self, fetch_id: str) -> None:

py_modules/domain/shortcut_data.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,10 @@ def build_shortcuts_data(
230230
"igdb_id": rom.get("igdb_id"),
231231
"sgdb_id": rom.get("sgdb_id"),
232232
"ra_id": rom.get("ra_id"),
233+
# Server-reported ROM size in bytes (#1395), carried through so the
234+
# commit persists it on the Rom aggregate (it rides the sync UPSERT
235+
# like the version dimensions). Absent/None → "size unknown".
236+
"fs_size_bytes": rom.get("fs_size_bytes"),
233237
"cover_path": "",
234238
# The sibling-group key + version dimensions (ADR-0021), extracted by
235239
# the shared helper so the sync build and the version-switch persist
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"""VersionMetadata — the ADR-0021 server-derived version facts as one value.
2+
3+
The sibling-group identity and version dimensions RomM supplies per ROM: which
4+
sibling group a dump belongs to, and how it differs from its siblings
5+
(region/language/revision/tag variants, and whether it is the group's main
6+
sibling). Bundled here so the cohesive set travels as a single value object
7+
across the factory boundary rather than as loose parallel parameters.
8+
9+
Pure value object — no I/O, no service/adapter imports. The ``Rom`` aggregate
10+
keeps these as flat fields; this type is the parameter object its factory
11+
accepts, and the shape :func:`domain.shortcut_data.extract_version_metadata`
12+
already treats as a unit.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
from dataclasses import dataclass
18+
from typing import TYPE_CHECKING, Any
19+
20+
if TYPE_CHECKING:
21+
from collections.abc import Mapping
22+
23+
24+
@dataclass(frozen=True, slots=True)
25+
class VersionMetadata:
26+
"""The server-derived version facts for one ROM (ADR-0021).
27+
28+
``sibling_group_key`` names the sibling group this dump belongs to;
29+
``regions`` / ``languages`` / ``revision`` / ``tags`` describe how it differs
30+
from its siblings; ``is_main_sibling`` marks the group's representative dump.
31+
The defaults are the "no version metadata known" state — an empty instance is
32+
the neutral value the ROM factory falls back to.
33+
"""
34+
35+
sibling_group_key: str | None = None
36+
regions: tuple[str, ...] = ()
37+
languages: tuple[str, ...] = ()
38+
revision: str = ""
39+
tags: tuple[str, ...] = ()
40+
is_main_sibling: bool = False
41+
42+
@classmethod
43+
def from_mapping(cls, m: Mapping[str, Any], *, sibling_group_key: str | None = None) -> VersionMetadata:
44+
"""Build a ``VersionMetadata`` from a flat RomM-derived mapping.
45+
46+
Applies the same safe defaulting the sync and version-switch persist
47+
paths use (``.get(...) or default``), so a missing or ``null`` field
48+
degrades to its neutral value rather than raising. *sibling_group_key*
49+
overrides the mapping's key when truthy (the version-switch case, where
50+
the target adopts its bound group's key); a falsy override falls back to
51+
the mapping's own key.
52+
"""
53+
return cls(
54+
sibling_group_key=sibling_group_key or m.get("sibling_group_key"),
55+
regions=tuple(m.get("regions") or ()),
56+
languages=tuple(m.get("languages") or ()),
57+
revision=m.get("revision") or "",
58+
tags=tuple(m.get("tags") or ()),
59+
is_main_sibling=bool(m.get("is_main_sibling", False)),
60+
)

py_modules/services/downloads.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -514,6 +514,13 @@ def _record_install_io(self, *, rom_id, rom_detail, file_path, rom_dir, system,
514514

515515
with self._uow_factory() as uow:
516516
uow.rom_installs.save(install)
517+
# Download write-back (#1395): top up the ROM's size from the detail
518+
# we already fetched, so the game-detail UI shows it without waiting
519+
# for the next sync. Guarded on truthiness — a missing/zero size must
520+
# never overwrite a good persisted value.
521+
size = rom_detail.get("fs_size_bytes")
522+
if size:
523+
uow.roms.set_fs_size_bytes(int(rom_id), size)
517524
return file_path, None
518525

519526
def _resolve_safe_extract_dir_name(self, rom_detail: dict[str, Any]) -> str:

py_modules/services/game_detail.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,9 @@ def get_cached_game_detail(self, app_id) -> dict[str, Any]:
280280
"revision": rom.revision,
281281
"tags": list(rom.tags),
282282
"is_main_sibling": rom.is_main_sibling,
283+
# Server-reported ROM size in bytes (#1395), surfaced read-only so the
284+
# frontend can show the space a download needs. NULL = size unknown.
285+
"fs_size_bytes": rom.fs_size_bytes,
283286
}
284287

285288
async def get_bios_status(self, rom_id) -> dict[str, Any]:

0 commit comments

Comments
 (0)