Skip to content

Commit d5c985f

Browse files
committed
Merge remote-tracking branch 'origin/master' into feat/screenshot-CRUD-endpoints
2 parents 5714ba3 + 2203faa commit d5c985f

48 files changed

Lines changed: 550 additions & 28 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/pytest.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ jobs:
8080
ROMM_DB_DRIVER: ${{ matrix.db }}
8181
REDIS_HOST: 127.0.0.1
8282
REDIS_PORT: ${{ job.services.valkey.ports['6379'] }}
83+
HYPOTHESIS_PROFILE: ci
8384
run: |
8485
cd backend
8586
uv run pytest -vv --maxfail=10 --junitxml=pytest-report.xml --cov --cov-report xml:coverage.xml --cov-config=.coveragerc .

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ romm_mock
5454
backend/romm_test/resources
5555
backend/romm_test/logs
5656
.pytest_cache
57+
.hypothesis
5758
/romm_test
5859
.coverage
5960
pytest-report.xml

backend/alembic/versions/0084_roms_search_sort_indexes.py renamed to backend/alembic/versions/0084_add_roms_search_index.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
downgrade() drops every object created here in reverse order, leaving the
1818
pg_trgm extension in place since other objects may depend on it.
1919
20-
Revision ID: 0084_roms_search_sort_indexes
20+
Revision ID: 0084_add_roms_search_index
2121
Revises: 0083_rom_category_soundtrack
2222
Create Date: 2026-06-16 00:00:00.000000
2323
@@ -30,7 +30,7 @@
3030
from utils.database import is_mariadb, is_mysql, is_postgresql
3131

3232
# revision identifiers, used by Alembic.
33-
revision = "0084_roms_search_sort_indexes"
33+
revision = "0084_add_roms_search_index"
3434
down_revision = "0083_rom_category_soundtrack"
3535
branch_labels = None
3636
depends_on = None
@@ -87,6 +87,7 @@ def upgrade() -> None:
8787
sa.String(length=NAME_SORT_KEY_MAX_LENGTH),
8888
nullable=True,
8989
),
90+
if_not_exists=True,
9091
)
9192

9293
roms = sa.table(
@@ -111,15 +112,17 @@ def upgrade() -> None:
111112
[{"_id": row.id, "_key": compute_name_sort_key(row.name)} for row in batch],
112113
)
113114

114-
op.create_index("idx_roms_name_sort_key", "roms", ["name_sort_key"])
115+
op.create_index(
116+
"idx_roms_name_sort_key", "roms", ["name_sort_key"], if_not_exists=True
117+
)
115118

116119

117120
def downgrade() -> None:
118121
bind = op.get_bind()
119122

120123
# 3. name_sort_key column and its index.
121-
op.drop_index("idx_roms_name_sort_key", table_name="roms")
122-
op.drop_column("roms", "name_sort_key")
124+
op.drop_index("idx_roms_name_sort_key", table_name="roms", if_exists=True)
125+
op.drop_column("roms", "name_sort_key", if_exists=True)
123126

124127
# 2. Plain index on roms.name.
125128
with op.batch_alter_table("roms", schema=None) as batch_op:

backend/endpoints/responses/rom.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,7 @@ class RomSchema(BaseModel):
277277
fs_size_bytes: int
278278

279279
name: str | None
280+
name_sort_key: str | None
280281
slug: str | None
281282
summary: str | None
282283

