Skip to content

Commit 37f0fea

Browse files
committed
Add opt-in files/siblings expansion to GET /api/roms
1 parent bb82cf4 commit 37f0fea

7 files changed

Lines changed: 191 additions & 15 deletions

File tree

backend/endpoints/responses/rom.py

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
from __future__ import annotations
22

33
import re
4+
from collections.abc import Sequence
45
from datetime import datetime, timezone
56
from typing import NotRequired, TypedDict, get_type_hints
67

78
from fastapi import Request
8-
from pydantic import ConfigDict, computed_field, field_validator
9+
from pydantic import ConfigDict, Field, computed_field, field_validator
910

1011
from endpoints.responses.assets import SaveSchema, ScreenshotSchema, StateSchema
1112
from handler.metadata.flashpoint_handler import FlashpointMetadata
@@ -18,7 +19,7 @@
1819
from handler.metadata.ra_handler import RAMetadata
1920
from handler.metadata.ss_handler import SSMetadata
2021
from models.collection import Collection
21-
from models.rom import Rom, RomArchiveMember, RomFileCategory, RomUserStatus
22+
from models.rom import Rom, RomArchiveMember, RomFile, RomFileCategory, RomUserStatus
2223

2324
from .base import BaseModel, UTCDatetime
2425

@@ -335,16 +336,50 @@ def sort_comparator(self) -> str:
335336
.lower()
336337
)
337338

339+
@classmethod
340+
def from_rom(cls, rom: Rom) -> SiblingRomSchema:
341+
return cls(
342+
id=rom.id,
343+
name=rom.name,
344+
fs_name_no_tags=rom.fs_name_no_tags,
345+
fs_name_no_ext=rom.fs_name_no_ext,
346+
)
347+
338348

339349
class SimpleRomSchema(RomSchema):
340350
sibling_ids: list[int]
351+
# `files` binds to a dedicated attribute via validation alias rather than the
352+
# `Rom.files` relationship: that keeps the full file list out of the default
353+
# gallery payload, and avoids tripping the relationship's lazy="raise" in
354+
# factory contexts where files aren't loaded. Populated only when requested.
355+
files: list[RomFileSchema] = Field(
356+
default_factory=list, validation_alias="included_files"
357+
)
358+
siblings: list[SiblingRomSchema] = Field(default_factory=list)
359+
360+
@field_validator("files")
361+
def sort_files(cls, v: list[RomFileSchema]) -> list[RomFileSchema]:
362+
return sorted(v, key=lambda x: x.file_name)
363+
364+
@field_validator("siblings")
365+
def sort_siblings(cls, v: list[SiblingRomSchema]) -> list[SiblingRomSchema]:
366+
return sorted(v, key=lambda x: x.sort_comparator)
341367

342368
@classmethod
343369
def from_orm_with_request(
344-
cls, db_rom: Rom, request: Request, sibling_ids: list[int] | None = None
370+
cls,
371+
db_rom: Rom,
372+
request: Request,
373+
sibling_ids: list[int] | None = None,
374+
files: Sequence[RomFile] | None = None,
375+
siblings: Sequence[Rom] | None = None,
345376
) -> SimpleRomSchema:
346377
db_rom = cls.populate_properties(db_rom, request)
347378
db_rom.sibling_ids = sibling_ids or [] # type: ignore
379+
db_rom.included_files = files or [] # type: ignore
380+
db_rom.siblings = ( # type: ignore
381+
[SiblingRomSchema.from_rom(s) for s in siblings] if siblings else []
382+
)
348383
return cls.model_validate(db_rom)
349384

350385
@classmethod
@@ -397,15 +432,7 @@ def from_orm_with_request(cls, db_rom: Rom, request: Request) -> DetailedRomSche
397432
db_rom = cls.populate_properties(db_rom, request)
398433

399434
sorted_siblings = sorted(
400-
(
401-
SiblingRomSchema(
402-
id=s.id,
403-
name=s.name,
404-
fs_name_no_tags=s.fs_name_no_tags,
405-
fs_name_no_ext=s.fs_name_no_ext,
406-
)
407-
for s in db_rom.sibling_roms
408-
),
435+
(SiblingRomSchema.from_rom(s) for s in db_rom.sibling_roms),
409436
key=lambda x: x.sort_comparator,
410437
)
411438
db_rom.siblings = sorted_siblings # type: ignore

