Skip to content
Open
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
28 changes: 28 additions & 0 deletions backend/alembic/versions/0082_add_rom_sort_name.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""Add sort_name column to roms

Revision ID: 0082_add_rom_sort_name
Revises: 0081_add_archive_members
Create Date: 2026-05-31 00:00:00.000000

"""

import sqlalchemy as sa
from alembic import op

revision = "0082_add_rom_sort_name"
down_revision = "0081_add_archive_members"
branch_labels = None
depends_on = None


def upgrade() -> None:
with op.batch_alter_table("roms", schema=None) as batch_op:
batch_op.add_column(
sa.Column("sort_name", sa.String(length=350), nullable=True),
if_not_exists=True,
)


def downgrade() -> None:
with op.batch_alter_table("roms", schema=None) as batch_op:
batch_op.drop_column("sort_name", if_exists=True)
1 change: 1 addition & 0 deletions backend/endpoints/responses/rom.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@ class RomSchema(BaseModel):
fs_size_bytes: int

name: str | None
sort_name: str | None
slug: str | None
summary: str | None

Expand Down
9 changes: 9 additions & 0 deletions backend/endpoints/roms/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ class RomUpdateForm(BaseModel):
default=None, description="Raw manual metadata as JSON string."
)
name: str | None = None
sort_name: str | None = None
summary: str | None = None
fs_name: str | None = None
url_cover: str | None = None
Expand Down Expand Up @@ -190,6 +191,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),
sort_name: 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 @@ -218,6 +220,7 @@ async def parse_rom_update_form(
"raw_hltb_metadata": raw_hltb_metadata,
"raw_manual_metadata": raw_manual_metadata,
"name": name,
"sort_name": sort_name,
"summary": summary,
"fs_name": fs_name,
"url_cover": url_cover,
Expand Down Expand Up @@ -1108,6 +1111,7 @@ async def update_rom(
"hltb_id": None,
"libretro_id": None,
"name": rom.fs_name,
"sort_name": None,
"summary": "",
"url_screenshots": [],
"path_screenshots": [],
Expand Down Expand Up @@ -1294,6 +1298,11 @@ async def update_rom(
cleaned_data.update(
{
"name": form_data.name if "name" in provided_fields else rom.name,
"sort_name": (
form_data.sort_name or None
if "sort_name" in provided_fields
else rom.sort_name
),
"summary": (
form_data.summary if "summary" in provided_fields else rom.summary
),
Expand Down
10 changes: 10 additions & 0 deletions backend/handler/database/roms_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,13 @@ def wrapper(*args, **kwargs):


class DBRomsHandler(DBBaseHandler):
@staticmethod
def _get_name_order_attr(order_attr: Any) -> Any:
if order_attr is Rom.name:
return func.coalesce(func.nullif(Rom.sort_name, ""), Rom.name)

return order_attr

@begin_session
@with_details
def add_rom(
Expand Down Expand Up @@ -838,6 +845,7 @@ def get_roms_query(
else:
order_attr = Rom.name

order_attr = self._get_name_order_attr(order_attr)
order_attr_column = order_attr

# Ignore case when the order attribute is a number
Expand Down Expand Up @@ -921,6 +929,8 @@ def with_char_index(
order_by_attr: Any,
session: Session = None, # type: ignore
) -> list[Row[tuple[str, int]]]:
order_by_attr = self._get_name_order_attr(order_by_attr)

if isinstance(order_by_attr.type, (String, Text)):
# Remove any leading articles
order_by_attr = func.trim(
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]
sort_name: NotRequired[str]
summary: NotRequired[str]
url_cover: NotRequired[str]
url_screenshots: NotRequired[list[str]]
Expand Down
13 changes: 13 additions & 0 deletions backend/handler/metadata/gamelist_handler.py
Original file line number Diff line number Diff line change
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 @@ -408,6 +420,7 @@ def _parse_gamelist_xml(
rom_data = GamelistRom(
gamelist_id=str(uuid.uuid4()),
name=name,
sort_name=sort_name,
summary=summary,
regions=regions,
languages=languages,
Expand Down
2 changes: 2 additions & 0 deletions backend/handler/scan_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,7 @@ async def scan_rom(
"sha1_hash": rom.sha1_hash,
"ra_hash": rom.ra_hash,
"fs_size_bytes": rom.fs_size_bytes,
"sort_name": None,
}

# Check if files have been parsed and hashed
Expand All @@ -340,6 +341,7 @@ async def scan_rom(
rom_attrs.update(
{
"name": rom.name,
"sort_name": rom.sort_name,
"slug": rom.slug,
"summary": rom.summary,
"url_cover": rom.url_cover,
Expand Down
1 change: 1 addition & 0 deletions backend/models/rom.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ class Rom(BaseModel):
fs_size_bytes: Mapped[int] = mapped_column(BigInteger(), default=0)

name: Mapped[str | None] = mapped_column(String(length=350))
sort_name: Mapped[str | None] = mapped_column(String(length=350))
slug: Mapped[str | None] = mapped_column(String(length=400))
summary: Mapped[str | None] = mapped_column(Text)
igdb_metadata: Mapped[dict[str, Any] | None] = mapped_column(
Expand Down
14 changes: 12 additions & 2 deletions backend/tests/endpoints/roms/test_rom.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ def test_update_rom(
data={
"igdb_id": str(MOCK_IGDB_ID),
"name": "Metroid Prime Remastered",
"sort_name": "Metroid Prime",
"slug": "metroid-prime-remastered",
"fs_name": "Metroid Prime Remastered.zip",
"summary": "summary test",
Expand All @@ -99,6 +100,7 @@ def test_update_rom(

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

assert rename_fs_rom_mock.called
assert get_rom_by_id_mock.called
Expand Down Expand Up @@ -420,7 +422,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),
"sort_name": "Imported sort title",
},
)
assert response.status_code == status.HTTP_200_OK

Expand Down Expand Up @@ -1101,14 +1106,18 @@ 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),
"sort_name": "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["sort_name"] == "Imported sort title"

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

assert body["name"] == rom.fs_name
assert body["sort_name"] is None
assert body["summary"] == ""
assert body["url_cover"] == ""
assert body["slug"] == ""
Expand Down
35 changes: 34 additions & 1 deletion backend/tests/handler/metadata/test_gamelist_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,24 @@
from typing import cast
from unittest.mock import patch

from handler.metadata.gamelist_handler import GamelistHandler
from defusedxml import ElementTree as ET

from handler.metadata.gamelist_handler import (
GamelistHandler,
extract_metadata_from_gamelist_rom,
)
from models.platform import Platform

MOCK_METADATA = {
"box2d_url": None,
"image_url": None,
"manual_url": None,
"screenshot_url": None,
"sort_name": None,
"title_screen_url": None,
}

MOCK_MEDIA = {
"box2d_url": None,
"image_url": None,
"manual_url": None,
Expand Down Expand Up @@ -82,6 +96,23 @@ def test_parse_gamelist_xml_keeps_game_entries(tmp_path: Path, platform: Platfor
assert roms_data["test-rom.zip"].get("name") == "Game Entry"


def test_extract_metadata_from_gamelist_rom_includes_sort_name(platform: Platform):
game = ET.fromstring(
"""<game>
<path>./test-rom.zip</path>
<sortname>Akumajou Dracula</sortname>
</game>"""
)

with patch(
"handler.metadata.gamelist_handler.extract_media_from_gamelist_rom",
return_value=MOCK_MEDIA,
):
metadata = extract_metadata_from_gamelist_rom(game, platform)

assert metadata["sort_name"] == "Akumajou Dracula"


class TestGamelistHandler:
def test_parse_gamelist_with_malformed_alternative_emulator_tag(self, tmp_path):
gamelist_path = tmp_path / "gamelist.xml"
Expand All @@ -106,6 +137,7 @@ def test_parse_gamelist_with_malformed_alternative_emulator_tag(self, tmp_path):
"image_url": None,
"manual_url": None,
"screenshot_url": None,
"sort_name": None,
"title_screen_url": None,
}

Expand Down Expand Up @@ -152,6 +184,7 @@ def test_parse_gamelist_with_es_de_alternative_emulator_sibling(self, tmp_path):
"image_url": None,
"manual_url": None,
"screenshot_url": None,
"sort_name": None,
"title_screen_url": None,
}

Expand Down
26 changes: 26 additions & 0 deletions backend/tests/handler/test_db_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,32 @@ def test_article_stripping_sort(platform: Platform):
assert [r.name for r in roms] == ["The Legend", "A Quest", "Zelda"]


def test_sort_name_overrides_name_sort_order(platform: Platform):
for name, sort_name in [
("Display Z", "Alpha"),
("Display M", None),
("Display A", "Zulu"),
]:
db_rom_handler.add_rom(
Rom(
platform_id=platform.id,
name=name,
sort_name=sort_name,
slug=name.lower().replace(" ", "-"),
fs_name=f"{name}.zip",
fs_name_no_tags=name,
fs_name_no_ext=name,
fs_extension="zip",
fs_path=f"{platform.slug}/roms",
)
)

roms = db_rom_handler.get_roms_scalar(
platform_ids=[platform.id], order_by="name", order_dir="asc"
)
assert [r.name for r in roms] == ["Display Z", "Display M", "Display A"]


def test_bulk_mark_present(platform: Platform):
"""bulk_mark_present sets missing_from_fs=False for the given ROM IDs."""
roms = []
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion frontend/src/__generated__/models/DetailedRomSchema.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion frontend/src/__generated__/models/SimpleRomSchema.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading