diff --git a/backend/alembic/versions/0085_add_name_sort_key_custom.py b/backend/alembic/versions/0085_add_name_sort_key_custom.py new file mode 100644 index 000000000..49649b815 --- /dev/null +++ b/backend/alembic/versions/0085_add_name_sort_key_custom.py @@ -0,0 +1,39 @@ +"""Add name_sort_key_custom column to roms + +Tracks whether `roms.name_sort_key` is a user/metadata-supplied override (so it +is preserved across renames and rescans) or derived from `name` on write. New +rows default to derived (false), and existing rows are backfilled the same way. + +Revision ID: 0085_add_name_sort_key_custom +Revises: 0084_add_roms_search_index +Create Date: 2026-05-31 00:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +revision = "0085_add_name_sort_key_custom" +down_revision = "0084_add_roms_search_index" +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( + "name_sort_key_custom", + sa.Boolean(), + nullable=False, + server_default="0", + ), + if_not_exists=True, + ) + # Drop the server-side default; the model supplies the Python default. + batch_op.alter_column("name_sort_key_custom", server_default=None) + + +def downgrade() -> None: + with op.batch_alter_table("roms", schema=None) as batch_op: + batch_op.drop_column("name_sort_key_custom", if_exists=True) diff --git a/backend/alembic/versions/0085_add_rom_sort_name.py b/backend/alembic/versions/0085_add_rom_sort_name.py deleted file mode 100644 index 01a659b7d..000000000 --- a/backend/alembic/versions/0085_add_rom_sort_name.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Add sort_name column to roms - -Revision ID: 0085_add_rom_sort_name -Revises: 0084_add_roms_search_index -Create Date: 2026-05-31 00:00:00.000000 - -""" - -import sqlalchemy as sa -from alembic import op - -revision = "0085_add_rom_sort_name" -down_revision = "0084_add_roms_search_index" -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) diff --git a/backend/endpoints/responses/rom.py b/backend/endpoints/responses/rom.py index 85cbc4f4b..a25d721f0 100644 --- a/backend/endpoints/responses/rom.py +++ b/backend/endpoints/responses/rom.py @@ -272,7 +272,8 @@ class RomSchema(BaseModel): fs_size_bytes: int name: str | None - sort_name: str | None + name_sort_key: str | None + name_sort_key_custom: bool slug: str | None summary: str | None diff --git a/backend/endpoints/roms/__init__.py b/backend/endpoints/roms/__init__.py index 6ade772dd..fd7104ba4 100644 --- a/backend/endpoints/roms/__init__.py +++ b/backend/endpoints/roms/__init__.py @@ -63,7 +63,7 @@ from handler.metadata.ss_handler import add_ss_auth_to_url, get_preferred_media_ 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 @@ -133,7 +133,7 @@ class RomUpdateForm(BaseModel): default=None, description="Raw manual metadata as JSON string." ) name: str | None = None - sort_name: str | None = None + name_sort_key: str | None = None summary: str | None = None fs_name: str | None = None url_cover: str | None = None @@ -193,7 +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), - sort_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), @@ -222,7 +222,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, + "name_sort_key": name_sort_key, "summary": summary, "fs_name": fs_name, "url_cover": url_cover, @@ -1208,7 +1208,8 @@ async def update_rom( "hltb_id": None, "libretro_id": None, "name": rom.fs_name, - "sort_name": None, + "name_sort_key": compute_name_sort_key(rom.fs_name), + "name_sort_key_custom": False, "summary": "", "url_screenshots": [], "path_screenshots": [], @@ -1393,20 +1394,28 @@ 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, - "sort_name": ( - form_data.sort_name or None - if "sort_name" in provided_fields - else rom.sort_name - ), + "name": name_value, "summary": ( form_data.summary if "summary" in provided_fields else rom.summary ), } ) + # `name_sort_key` is a normalized override the user can set or clear. A + # non-empty value pins a custom key; clearing it reverts to deriving from + # `name`. When not provided, leave the stored key (and its flag) untouched. + if "name_sort_key" in provided_fields: + override = (form_data.name_sort_key or "").strip() + if override: + cleaned_data["name_sort_key"] = compute_name_sort_key(override) + cleaned_data["name_sort_key_custom"] = True + else: + cleaned_data["name_sort_key"] = compute_name_sort_key(name_value) + cleaned_data["name_sort_key_custom"] = False + 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}) diff --git a/backend/handler/database/roms_handler.py b/backend/handler/database/roms_handler.py index 77c32b4a9..e1dd9e1a4 100644 --- a/backend/handler/database/roms_handler.py +++ b/backend/handler/database/roms_handler.py @@ -1010,8 +1010,8 @@ class DBRomsHandler(DBBaseHandler): order_attr = Rom.name # Use indexed `name_sort_key` to have fast access to names without - # articles (the, a, an) and leading digits. The key already folds in - # `sort_name` (falling back to `name`) at write time. + # 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 @@ -1206,19 +1206,16 @@ class DBRomsHandler(DBBaseHandler): ) -> Rom: # Bulk update() bypasses the ORM @validates hooks, so keep the # columns derived from name / fs_name in sync explicitly. - if "name" in data or "sort_name" in data: - if "name" in data and "sort_name" in data: - effective = data["sort_name"] or data["name"] - else: - # Only one of the two changed; read the other from the row so - # the key reflects the effective `sort_name or name`. - existing = session.query(Rom).filter_by(id=id).one() - name = data["name"] if "name" in data else existing.name - sort_name = ( - data["sort_name"] if "sort_name" in data else existing.sort_name - ) - effective = sort_name or name - data = {**data, "name_sort_key": compute_name_sort_key(effective)} + if "name_sort_key" in data: + # An explicit sort key was supplied (custom override or a revert to + # the derived value). Trust it; mark it custom unless told otherwise. + data = {"name_sort_key_custom": True, **data} + elif "name" in data: + # Name changed without an explicit key: recompute the derived key, + # but never clobber a row pinned to a custom sort key. + existing = session.query(Rom).filter_by(id=id).one() + if not existing.name_sort_key_custom: + data = {**data, "name_sort_key": compute_name_sort_key(data["name"])} if "fs_name" in data: parts = compute_file_name_parts(data["fs_name"]) diff --git a/backend/handler/metadata/base_handler.py b/backend/handler/metadata/base_handler.py index fa2583247..b75ec9949 100644 --- a/backend/handler/metadata/base_handler.py +++ b/backend/handler/metadata/base_handler.py @@ -52,7 +52,8 @@ MULTIPLE_SPACE_PATTERN = re.compile(r"\s+") class BaseRom(TypedDict): name: NotRequired[str] - sort_name: NotRequired[str | None] + name_sort_key: NotRequired[str | None] + name_sort_key_custom: NotRequired[bool] summary: NotRequired[str] url_cover: NotRequired[str] url_screenshots: NotRequired[list[str]] diff --git a/backend/handler/metadata/gamelist_handler.py b/backend/handler/metadata/gamelist_handler.py index c8718228e..cab9666f2 100644 --- a/backend/handler/metadata/gamelist_handler.py +++ b/backend/handler/metadata/gamelist_handler.py @@ -14,7 +14,7 @@ from config.config_manager import config_manager as cm 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 @@ -420,7 +420,12 @@ class GamelistHandler(MetadataHandler): rom_data = GamelistRom( gamelist_id=str(uuid.uuid4()), name=name, - sort_name=sort_name or name, + # A gamelist tag becomes a custom sort key; the + # derived-from-name default is used when it is absent. + name_sort_key=( + compute_name_sort_key(sort_name) if sort_name else None + ), + name_sort_key_custom=bool(sort_name), summary=summary, regions=regions, languages=languages, diff --git a/backend/handler/scan_handler.py b/backend/handler/scan_handler.py index bbbc20836..9a82933b0 100644 --- a/backend/handler/scan_handler.py +++ b/backend/handler/scan_handler.py @@ -310,6 +310,19 @@ async def scan_firmware( return Firmware(**firmware_attrs) +def _build_rom(rom_attrs: dict[str, Any]) -> Rom: + """Construct a Rom, applying a custom ``name_sort_key`` after construction so + the override survives the ``@validates('name')`` derivation regardless of the + order in which ``name`` and the sort-key fields are passed.""" + name_sort_key = rom_attrs.pop("name_sort_key", None) + name_sort_key_custom = rom_attrs.pop("name_sort_key_custom", False) + rom = Rom(**rom_attrs) + if name_sort_key_custom and name_sort_key: + rom.name_sort_key = name_sort_key + rom.name_sort_key_custom = True + return rom + + async def scan_rom( scan_type: ScanType, platform: Platform, @@ -335,7 +348,6 @@ 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 @@ -356,7 +368,8 @@ async def scan_rom( rom_attrs.update( { "name": rom.name, - "sort_name": rom.sort_name, + "name_sort_key": rom.name_sort_key, + "name_sort_key_custom": rom.name_sort_key_custom, "slug": rom.slug, "summary": rom.summary, "url_cover": rom.url_cover, @@ -1003,7 +1016,7 @@ async def scan_rom( f"{hl(rom_attrs['fs_name'])} not identified {emoji.EMOJI_CROSS_MARK}", extra=LOGGER_MODULE_NAME, ) - return Rom(**rom_attrs) + return _build_rom(rom_attrs) async def fetch_sgdb_details(playmatch_rom: PlaymatchRomMatch) -> SGDBRom: """Fetch SteamGridDB details for the ROM.""" @@ -1079,7 +1092,7 @@ async def scan_rom( ) rom_attrs["missing_from_fs"] = False - return Rom(**rom_attrs) + return _build_rom(rom_attrs) async def _scan_asset(file_name: str, asset_path: str, should_hash: bool = False): diff --git a/backend/models/rom.py b/backend/models/rom.py index bd30b311a..76e2f8fd9 100644 --- a/backend/models/rom.py +++ b/backend/models/rom.py @@ -232,10 +232,12 @@ 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)) name_sort_key: Mapped[str | None] = mapped_column( String(length=NAME_SORT_KEY_MAX_LENGTH), default=None ) + # When true, `name_sort_key` is a user/metadata-supplied override and is NOT + # recomputed from `name`. When false, it is derived from `name` on write. + name_sort_key_custom: Mapped[bool] = mapped_column(default=False, nullable=False) 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( @@ -332,15 +334,13 @@ class Rom(BaseModel): super().__init__(*args, **kwargs) self._is_identifying = False - @validates("name", "sort_name") - def _sync_name_sort_key(self, key: str, value: str | None) -> str | None: - """Derive the indexed `name_sort_key` from `sort_name` (falling back to - `name`) whenever either is assigned.""" - if key == "sort_name": - effective = value or self.name - else: - effective = self.sort_name or value - self.name_sort_key = compute_name_sort_key(effective) + @validates("name") + def _sync_name_sort_key(self, _key: str, value: str | None) -> str | None: + """Keep the indexed `name_sort_key` in sync with `name`, unless a custom + sort key has been pinned via `name_sort_key_custom`. Callers that supply + a custom key assign `name_sort_key` directly and flip the flag.""" + if not getattr(self, "name_sort_key_custom", False): + self.name_sort_key = compute_name_sort_key(value) return value @validates("fs_name") diff --git a/backend/tests/endpoints/roms/test_rom.py b/backend/tests/endpoints/roms/test_rom.py index 8aed0517d..dda73421a 100644 --- a/backend/tests/endpoints/roms/test_rom.py +++ b/backend/tests/endpoints/roms/test_rom.py @@ -15,7 +15,7 @@ from handler.metadata.moby_handler import MobyGamesHandler, MobyGamesRom 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 @@ -184,7 +184,7 @@ def test_update_rom( data={ "igdb_id": str(MOCK_IGDB_ID), "name": "Metroid Prime Remastered", - "sort_name": "Metroid Prime", + "name_sort_key": "Metroid Prime", "slug": "metroid-prime-remastered", "fs_name": "Metroid Prime Remastered.zip", "summary": "summary test", @@ -210,7 +210,8 @@ def test_update_rom( body = response.json() assert body["fs_name"] == "Metroid Prime Remastered.zip" - assert body["sort_name"] == "Metroid Prime" + assert body["name_sort_key"] == compute_name_sort_key("Metroid Prime") + assert body["name_sort_key_custom"] is True assert rename_fs_rom_mock.called assert get_rom_by_id_mock.called @@ -558,7 +559,7 @@ class TestUpdateMetadataIDs: headers={"Authorization": f"Bearer {access_token}"}, data={ "igdb_id": str(MOCK_IGDB_ID), - "sort_name": "Imported sort title", + "name_sort_key": "Imported sort title", }, ) assert response.status_code == status.HTTP_200_OK @@ -1242,7 +1243,7 @@ class TestUnmatchMetadata: headers={"Authorization": f"Bearer {access_token}"}, data={ "igdb_id": str(MOCK_IGDB_ID), - "sort_name": "Imported sort title", + "name_sort_key": "Imported sort title", }, ) assert initial_response.status_code == status.HTTP_200_OK @@ -1251,7 +1252,10 @@ class TestUnmatchMetadata: 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" + assert initial_body["name_sort_key"] == compute_name_sort_key( + "Imported sort title" + ) + assert initial_body["name_sort_key_custom"] is True # Now unmatch all metadata response = client.put( @@ -1274,7 +1278,8 @@ class TestUnmatchMetadata: assert body["hltb_id"] is None assert body["name"] == rom.fs_name - assert body["sort_name"] is None + assert body["name_sort_key"] == compute_name_sort_key(rom.fs_name) + assert body["name_sort_key_custom"] is False assert body["summary"] == "" assert body["url_cover"] == "" assert body["slug"] == "" diff --git a/backend/tests/handler/database/test_roms_handler.py b/backend/tests/handler/database/test_roms_handler.py index ad0aa8672..85666a0ab 100644 --- a/backend/tests/handler/database/test_roms_handler.py +++ b/backend/tests/handler/database/test_roms_handler.py @@ -31,3 +31,18 @@ class TestUpdateRomDerivedColumns: 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" + assert updated.name_sort_key_custom is True + + 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" + assert updated.name_sort_key_custom is True diff --git a/backend/tests/handler/test_db_handler.py b/backend/tests/handler/test_db_handler.py index 1b89ba5d2..517034967 100644 --- a/backend/tests/handler/test_db_handler.py +++ b/backend/tests/handler/test_db_handler.py @@ -15,7 +15,7 @@ from handler.database import ( ) from models.assets import Save, Screenshot, State from models.platform import Platform -from models.rom import Rom +from models.rom import Rom, compute_name_sort_key from models.user import Role, User @@ -432,25 +432,27 @@ 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 [ +def test_custom_name_sort_key_overrides_name_sort_order(platform: Platform): + for name, sort_override 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", - ) + rom = Rom( + platform_id=platform.id, + name=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", ) + # A custom key pins ordering; without one it derives from `name`. + if sort_override is not None: + rom.name_sort_key = compute_name_sort_key(sort_override) + rom.name_sort_key_custom = True + db_rom_handler.add_rom(rom) roms = db_rom_handler.get_roms_scalar( platform_ids=[platform.id], order_by="name", order_dir="asc" diff --git a/frontend/src/__generated__/models/Body_update_rom_api_roms__id__put.ts b/frontend/src/__generated__/models/Body_update_rom_api_roms__id__put.ts index e4bf1565a..dfb13d581 100644 --- a/frontend/src/__generated__/models/Body_update_rom_api_roms__id__put.ts +++ b/frontend/src/__generated__/models/Body_update_rom_api_roms__id__put.ts @@ -27,7 +27,7 @@ export type Body_update_rom_api_roms__id__put = { raw_hltb_metadata?: (string | null); raw_manual_metadata?: (string | null); name?: (string | null); - sort_name?: (string | null); + name_sort_key?: (string | null); summary?: (string | null); fs_name?: (string | null); url_cover?: (string | null); diff --git a/frontend/src/__generated__/models/DetailedRomSchema.ts b/frontend/src/__generated__/models/DetailedRomSchema.ts index c127401ed..c02ef4d64 100644 --- a/frontend/src/__generated__/models/DetailedRomSchema.ts +++ b/frontend/src/__generated__/models/DetailedRomSchema.ts @@ -47,7 +47,8 @@ export type DetailedRomSchema = { fs_path: string; fs_size_bytes: number; name: (string | null); - sort_name: (string | null); + name_sort_key: (string | null); + name_sort_key_custom: boolean; slug: (string | null); summary: (string | null); alternative_names: Array; diff --git a/frontend/src/__generated__/models/SimpleRomSchema.ts b/frontend/src/__generated__/models/SimpleRomSchema.ts index 427d0b438..f58853d39 100644 --- a/frontend/src/__generated__/models/SimpleRomSchema.ts +++ b/frontend/src/__generated__/models/SimpleRomSchema.ts @@ -42,7 +42,8 @@ export type SimpleRomSchema = { fs_path: string; fs_size_bytes: number; name: (string | null); - sort_name: (string | null); + name_sort_key: (string | null); + name_sort_key_custom: boolean; slug: (string | null); summary: (string | null); alternative_names: Array; diff --git a/frontend/src/components/common/Game/Dialog/EditRom.vue b/frontend/src/components/common/Game/Dialog/EditRom.vue index 06c97b446..4b67a947b 100644 --- a/frontend/src/components/common/Game/Dialog/EditRom.vue +++ b/frontend/src/components/common/Game/Dialog/EditRom.vue @@ -1,6 +1,6 @@