backend/endpoints/roms/__init__.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,14 @@ def get_roms(
482482
description="Filter roms updated after this datetime (ISO 8601 format with timezone information)."
483483
),
484484
] = None,
485+
with_files: Annotated[
486+
bool,
487+
Query(description="Whether to include each rom's file entries."),
488+
] = False,
489+
with_siblings: Annotated[
490+
bool,
491+
Query(description="Whether to include each rom's sibling roms."),
492+
] = False,
485493
) -> CustomLimitOffsetPage[SimpleRomSchema]:
486494
"""Retrieve roms."""
487495
unfiltered_query, order_by_attr = db_rom_handler.get_roms_query(
@@ -571,15 +579,28 @@ def get_roms(
571579
rom_id_index = session.scalars(query.with_only_columns(Rom.id)).all() # type: ignore
572580

573581
def _transform(items: Sequence[Rom]) -> list[SimpleRomSchema]:
582+
rom_ids = [i.id for i in items]
574583
sibling_ids_by_rom = db_rom_handler.get_sibling_ids_for_roms(
575-
[i.id for i in items], session=session
584+
rom_ids, session=session
585+
)
586+
files_by_rom = (
587+
db_rom_handler.get_files_for_roms(rom_ids, session=session)
588+
if with_files
589+
else {}
590+
)
591+
siblings_by_rom = (
592+
db_rom_handler.get_siblings_for_roms(rom_ids, session=session)
593+
if with_siblings
594+
else {}
576595
)
577596

578597
return [
579598
SimpleRomSchema.from_orm_with_request(
580599
db_rom=item,
581600
request=request,
582601
sibling_ids=sibling_ids_by_rom.get(item.id, []),
602+
files=files_by_rom.get(item.id, []),
603+
siblings=siblings_by_rom.get(item.id, []),
583604
)
584605
for item in items
585606
]

backend/endpoints/sockets/scan.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,7 @@ async def _identify_rom(
361361
"rom_user",
362362
"last_modified",
363363
"files",
364+
"siblings",
364365
}
365366
),
366367
)
@@ -480,7 +481,14 @@ async def _identify_rom(
480481
await socket_manager.emit(
481482
"scan:scanning_rom",
482483
SimpleRomSchema.from_orm_with_factory(_added_rom).model_dump(
483-
exclude={"created_at", "updated_at", "rom_user", "last_modified", "files"}
484+
exclude={
485+
"created_at",
486+
"updated_at",
487+
"rom_user",
488+
"last_modified",
489+
"files",
490+
"siblings",
491+
}
484492
),
485493
)
486494

backend/handler/database/roms_handler.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,52 @@ def get_sibling_ids_for_roms(
231231

232232
return {rom_id: sorted(ids) for rom_id, ids in buckets.items()}
233233

234+
def get_files_for_roms(
235+
self,
236+
rom_ids: list[int],
237+
*,
238+
session: Session,
239+
) -> dict[int, list[RomFile]]:
240+
"""Return {rom_id: [RomFile, ...]} for the given rom IDs in a single query.
241+
242+
Used by the list endpoint to serialize files without relying on the
243+
query's relationship eager-load surviving pagination.
244+
"""
245+
if not rom_ids:
246+
return {}
247+
248+
files = session.scalars(
249+
select(RomFile).where(RomFile.rom_id.in_(rom_ids))
250+
).all()
251+
252+
buckets: dict[int, list[RomFile]] = {rom_id: [] for rom_id in rom_ids}
253+
for file in files:
254+
buckets[file.rom_id].append(file)
255+
256+
return buckets
257+
258+
def get_siblings_for_roms(
259+
self,
260+
rom_ids: list[int],
261+
*,
262+
session: Session,
263+
) -> dict[int, list[Rom]]:
264+
"""Return {rom_id: [sibling Rom, ...]} for the given rom IDs in a single query."""
265+
if not rom_ids:
266+
return {}
267+
268+
rows = session.execute(
269+
select(SiblingRom.rom_id, Rom)
270+
.join(Rom, Rom.id == SiblingRom.sibling_rom_id)
271+
.where(SiblingRom.rom_id.in_(rom_ids))
272+
).all()
273+
274+
buckets: dict[int, list[Rom]] = {rom_id: [] for rom_id in rom_ids}
275+
for rom_id, sibling in rows:
276+
buckets[rom_id].append(sibling)
277+
278+
return buckets
279+
234280
def filter_by_platform_id(self, query: Query, platform_id: int):
235281
return query.filter(Rom.platform_id == platform_id)
236282

backend/handler/scan_handler.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,7 @@ async def fetch_hasheous_hash_match() -> HasheousRom:
443443
"rom_user",
444444
"last_modified",
445445
"files",
446+
"siblings",
446447
}
447448
),
448449
},