backend/endpoints/roms/__init__.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@
6363
from logger.formatter import BLUE
6464
from logger.formatter import highlight as hl
6565
from logger.logger import log
66-
from models.rom import Rom, RomUserStatus
66+
from models.rom import Rom, RomUserStatus, compute_name_sort_key
6767
from utils.background_tasks import fire_and_forget
6868
from utils.database import safe_int, safe_str_to_bool
6969
from utils.filesystem import sanitize_filename
@@ -135,6 +135,7 @@ class RomUpdateForm(BaseModel):
135135
default=None, description="Raw manual metadata as JSON string."
136136
)
137137
name: str | None = None
138+
name_sort_key: str | None = None
138139
summary: str | None = None
139140
fs_name: str | None = None
140141
url_cover: str | None = None
@@ -194,6 +195,7 @@ async def parse_rom_update_form(
194195
raw_hltb_metadata: str | None = Form(default=None),
195196
raw_manual_metadata: str | None = Form(default=None),
196197
name: str | None = Form(default=None),
198+
name_sort_key: str | None = Form(default=None),
197199
summary: str | None = Form(default=None),
198200
fs_name: str | None = Form(default=None),
199201
url_cover: str | None = Form(default=None),
@@ -222,6 +224,7 @@ async def parse_rom_update_form(
222224
"raw_hltb_metadata": raw_hltb_metadata,
223225
"raw_manual_metadata": raw_manual_metadata,
224226
"name": name,
227+
"name_sort_key": name_sort_key,
225228
"summary": summary,
226229
"fs_name": fs_name,
227230
"url_cover": url_cover,
@@ -1207,6 +1210,7 @@ async def update_rom(
12071210
"hltb_id": None,
12081211
"libretro_id": None,
12091212
"name": rom.fs_name,
1213+
"name_sort_key": compute_name_sort_key(rom.fs_name),
12101214
"summary": "",
12111215
"url_screenshots": [],
12121216
"path_screenshots": [],
@@ -1391,15 +1395,27 @@ async def update_rom(
13911395
log.error(f"Invalid screenshot URL in update_rom: {str(e)}")
13921396
raise HTTPException(status_code=400, detail=str(e)) from e
13931397

1398+
name_value = form_data.name if "name" in provided_fields else rom.name
13941399
cleaned_data.update(
13951400
{
1396-
"name": form_data.name if "name" in provided_fields else rom.name,
1401+
"name": name_value,
13971402
"summary": (
13981403
form_data.summary if "summary" in provided_fields else rom.summary
13991404
),
14001405
}
14011406
)
14021407

1408+
if "name_sort_key" in provided_fields:
1409+
# The edit form always echoes the current key back, so only act when the
1410+
# user actually changed it: a value is a custom override, an empty value
1411+
# reverts to deriving from the (possibly new) name. When unchanged, leave
1412+
# it out so update_rom re-derives only if the stored key isn't custom.
1413+
submitted = (form_data.name_sort_key or "").strip()
1414+
if submitted != (rom.name_sort_key or "").strip():
1415+
cleaned_data["name_sort_key"] = compute_name_sort_key(
1416+
submitted or name_value
1417+
)
1418+
14031419
new_fs_name = str(form_data.fs_name or rom.fs_name)
14041420
new_fs_name = sanitize_filename(new_fs_name)
14051421
cleaned_data.update({"fs_name": new_fs_name})

backend/handler/database/roms_handler.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1010,7 +1010,8 @@ def get_roms_query(
10101010
order_attr = Rom.name
10111011

10121012
# Use indexed `name_sort_key` to have fast access to names without
1013-
# articles (the, a, an) and leading digits
1013+
# articles (the, a, an) and leading digits. The key is derived from
1014+
# `name` at write time, or holds a custom override when one is set.
10141015
if order_attr is Rom.name:
10151016
order_attr = Rom.name_sort_key
10161017

@@ -1203,10 +1204,16 @@ def update_rom(
12031204
data: dict,
12041205
session: Session = None, # type: ignore
12051206
) -> Rom:
1206-
# Bulk update() bypasses the ORM @validates hooks, so keep the
1207-
# columns derived from name / fs_name in sync explicitly.
1208-
if "name" in data:
1209-
data = {**data, "name_sort_key": compute_name_sort_key(data["name"])}
1207+
if "name" in data and "name_sort_key" not in data:
1208+
# Re-derive the key from the new name, but only when the stored key
1209+
# is still the derived value (i.e. not a manual override). Mirrors
1210+
# the `@validates` logic, which the bulk update() bypasses.
1211+
existing = session.query(Rom).filter_by(id=id).one()
1212+
if (
1213+
existing.name_sort_key is None
1214+
or existing.name_sort_key == compute_name_sort_key(existing.name)
1215+
):
1216+
data = {**data, "name_sort_key": compute_name_sort_key(data["name"])}
12101217

12111218
if "fs_name" in data:
12121219
parts = compute_file_name_parts(data["fs_name"])

backend/handler/metadata/base_handler.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252

5353
class BaseRom(TypedDict):
5454
name: NotRequired[str]
55+
name_sort_key: NotRequired[str | None]
5556
summary: NotRequired[str]
5657
url_cover: NotRequired[str]
5758
url_screenshots: NotRequired[list[str]]

backend/handler/metadata/gamelist_handler.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from handler.filesystem import fs_platform_handler, fs_resource_handler
1515
from logger.logger import log
1616
from models.platform import Platform
17-
from models.rom import Rom
17+
from models.rom import Rom, compute_name_sort_key
1818

1919
from .base_handler import BaseRom, MetadataHandler
2020

@@ -57,6 +57,7 @@ class GamelistMetadataMedia(TypedDict):
5757
class GamelistMetadata(GamelistMetadataMedia):
5858
rating: float | None
5959
first_release_date: str | None
60+
sort_name: str | None
6061
companies: list[str] | None
6162
franchises: list[str] | None
6263
genres: list[str] | None
@@ -182,6 +183,7 @@ def extract_metadata_from_gamelist_rom(
182183
) -> GamelistMetadata:
183184
rating_elem = game.find("rating")
184185
releasedate_elem = game.find("releasedate")
186+
sortname_elem = game.find("sortname")
185187
developer_elem = game.find("developer")
186188
publisher_elem = game.find("publisher")
187189
family_elem = game.find("family")
@@ -199,6 +201,9 @@ def extract_metadata_from_gamelist_rom(
199201
if releasedate_elem is not None and releasedate_elem.text
200202
else None
201203
)
204+
sort_name = (
205+
sortname_elem.text if sortname_elem is not None and sortname_elem.text else None
206+
)
202207
developer = (
203208
developer_elem.text
204209
if developer_elem is not None and developer_elem.text
@@ -219,6 +224,7 @@ def extract_metadata_from_gamelist_rom(
219224
return GamelistMetadata(
220225
rating=rating,
221226
first_release_date=first_release_date,
227+
sort_name=sort_name,
222228
companies=list(
223229
dict.fromkeys(
224230
pydash.compact(
@@ -385,10 +391,16 @@ def _parse_gamelist_xml(
385391
desc_elem = game.find("desc")
386392
lang_elem = game.find("lang")
387393
region_elem = game.find("region")
394+
sortname_elem = game.find("sortname")
388395

389396
name = (
390397
name_elem.text if name_elem is not None and name_elem.text else ""
391398
)
399+
sort_name = (
400+
sortname_elem.text
401+
if sortname_elem is not None and sortname_elem.text
402+
else None
403+
)
392404
summary = (
393405
desc_elem.text if desc_elem is not None and desc_elem.text else ""
394406
)
@@ -405,9 +417,11 @@ def _parse_gamelist_xml(
405417

406418
# Build ROM data
407419
rom_metadata = extract_metadata_from_gamelist_rom(game, platform)
420+
name_sort_key = compute_name_sort_key(sort_name) if sort_name else None
408421
rom_data = GamelistRom(
409422
gamelist_id=str(uuid.uuid4()),
410423
name=name,
424+
name_sort_key=name_sort_key,
411425
summary=summary,
412426
regions=regions,
413427
languages=languages,

backend/handler/scan_handler.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,7 @@ async def scan_rom(
355355
rom_attrs.update(
356356
{
357357
"name": rom.name,
358+
"name_sort_key": rom.name_sort_key,
358359
"slug": rom.slug,
359360
"summary": rom.summary,
360361
"url_cover": rom.url_cover,

backend/models/rom.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -332,11 +332,18 @@ def __init__(self, *args: Any, **kwargs: Any) -> None:
332332
super().__init__(*args, **kwargs)
333333
self._is_identifying = False
334334

335-
@validates("name")
336-
def _sync_name_sort_key(self, _key: str, name: str | None) -> str | None:
337-
"""Derive the indexed `name_sort_key` whenever `name` is assigned."""
338-
self.name_sort_key = compute_name_sort_key(name)
339-
return name
335+
@validates("name", "name_sort_key")
336+
def _sync_name_sort_key(self, key: str, value: str | None) -> str | None:
337+
"""Keep the indexed `name_sort_key` in sync with `name`"""
338+
if key == "name_sort_key":
339+
return compute_name_sort_key(value or self.name)
340+
341+
if self.name_sort_key is None or self.name_sort_key == compute_name_sort_key(
342+
self.name
343+
):
344+
self.name_sort_key = compute_name_sort_key(value)
345+
346+
return value
340347

341348
@validates("fs_name")
342349
def _sync_fs_name_parts(self, _key: str, fs_name: str) -> str:

0 commit comments

Comments
 (0)