Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
downgrade() drops every object created here in reverse order, leaving the
pg_trgm extension in place since other objects may depend on it.

Revision ID: 0084_add_roms_search_and_sort_indexes
Revision ID: 0084_add_roms_search_index
Revises: 0083_rom_category_soundtrack
Create Date: 2026-06-16 00:00:00.000000

Expand All @@ -30,7 +30,7 @@
from utils.database import is_mariadb, is_mysql, is_postgresql

# revision identifiers, used by Alembic.
revision = "0084_add_roms_search_and_sort_indexes"
revision = "0084_add_roms_search_index"
Comment thread
gantoine marked this conversation as resolved.
down_revision = "0083_rom_category_soundtrack"
branch_labels = None
depends_on = None
Expand Down Expand Up @@ -87,6 +87,7 @@ def upgrade() -> None:
sa.String(length=NAME_SORT_KEY_MAX_LENGTH),
nullable=True,
),
if_not_exists=True,
)

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

op.create_index("idx_roms_name_sort_key", "roms", ["name_sort_key"])
op.create_index(
"idx_roms_name_sort_key", "roms", ["name_sort_key"], if_not_exists=True
)


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

# 3. name_sort_key column and its index.
op.drop_index("idx_roms_name_sort_key", table_name="roms")
op.drop_column("roms", "name_sort_key")
op.drop_index("idx_roms_name_sort_key", table_name="roms", if_exists=True)
op.drop_column("roms", "name_sort_key", if_exists=True)

# 2. Plain index on roms.name.
with op.batch_alter_table("roms", schema=None) as batch_op:
Expand Down
1 change: 1 addition & 0 deletions backend/endpoints/responses/rom.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,7 @@ class RomSchema(BaseModel):
fs_size_bytes: int

name: str | None
name_sort_key: str | None
slug: str | None
summary: str | None