backend/tests/endpoints/roms/test_rom.py

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from fastapi import status
55
from fastapi.testclient import TestClient
66

7+
from handler.database import db_rom_handler
78
from handler.filesystem.resources_handler import FSResourcesHandler
89
from handler.filesystem.roms_handler import FSRomsHandler
910
from handler.metadata.flashpoint_handler import FlashpointHandler, FlashpointRom
@@ -14,7 +15,8 @@
1415
from handler.metadata.ra_handler import RAGameRom, RAHandler
1516
from handler.metadata.ss_handler import SSHandler, SSRom
1617
from models.platform import Platform
17-
from models.rom import Rom
18+
from models.rom import Rom, RomFile
19+
from models.user import User
1820

1921
MOCK_IGDB_ID = 11111
2022
MOCK_MOBY_ID = 22222
@@ -80,6 +82,73 @@ def test_get_all_roms(
8082
items = body["items"]
8183
assert len(items) == 1
8284
assert items[0]["id"] == rom.id
85+
assert items[0]["files"] == []
86+
assert items[0]["siblings"] == []
87+
88+
89+
def test_get_all_roms_with_files(
90+
client: TestClient, access_token: str, rom: Rom, platform: Platform
91+
):
92+
db_rom_handler.add_rom_file(
93+
RomFile(
94+
rom_id=rom.id,
95+
file_name="test_rom.zip",
96+
file_path=f"{platform.slug}/roms",
97+
file_size_bytes=1024,
98+
last_modified=1700000000.0,
99+
)
100+
)
101+
102+
response = client.get(
103+
"/api/roms",
104+
headers={"Authorization": f"Bearer {access_token}"},
105+
params={"platform_id": platform.id, "with_files": True},
106+
)
107+
assert response.status_code == status.HTTP_200_OK
108+
109+
item = response.json()["items"][0]
110+
assert item["id"] == rom.id
111+
assert len(item["files"]) == 1
112+
assert item["files"][0]["file_name"] == "test_rom.zip"
113+
# with_files alone must not pull in siblings.
114+
assert item["siblings"] == []
115+
116+
117+
def test_get_all_roms_with_siblings(
118+
client: TestClient, access_token: str, platform: Platform, admin_user: User
119+
):
120+
siblings = [
121+
db_rom_handler.add_rom(
122+
Rom(
123+
platform_id=platform.id,
124+
igdb_id=424242,
125+
name=name,
126+
slug=slug,
127+
fs_name=f"{slug}.zip",
128+
fs_name_no_tags=slug,
129+
fs_name_no_ext=slug,
130+
fs_extension="zip",
131+
fs_path=f"{platform.slug}/roms",
132+
)
133+
)
134+
for name, slug in (("Game A", "game_a"), ("Game B", "game_b"))
135+
]
136+
for sibling in siblings:
137+
db_rom_handler.add_rom_user(rom_id=sibling.id, user_id=admin_user.id)
138+
139+
response = client.get(
140+
"/api/roms",
141+
headers={"Authorization": f"Bearer {access_token}"},
142+
params={"platform_id": platform.id, "with_siblings": True},
143+
)
144+
assert response.status_code == status.HTTP_200_OK
145+
146+
items = {item["id"]: item for item in response.json()["items"]}
147+
rom_a, rom_b = siblings
148+
assert [s["id"] for s in items[rom_a.id]["siblings"]] == [rom_b.id]
149+
assert [s["id"] for s in items[rom_b.id]["siblings"]] == [rom_a.id]
150+
# with_siblings alone must not pull in files.
151+
assert items[rom_a.id]["files"] == []
83152

84153

85154
def test_get_rom_content_requires_auth(client: TestClient, rom: Rom, rom_file):

frontend/src/__generated__/models/SimpleRomSchema.ts

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)