Expand Down
20 changes: 18 additions & 2 deletions backend/endpoints/roms/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@
from logger.formatter import BLUE
from logger.formatter import highlight as hl
from logger.logger import log
from models.rom import Rom, RomUserStatus
from models.rom import Rom, RomUserStatus, compute_name_sort_key
from utils.background_tasks import fire_and_forget
from utils.database import safe_int, safe_str_to_bool
from utils.filesystem import sanitize_filename
Expand Down Expand Up @@ -133,6 +133,7 @@ class RomUpdateForm(BaseModel):
default=None, description="Raw manual metadata as JSON string."
)
name: str | None = None
name_sort_key: str | None = None
summary: str | None = None
fs_name: str | None = None
url_cover: str | None = None
Expand Down Expand Up @@ -192,6 +193,7 @@ async def parse_rom_update_form(
raw_hltb_metadata: str | None = Form(default=None),
raw_manual_metadata: str | None = Form(default=None),
name: str | None = Form(default=None),
name_sort_key: str | None = Form(default=None),
summary: str | None = Form(default=None),
fs_name: str | None = Form(default=None),
url_cover: str | None = Form(default=None),
Expand Down Expand Up @@ -220,6 +222,7 @@ async def parse_rom_update_form(
"raw_hltb_metadata": raw_hltb_metadata,
"raw_manual_metadata": raw_manual_metadata,
"name": name,
"name_sort_key": name_sort_key,
"summary": summary,
"fs_name": fs_name,
"url_cover": url_cover,
Expand Down Expand Up @@ -1205,6 +1208,7 @@ async def update_rom(
"hltb_id": None,
"libretro_id": None,
"name": rom.fs_name,
"name_sort_key": compute_name_sort_key(rom.fs_name),
"summary": "",
"url_screenshots": [],
"path_screenshots": [],
Expand Down Expand Up @@ -1389,15 +1393,27 @@ async def update_rom(
log.error(f"Invalid screenshot URL in update_rom: {str(e)}")
raise HTTPException(status_code=400, detail=str(e)) from e

name_value = form_data.name if "name" in provided_fields else rom.name
cleaned_data.update(
{
"name": form_data.name if "name" in provided_fields else rom.name,
"name": name_value,
"summary": (
form_data.summary if "summary" in provided_fields else rom.summary
),
}
)

if "name_sort_key" in provided_fields:
# The edit form always echoes the current key back, so only act when the
# user actually changed it: a value is a custom override, an empty value
# reverts to deriving from the (possibly new) name. When unchanged, leave
# it out so update_rom re-derives only if the stored key isn't custom.
submitted = (form_data.name_sort_key or "").strip()
if submitted != (rom.name_sort_key or "").strip():
cleaned_data["name_sort_key"] = compute_name_sort_key(
submitted or name_value
)

new_fs_name = str(form_data.fs_name or rom.fs_name)
new_fs_name = sanitize_filename(new_fs_name)
cleaned_data.update({"fs_name": new_fs_name})
Expand Down
17 changes: 12 additions & 5 deletions backend/handler/database/roms_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -1010,7 +1010,8 @@ def get_roms_query(
order_attr = Rom.name

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

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

if "fs_name" in data:
parts = compute_file_name_parts(data["fs_name"])
Expand Down
1 change: 1 addition & 0 deletions backend/handler/metadata/base_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@

class BaseRom(TypedDict):
name: NotRequired[str]
name_sort_key: NotRequired[str | None]
summary: NotRequired[str]
url_cover: NotRequired[str]
url_screenshots: NotRequired[list[str]]
Expand Down
16 changes: 15 additions & 1 deletion backend/handler/metadata/gamelist_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from handler.filesystem import fs_platform_handler, fs_resource_handler
from logger.logger import log
from models.platform import Platform
from models.rom import Rom
from models.rom import Rom, compute_name_sort_key

from .base_handler import BaseRom, MetadataHandler

Expand Down Expand Up @@ -57,6 +57,7 @@ class GamelistMetadataMedia(TypedDict):
class GamelistMetadata(GamelistMetadataMedia):
rating: float | None
first_release_date: str | None
sort_name: str | None
companies: list[str] | None
franchises: list[str] | None
genres: list[str] | None
Expand Down Expand Up @@ -182,6 +183,7 @@ def extract_metadata_from_gamelist_rom(
) -> GamelistMetadata:
rating_elem = game.find("rating")
releasedate_elem = game.find("releasedate")
sortname_elem = game.find("sortname")
developer_elem = game.find("developer")
publisher_elem = game.find("publisher")
family_elem = game.find("family")
Expand All @@ -199,6 +201,9 @@ def extract_metadata_from_gamelist_rom(
if releasedate_elem is not None and releasedate_elem.text
else None
)
sort_name = (
sortname_elem.text if sortname_elem is not None and sortname_elem.text else None
)
developer = (
developer_elem.text
if developer_elem is not None and developer_elem.text
Expand All @@ -219,6 +224,7 @@ def extract_metadata_from_gamelist_rom(
return GamelistMetadata(
rating=rating,
first_release_date=first_release_date,
sort_name=sort_name,
companies=list(
dict.fromkeys(
pydash.compact(
Expand Down Expand Up @@ -385,10 +391,16 @@ def _parse_gamelist_xml(
desc_elem = game.find("desc")
lang_elem = game.find("lang")
region_elem = game.find("region")
sortname_elem = game.find("sortname")

name = (
name_elem.text if name_elem is not None and name_elem.text else ""
)
sort_name = (
sortname_elem.text
if sortname_elem is not None and sortname_elem.text
else None
)
summary = (
desc_elem.text if desc_elem is not None and desc_elem.text else ""
)
Expand All @@ -405,9 +417,11 @@ def _parse_gamelist_xml(

# Build ROM data
rom_metadata = extract_metadata_from_gamelist_rom(game, platform)
name_sort_key = compute_name_sort_key(sort_name) if sort_name else None
rom_data = GamelistRom(
gamelist_id=str(uuid.uuid4()),
name=name,
name_sort_key=name_sort_key,
summary=summary,
regions=regions,
languages=languages,
Expand Down
1 change: 1 addition & 0 deletions backend/handler/scan_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,7 @@ async def scan_rom(
rom_attrs.update(
{
"name": rom.name,
"name_sort_key": rom.name_sort_key,
"slug": rom.slug,
"summary": rom.summary,
"url_cover": rom.url_cover,
Expand Down
17 changes: 12 additions & 5 deletions backend/models/rom.py
Original file line number Diff line number Diff line change
Expand Up @@ -331,11 +331,18 @@ def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self._is_identifying = False

@validates("name")
def _sync_name_sort_key(self, _key: str, name: str | None) -> str | None:
"""Derive the indexed `name_sort_key` whenever `name` is assigned."""
self.name_sort_key = compute_name_sort_key(name)
return name
@validates("name", "name_sort_key")
def _sync_name_sort_key(self, key: str, value: str | None) -> str | None:
"""Keep the indexed `name_sort_key` in sync with `name`"""
if key == "name_sort_key":
return compute_name_sort_key(value or self.name)

if self.name_sort_key is None or self.name_sort_key == compute_name_sort_key(
self.name
):
self.name_sort_key = compute_name_sort_key(value)

return value

@validates("fs_name")
def _sync_fs_name_parts(self, _key: str, fs_name: str) -> str:
Expand Down
18 changes: 15 additions & 3 deletions backend/tests/endpoints/roms/test_rom.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from handler.metadata.ra_handler import RAGameRom, RAHandler
from handler.metadata.ss_handler import SSHandler, SSRom
from models.platform import Platform
from models.rom import Rom, RomFile
from models.rom import Rom, RomFile, compute_name_sort_key

MOCK_IGDB_ID = 11111
MOCK_MOBY_ID = 22222
Expand Down Expand Up @@ -184,6 +184,7 @@ def test_update_rom(
data={
"igdb_id": str(MOCK_IGDB_ID),
"name": "Metroid Prime Remastered",
"name_sort_key": "Metroid Prime",
"slug": "metroid-prime-remastered",
"fs_name": "Metroid Prime Remastered.zip",
"summary": "summary test",
Expand All @@ -209,6 +210,7 @@ def test_update_rom(

body = response.json()
assert body["fs_name"] == "Metroid Prime Remastered.zip"
assert body["name_sort_key"] == compute_name_sort_key("Metroid Prime")

assert rename_fs_rom_mock.called
assert get_rom_by_id_mock.called
Expand Down Expand Up @@ -554,7 +556,10 @@ def test_update_rom_igdb_id_persists_when_handler_disabled(
response = client.put(
f"/api/roms/{rom.id}",
headers={"Authorization": f"Bearer {access_token}"},
data={"igdb_id": str(MOCK_IGDB_ID)},
data={
"igdb_id": str(MOCK_IGDB_ID),
"name_sort_key": "Imported sort title",
},
)
assert response.status_code == status.HTTP_200_OK

Expand Down Expand Up @@ -1235,14 +1240,20 @@ def test_update_rom_unmatch_metadata(
initial_response = client.put(
f"/api/roms/{rom.id}",
headers={"Authorization": f"Bearer {access_token}"},
data={"igdb_id": str(MOCK_IGDB_ID)},
data={
"igdb_id": str(MOCK_IGDB_ID),
"name_sort_key": "Imported sort title",
},
)
assert initial_response.status_code == status.HTTP_200_OK
assert get_rom_by_id_mock.called

initial_body = initial_response.json()
assert initial_body["igdb_id"] == MOCK_IGDB_ID
assert initial_body["igdb_metadata"] is not None
assert initial_body["name_sort_key"] == compute_name_sort_key(
"Imported sort title"
)

# Now unmatch all metadata
response = client.put(
Expand All @@ -1265,6 +1276,7 @@ def test_update_rom_unmatch_metadata(
assert body["hltb_id"] is None

assert body["name"] == rom.fs_name
assert body["name_sort_key"] == compute_name_sort_key(rom.fs_name)
assert body["summary"] == ""
assert body["url_cover"] == ""
assert body["slug"] == ""
Expand Down
13 changes: 13 additions & 0 deletions backend/tests/handler/database/test_roms_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,16 @@ def test_update_unrelated_field_leaves_derived_columns(self, rom: Rom):
assert updated.fs_name_no_tags == "test_rom"
assert updated.fs_extension == "zip"
assert updated.name_sort_key == "test_rom"

def test_explicit_name_sort_key_marks_custom(self, rom: Rom):
updated = db_rom_handler.update_rom(rom.id, {"name_sort_key": "zelda"})

assert updated.name_sort_key == "zelda"

def test_update_name_keeps_custom_sort_key(self, rom: Rom):
db_rom_handler.update_rom(rom.id, {"name_sort_key": "pinned"})
updated = db_rom_handler.update_rom(rom.id, {"name": "The New Name 2"})

# A pinned custom key is never clobbered by a name change.
assert updated.name == "The New Name 2"
assert updated.name_sort_key == "pinned"
Loading
Loading