diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index cae18c038..4a1dfff2d 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -24,21 +24,21 @@ lint: enabled: - dotenv-linter@4.0.0 - hadolint@2.14.0 - - markdownlint@0.46.0 - - eslint@9.39.1 + - markdownlint@0.47.0 + - eslint@9.39.2 - actionlint@1.7.9 - bandit@1.9.2 - black@25.12.0 - checkov@3.2.495 - git-diff-check - isort@7.0.0 - - mypy@1.19.0 - - osv-scanner@2.3.0 + - mypy@1.19.1 + - osv-scanner@2.3.1 - prettier@3.7.4: packages: - "@trivago/prettier-plugin-sort-imports@6.0.0" - "@vue/compiler-sfc@3.5.25" - - ruff@0.14.8 + - ruff@0.14.9 - shellcheck@0.11.0 - shfmt@3.6.0 - taplo@0.10.0 diff --git a/backend/alembic/versions/0009_models_refactor.py b/backend/alembic/versions/0009_models_refactor.py index 467760d3e..3779e390e 100644 --- a/backend/alembic/versions/0009_models_refactor.py +++ b/backend/alembic/versions/0009_models_refactor.py @@ -1,4 +1,4 @@ -"""empty message +"""Refactor platforms and roms tables Revision ID: 0009_models_refactor Revises: 2.0.0 diff --git a/backend/alembic/versions/0010_igdb_id_integerr.py b/backend/alembic/versions/0010_igdb_id_integerr.py index 8e7ad272b..d4613f70c 100644 --- a/backend/alembic/versions/0010_igdb_id_integerr.py +++ b/backend/alembic/versions/0010_igdb_id_integerr.py @@ -1,4 +1,4 @@ -"""empty message +"""Change igdb_id and sgdb_id to integer Revision ID: 0010_igdb_id_integerr Revises: 0009_models_refactor diff --git a/backend/alembic/versions/0011_drop_has_cover.py b/backend/alembic/versions/0011_drop_has_cover.py index 11cd2f280..e9ac0982a 100644 --- a/backend/alembic/versions/0011_drop_has_cover.py +++ b/backend/alembic/versions/0011_drop_has_cover.py @@ -1,4 +1,4 @@ -"""empty message +"""Remove has_cover column from roms table Revision ID: 0011_drop_has_cover Revises: 0010_igdb_id_integerr diff --git a/backend/alembic/versions/0012_add_regions_languages.py b/backend/alembic/versions/0012_add_regions_languages.py index 83da434a8..177476ca3 100644 --- a/backend/alembic/versions/0012_add_regions_languages.py +++ b/backend/alembic/versions/0012_add_regions_languages.py @@ -1,4 +1,4 @@ -"""empty message +"""Add support for multiple regions and languages Revision ID: 0012_add_regions_languages Revises: 0011_drop_has_cover diff --git a/backend/alembic/versions/0013_upgrade_file_extension.py b/backend/alembic/versions/0013_upgrade_file_extension.py index 589f5b5ac..1aa58c2e3 100644 --- a/backend/alembic/versions/0013_upgrade_file_extension.py +++ b/backend/alembic/versions/0013_upgrade_file_extension.py @@ -1,4 +1,4 @@ -"""empty message +"""Increase length of file_extension column Revision ID: 0013_upgrade_file_extension Revises: 0012_add_regions_languages diff --git a/backend/alembic/versions/0014_asset_files.py b/backend/alembic/versions/0014_asset_files.py index 9aeb10438..2b60964ea 100644 --- a/backend/alembic/versions/0014_asset_files.py +++ b/backend/alembic/versions/0014_asset_files.py @@ -1,4 +1,4 @@ -"""empty message +"""Introduce saves, screenshots, and states tables and refactor database schema Revision ID: 0014_asset_files Revises: 0013_upgrade_file_extension diff --git a/backend/alembic/versions/0015_mobygames_data.py b/backend/alembic/versions/0015_mobygames_data.py index f9fbddf68..269177c63 100644 --- a/backend/alembic/versions/0015_mobygames_data.py +++ b/backend/alembic/versions/0015_mobygames_data.py @@ -1,4 +1,4 @@ -"""empty message +"""Add MobyGames data Revision ID: 0015_mobygames_data Revises: 0014_asset_files diff --git a/backend/alembic/versions/0016_user_last_login_active.py b/backend/alembic/versions/0016_user_last_login_active.py index 848400d21..a39790222 100644 --- a/backend/alembic/versions/0016_user_last_login_active.py +++ b/backend/alembic/versions/0016_user_last_login_active.py @@ -1,4 +1,4 @@ -"""empty message +"""Track user last login and active times Revision ID: 0016_user_last_login_active Revises: 0015_mobygames_data diff --git a/backend/alembic/versions/0017_rom_notes.py b/backend/alembic/versions/0017_rom_notes.py index 640080af6..d3a8112ec 100644 --- a/backend/alembic/versions/0017_rom_notes.py +++ b/backend/alembic/versions/0017_rom_notes.py @@ -1,4 +1,4 @@ -"""empty message +"""Create rom_notes table Revision ID: 0017_rom_notes Revises: 0016_user_last_login_active diff --git a/backend/alembic/versions/0018_firmware.py b/backend/alembic/versions/0018_firmware.py index 9d0083bbc..5cb25992d 100644 --- a/backend/alembic/versions/0018_firmware.py +++ b/backend/alembic/versions/0018_firmware.py @@ -1,4 +1,4 @@ -"""empty message +"""Create firmware table Revision ID: 0018_firmware Revises: 0017_rom_notes diff --git a/backend/alembic/versions/0019_resources_refactor.py b/backend/alembic/versions/0019_resources_refactor.py index f69cabbb8..b6f7957eb 100644 --- a/backend/alembic/versions/0019_resources_refactor.py +++ b/backend/alembic/versions/0019_resources_refactor.py @@ -1,4 +1,4 @@ -"""empty message +"""Refactor resource storage Revision ID: 0019_resources_refactor Revises: 0018_firmware diff --git a/backend/alembic/versions/0020_created_and_updated.py b/backend/alembic/versions/0020_created_and_updated.py index d255a7c8a..b4a1d09b4 100644 --- a/backend/alembic/versions/0020_created_and_updated.py +++ b/backend/alembic/versions/0020_created_and_updated.py @@ -1,4 +1,4 @@ -"""empty message +"""Add created_at and updated_at columns Revision ID: 0020_created_and_updated Revises: 0019_resources_refactor diff --git a/backend/alembic/versions/0021_rom_user.py b/backend/alembic/versions/0021_rom_user.py index 6b3994623..5e94ed94c 100644 --- a/backend/alembic/versions/0021_rom_user.py +++ b/backend/alembic/versions/0021_rom_user.py @@ -1,4 +1,4 @@ -"""empty message +"""Rename rom_notes to rom_user and add is_main_sibling column Revision ID: 0021_rom_user Revises: 0020_created_and_updated diff --git a/backend/alembic/versions/0022_collections_.py b/backend/alembic/versions/0022_collections_.py index 89db6c89c..9382be091 100644 --- a/backend/alembic/versions/0022_collections_.py +++ b/backend/alembic/versions/0022_collections_.py @@ -1,4 +1,4 @@ -"""empty message +"""Migrate resources and create collections table Revision ID: 0022_collections Revises: 0021_rom_user diff --git a/backend/alembic/versions/0024_sibling_roms_db_view.py b/backend/alembic/versions/0024_sibling_roms_db_view.py index 8c31e0219..3dc28a86d 100644 --- a/backend/alembic/versions/0024_sibling_roms_db_view.py +++ b/backend/alembic/versions/0024_sibling_roms_db_view.py @@ -1,4 +1,4 @@ -"""empty message +"""Create sibling_roms view Revision ID: 0024_sibling_roms_db_view Revises: 0023_make_columns_non_nullable diff --git a/backend/alembic/versions/0025_roms_hashes.py b/backend/alembic/versions/0025_roms_hashes.py index 90c725c9d..cde46d77f 100644 --- a/backend/alembic/versions/0025_roms_hashes.py +++ b/backend/alembic/versions/0025_roms_hashes.py @@ -1,4 +1,4 @@ -"""empty message +"""Add hashes to roms Revision ID: 0025_roms_hashes Revises: 0024_sibling_roms_db_view diff --git a/backend/alembic/versions/0026_romuser_status_fields.py b/backend/alembic/versions/0026_romuser_status_fields.py index 351776eda..ed811bf7c 100644 --- a/backend/alembic/versions/0026_romuser_status_fields.py +++ b/backend/alembic/versions/0026_romuser_status_fields.py @@ -1,4 +1,4 @@ -"""empty message +"""Add status fields to rom_user table Revision ID: 0026_romuser_status_fields Revises: 0025_roms_hashes diff --git a/backend/alembic/versions/0028_user_email.py b/backend/alembic/versions/0028_user_email.py index 935f5411c..f3999108a 100644 --- a/backend/alembic/versions/0028_user_email.py +++ b/backend/alembic/versions/0028_user_email.py @@ -1,4 +1,4 @@ -"""empty message +"""Add email to users table Revision ID: 0028_user_email Revises: 0027_platforms_data diff --git a/backend/alembic/versions/0031_datetime_to_timestamp.py b/backend/alembic/versions/0031_datetime_to_timestamp.py index 1c12ed23e..0367a3a6f 100644 --- a/backend/alembic/versions/0031_datetime_to_timestamp.py +++ b/backend/alembic/versions/0031_datetime_to_timestamp.py @@ -1,4 +1,4 @@ -"""empty message +"""Change DateTime columns to TIMESTAMP Revision ID: 0031_datetime_to_timestamp Revises: 0030_user_email_null diff --git a/backend/alembic/versions/0032_longer_fs_fields.py b/backend/alembic/versions/0032_longer_fs_fields.py index b0a31d722..be159f507 100644 --- a/backend/alembic/versions/0032_longer_fs_fields.py +++ b/backend/alembic/versions/0032_longer_fs_fields.py @@ -1,4 +1,4 @@ -"""empty message +"""Increase length of platform fields Revision ID: 0032_longer_fs_fields Revises: 0031_datetime_to_timestamp diff --git a/backend/alembic/versions/0033_rom_file_and_hashes.py b/backend/alembic/versions/0033_rom_file_and_hashes.py index b623227ab..ce4b1842e 100644 --- a/backend/alembic/versions/0033_rom_file_and_hashes.py +++ b/backend/alembic/versions/0033_rom_file_and_hashes.py @@ -1,4 +1,4 @@ -"""empty message +"""Introduce rom_files table and refactor roms table Revision ID: 0033_rom_file_and_hashes Revises: 0032_longer_fs_fields diff --git a/backend/alembic/versions/0034_virtual_collections_db_view.py b/backend/alembic/versions/0034_virtual_collections_db_view.py index 4ff2a6a78..5993af891 100644 --- a/backend/alembic/versions/0034_virtual_collections_db_view.py +++ b/backend/alembic/versions/0034_virtual_collections_db_view.py @@ -1,4 +1,4 @@ -"""empty message +"""Introduce collections_roms table and virtual_collections view Revision ID: 0034_virtual_collections_db_view Revises: 0033_rom_file_and_hashes diff --git a/backend/alembic/versions/0035_screenscraper.py b/backend/alembic/versions/0035_screenscraper.py index 7d2cab16d..4c86c9a54 100644 --- a/backend/alembic/versions/0035_screenscraper.py +++ b/backend/alembic/versions/0035_screenscraper.py @@ -1,4 +1,4 @@ -"""empty message +"""Add ScreenScraper data Revision ID: 0035_screenscraper Revises: 0034_virtual_collections_db_view diff --git a/backend/alembic/versions/0036_screenscraper_platforms_id.py b/backend/alembic/versions/0036_screenscraper_platforms_id.py index d452b710f..c37792c27 100644 --- a/backend/alembic/versions/0036_screenscraper_platforms_id.py +++ b/backend/alembic/versions/0036_screenscraper_platforms_id.py @@ -1,4 +1,4 @@ -"""empty message +"""Populate ScreenScraper platform IDs Revision ID: 0036_screenscraper_platforms_id Revises: 0035_screenscraper diff --git a/backend/alembic/versions/0037_virtual_rom_columns.py b/backend/alembic/versions/0037_virtual_rom_columns.py index 1b1b05e5e..61b1dddb8 100644 --- a/backend/alembic/versions/0037_virtual_rom_columns.py +++ b/backend/alembic/versions/0037_virtual_rom_columns.py @@ -1,4 +1,4 @@ -"""empty message +"""Create or update roms_metadata view with consolidated metadata Revision ID: 0037_virtual_rom_columns Revises: 0036_screenscraper_platforms_id diff --git a/backend/alembic/versions/0038_add_ssid_to_sibling_roms.py b/backend/alembic/versions/0038_add_ssid_to_sibling_roms.py index dd10ea78e..e536931a8 100644 --- a/backend/alembic/versions/0038_add_ssid_to_sibling_roms.py +++ b/backend/alembic/versions/0038_add_ssid_to_sibling_roms.py @@ -1,4 +1,4 @@ -"""empty message +"""Add ScreenScraper ID to sibling_roms view Revision ID: 0038_add_ssid_to_sibling_roms Revises: 0037_virtual_rom_columns diff --git a/backend/alembic/versions/0041_assets_t_thumb_cleanup.py b/backend/alembic/versions/0041_assets_t_thumb_cleanup.py index d14a7beea..8b4addd59 100644 --- a/backend/alembic/versions/0041_assets_t_thumb_cleanup.py +++ b/backend/alembic/versions/0041_assets_t_thumb_cleanup.py @@ -1,4 +1,4 @@ -"""empty message +"""Use higher resolution images for assets Revision ID: 0041_assets_t_thumb_cleanup Revises: 0040_migrate_assets_paths diff --git a/backend/alembic/versions/0042_add_missing_from_fs.py b/backend/alembic/versions/0042_add_missing_from_fs.py index 8a2899a33..f7dd1daa0 100644 --- a/backend/alembic/versions/0042_add_missing_from_fs.py +++ b/backend/alembic/versions/0042_add_missing_from_fs.py @@ -1,4 +1,4 @@ -"""empty message +"""Add missing from filesystem to paltform, roms and files Revision ID: 0042_add_missing_from_fs Revises: 0041_assets_t_thumb_cleanup diff --git a/backend/alembic/versions/0043_launchbox_id.py b/backend/alembic/versions/0043_launchbox_id.py index 72aa974d1..c91a1cf39 100644 --- a/backend/alembic/versions/0043_launchbox_id.py +++ b/backend/alembic/versions/0043_launchbox_id.py @@ -1,4 +1,4 @@ -"""empty message +"""Add Launchbox data and remove RetroAchievements metadata from rom_user Revision ID: 0043_launchbox_id Revises: 0042_add_missing_from_fs diff --git a/backend/alembic/versions/0044_hasheous_id.py b/backend/alembic/versions/0044_hasheous_id.py index 84b447c12..47bda4517 100644 --- a/backend/alembic/versions/0044_hasheous_id.py +++ b/backend/alembic/versions/0044_hasheous_id.py @@ -1,4 +1,4 @@ -"""empty message +"""Add Hasheous and TGDB data Revision ID: 0044_hasheous_id Revises: 0043_launchbox_id diff --git a/backend/alembic/versions/0045_roms_metadata_update.py b/backend/alembic/versions/0045_roms_metadata_update.py index b3baf2247..e644429fc 100644 --- a/backend/alembic/versions/0045_roms_metadata_update.py +++ b/backend/alembic/versions/0045_roms_metadata_update.py @@ -1,4 +1,4 @@ -"""empty message +"""Create or update roms_metadata view Revision ID: 0045_roms_metadata_update Revises: 0044_hasheous_id diff --git a/backend/alembic/versions/0046_migrate_platform_slugs.py b/backend/alembic/versions/0046_migrate_platform_slugs.py index 447fb8642..a8f203688 100644 --- a/backend/alembic/versions/0046_migrate_platform_slugs.py +++ b/backend/alembic/versions/0046_migrate_platform_slugs.py @@ -1,4 +1,4 @@ -"""empty message +"""Standardize platform slugs Revision ID: 0046_migrate_platform_slugs Revises: 0045_roms_metadata_update diff --git a/backend/alembic/versions/0049_add_fs_size_bytes.py b/backend/alembic/versions/0049_add_fs_size_bytes.py index f7f734473..ad97b22ae 100644 --- a/backend/alembic/versions/0049_add_fs_size_bytes.py +++ b/backend/alembic/versions/0049_add_fs_size_bytes.py @@ -1,4 +1,4 @@ -"""empty message +"""Add fs_size_bytes to roms table Revision ID: 0049_add_fs_size_bytes Revises: 0048_sibling_roms_more_ids diff --git a/backend/alembic/versions/0051_flashpoint_metadata.py b/backend/alembic/versions/0051_flashpoint_metadata.py index 12bd01315..27fa591ee 100644 --- a/backend/alembic/versions/0051_flashpoint_metadata.py +++ b/backend/alembic/versions/0051_flashpoint_metadata.py @@ -1,4 +1,4 @@ -"""empty message +"""Add Flashpoint metadata Revision ID: 0051_flashpoint_metadata Revises: 0050_firmware_add_is_verified diff --git a/backend/alembic/versions/0052_roms_metadata_flashpoint.py b/backend/alembic/versions/0052_roms_metadata_flashpoint.py index c0cc380e9..5e4f246e2 100644 --- a/backend/alembic/versions/0052_roms_metadata_flashpoint.py +++ b/backend/alembic/versions/0052_roms_metadata_flashpoint.py @@ -1,4 +1,4 @@ -"""empty message +"""Update roms_metadata view to include flashpoint metadata Revision ID: 0052_roms_metadata_flashpoint Revises: 0051_flashpoint_metadata diff --git a/backend/alembic/versions/0054_add_platform_metadata_slugs.py b/backend/alembic/versions/0054_add_platform_metadata_slugs.py index b29cb08ec..e724f0a9d 100644 --- a/backend/alembic/versions/0054_add_platform_metadata_slugs.py +++ b/backend/alembic/versions/0054_add_platform_metadata_slugs.py @@ -1,4 +1,4 @@ -"""empty message +"""Add metadata slugs to platform Revision ID: 0054_add_platform_metadata_slugs Revises: 0053_add_hltb_metadata diff --git a/backend/alembic/versions/0055_collection_is_favorite.py b/backend/alembic/versions/0055_collection_is_favorite.py index f58a44df4..d290875b0 100644 --- a/backend/alembic/versions/0055_collection_is_favorite.py +++ b/backend/alembic/versions/0055_collection_is_favorite.py @@ -1,4 +1,4 @@ -"""empty message +"""Add is_favorite to collections Revision ID: 0055_collection_is_favorite Revises: 0054_add_platform_metadata_slugs diff --git a/backend/alembic/versions/0058_roms_metadata_launchbox.py b/backend/alembic/versions/0058_roms_metadata_launchbox.py index 4f0c4e494..50e1e108b 100644 --- a/backend/alembic/versions/0058_roms_metadata_launchbox.py +++ b/backend/alembic/versions/0058_roms_metadata_launchbox.py @@ -1,4 +1,4 @@ -"""empty message +"""Add launchbox to roms_metadata view Revision ID: 0058_roms_metadata_launchbox Revises: 0057_multi_notes diff --git a/backend/alembic/versions/0059_rom_version_tag.py b/backend/alembic/versions/0059_rom_version_tag.py new file mode 100644 index 000000000..c5ca88f58 --- /dev/null +++ b/backend/alembic/versions/0059_rom_version_tag.py @@ -0,0 +1,26 @@ +"""Adds version column to roms table + +Revision ID: 0059_rom_version_tag +Revises: 0058_roms_metadata_launchbox +Create Date: 2025-12-30 10:48:45.025990 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "0059_rom_version_tag" +down_revision = "0058_roms_metadata_launchbox" +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("version", sa.String(length=100), nullable=True)) + + +def downgrade() -> None: + with op.batch_alter_table("roms", schema=None) as batch_op: + batch_op.drop_column("version") diff --git a/backend/alembic/versions/0060_user_ui_settings.py b/backend/alembic/versions/0060_user_ui_settings.py new file mode 100644 index 000000000..6ad6bf9a7 --- /dev/null +++ b/backend/alembic/versions/0060_user_ui_settings.py @@ -0,0 +1,37 @@ +"""user_ui_settings + +Revision ID: 0060_user_ui_settings +Revises: 0059_rom_version_tag +Create Date: 2025-12-16 21:02:52.394533 + +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "0060_user_ui_settings" +down_revision = "0059_rom_version_tag" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Add ui_settings column to users table.""" + with op.batch_alter_table("users", schema=None) as batch_op: + batch_op.add_column( + sa.Column( + "ui_settings", + sa.JSON().with_variant( + postgresql.JSONB(astext_type=sa.Text()), "postgresql" + ), + nullable=True, + ) + ) + + +def downgrade() -> None: + """Remove ui_settings column from users table.""" + with op.batch_alter_table("users", schema=None) as batch_op: + batch_op.drop_column("ui_settings") diff --git a/backend/endpoints/feeds.py b/backend/endpoints/feeds.py index 4925028ad..c5acf8241 100644 --- a/backend/endpoints/feeds.py +++ b/backend/endpoints/feeds.py @@ -95,7 +95,7 @@ def platforms_webrcade_feed(request: Request) -> WebrcadeFeedSchema: continue category_items = [] - roms = db_rom_handler.get_roms_scalar(platform_id=p.id) + roms = db_rom_handler.get_roms_scalar(platform_ids=[p.id]) for rom in roms: download_url = generate_rom_download_url(request, rom) category_item = WebrcadeFeedItemSchema( @@ -206,7 +206,7 @@ async def tinfoil_index_feed( return titledb - roms = db_rom_handler.get_roms_scalar(platform_id=switch.id) + roms = db_rom_handler.get_roms_scalar(platform_ids=[switch.id]) return TinfoilFeedSchema( files=[ @@ -294,7 +294,7 @@ def pkgi_ps3_feed( status_code=400, detail=f"Invalid content type: {content_type}" ) from e - roms = db_rom_handler.get_roms_scalar(platform_id=ps3_platform.id) + roms = db_rom_handler.get_roms_scalar(platform_ids=[ps3_platform.id]) txt_lines = [] for rom in roms: @@ -365,7 +365,7 @@ def pkgi_psvita_feed( status_code=400, detail=f"Invalid content type: {content_type}" ) from e - roms = db_rom_handler.get_roms_scalar(platform_id=psvita_platform.id) + roms = db_rom_handler.get_roms_scalar(platform_ids=[psvita_platform.id]) txt_lines = [] for rom in roms: @@ -436,7 +436,7 @@ def pkgi_psp_feed( status_code=400, detail=f"Invalid content type: {content_type}" ) from e - roms = db_rom_handler.get_roms_scalar(platform_id=psp_platform.id) + roms = db_rom_handler.get_roms_scalar(platform_ids=[psp_platform.id]) txt_lines = [] for rom in roms: @@ -505,7 +505,7 @@ def fpkgi_feed(request: Request, platform_slug: str) -> Response: status_code=404, detail=f"Platform {platform_slug} not found" ) - roms = db_rom_handler.get_roms_scalar(platform_id=platform.id) + roms = db_rom_handler.get_roms_scalar(platform_ids=[platform.id]) response_data = {} for rom in roms: @@ -551,7 +551,7 @@ def kekatsu_ds_feed(request: Request, platform_slug: str) -> Response: status_code=404, detail=f"Platform {platform_slug} not found" ) - roms = db_rom_handler.get_roms_scalar(platform_id=platform.id) + roms = db_rom_handler.get_roms_scalar(platform_ids=[platform.id]) txt_lines = [] txt_lines.append("1") # Database version diff --git a/backend/endpoints/forms/identity.py b/backend/endpoints/forms/identity.py index 312703a20..285d60bdb 100644 --- a/backend/endpoints/forms/identity.py +++ b/backend/endpoints/forms/identity.py @@ -11,6 +11,7 @@ class UserForm(BaseModel): enabled: bool | None = None ra_username: str | None = None avatar: UploadFile | None = None + ui_settings: str | None = None class OAuth2RequestForm: diff --git a/backend/endpoints/responses/identity.py b/backend/endpoints/responses/identity.py index cd96572a6..5ee5546ef 100644 --- a/backend/endpoints/responses/identity.py +++ b/backend/endpoints/responses/identity.py @@ -25,6 +25,7 @@ class UserSchema(BaseModel): last_active: datetime | None ra_username: str | None = None ra_progression: RAProgression | None = None + ui_settings: dict | None = None created_at: datetime updated_at: datetime diff --git a/backend/endpoints/responses/rom.py b/backend/endpoints/responses/rom.py index 5249f0cb8..561728e12 100644 --- a/backend/endpoints/responses/rom.py +++ b/backend/endpoints/responses/rom.py @@ -269,6 +269,7 @@ class RomSchema(BaseModel): created_at: datetime updated_at: datetime missing_from_fs: bool + has_notes: bool siblings: list[SiblingRomSchema] rom_user: RomUserSchema @@ -290,6 +291,9 @@ class RomSchema(BaseModel): ) for s in db_rom.sibling_roms ] + db_rom.has_notes = any( # type: ignore + note.is_public or note.user_id == request.user.id for note in db_rom.notes + ) return db_rom @classmethod @@ -338,6 +342,7 @@ class SimpleRomSchema(RomSchema): def from_orm_with_factory(cls, db_rom: Rom) -> SimpleRomSchema: db_rom.rom_user = rom_user_schema_factory() # type: ignore db_rom.siblings = [] # type: ignore + db_rom.has_notes = False # type: ignore return cls.model_validate(db_rom) diff --git a/backend/endpoints/rom.py b/backend/endpoints/rom.py index 6997d0b7b..0311ccc1b 100644 --- a/backend/endpoints/rom.py +++ b/backend/endpoints/rom.py @@ -200,9 +200,14 @@ def get_roms( str | None, Query(description="Search term to filter roms."), ] = None, - platform_id: Annotated[ - int | None, - Query(description="Platform internal id.", ge=1), + platform_ids: Annotated[ + list[int] | None, + Query( + description=( + "Platform internal ids. Multiple values are allowed by repeating the" + " parameter, and results that match any of the values will be returned." + ), + ), ] = None, collection_id: Annotated[ int | None, @@ -218,7 +223,7 @@ def get_roms( ] = None, matched: Annotated[ bool | None, - Query(description="Whether the rom matched a metadata source."), + Query(description="Whether the rom matched at least one metadata source."), ] = None, favorite: Annotated[ bool | None, @@ -228,6 +233,12 @@ def get_roms( bool | None, Query(description="Whether the rom is marked as duplicate."), ] = None, + last_played: Annotated[ + bool | None, + Query( + description="Whether the rom has a last played value for the current user." + ), + ] = None, playable: Annotated[ bool | None, Query(description="Whether the rom is playable from the browser."), @@ -242,9 +253,7 @@ def get_roms( ] = None, verified: Annotated[ bool | None, - Query( - description="Whether the rom is verified by Hasheous from the filesystem." - ), + Query(description="Whether the rom is verified by Hasheous."), ] = None, group_by_meta_id: Annotated[ bool, @@ -252,38 +261,127 @@ def get_roms( description="Whether to group roms by metadata ID (IGDB / Moby / ScreenScraper / RetroAchievements / LaunchBox)." ), ] = False, - selected_genre: Annotated[ - str | None, - Query(description="Associated genre."), + genres: Annotated[ + list[str] | None, + Query( + description=( + "Associated genre. Multiple values are allowed by repeating the" + " parameter, and results that match any of the values will be returned." + ), + ), ] = None, - selected_franchise: Annotated[ - str | None, - Query(description="Associated franchise."), + franchises: Annotated[ + list[str] | None, + Query( + description=( + "Associated franchise. Multiple values are allowed by repeating" + " the parameter, and results that match any of the values will be returned." + ), + ), ] = None, - selected_collection: Annotated[ - str | None, - Query(description="Associated collection."), + collections: Annotated[ + list[str] | None, + Query( + description=( + "Associated collection. Multiple values are allowed by repeating" + " the parameter, and results that match any of the values will be returned." + ), + ), ] = None, - selected_company: Annotated[ - str | None, - Query(description="Associated company."), + companies: Annotated[ + list[str] | None, + Query( + description=( + "Associated company. Multiple values are allowed by repeating" + " the parameter, and results that match any of the values will be returned." + ), + ), ] = None, - selected_age_rating: Annotated[ - str | None, - Query(description="Associated age rating."), + age_ratings: Annotated[ + list[str] | None, + Query( + description=( + "Associated age rating. Multiple values are allowed by repeating" + " the parameter, and results that match any of the values will be returned." + ), + ), ] = None, - selected_status: Annotated[ - str | None, - Query(description="Game status, set by the current user."), + selected_statuses: Annotated[ + list[str] | None, + Query( + description=( + "Game status, set by the current user. Multiple values are allowed by repeating" + " the parameter, and results that match any of the values will be returned." + ), + ), ] = None, - selected_region: Annotated[ - str | None, - Query(description="Associated region tag."), + regions: Annotated[ + list[str] | None, + Query( + description=( + "Associated region tag. Multiple values are allowed by repeating" + " the parameter, and results that match any of the values will be returned." + ), + ), ] = None, - selected_language: Annotated[ - str | None, - Query(description="Associated language tag."), + languages: Annotated[ + list[str] | None, + Query( + description=( + "Associated language tag. Multiple values are allowed by repeating" + " the parameter, and results that match any of the values will be returned." + ), + ), ] = None, + # Logic operators for multi-value filters + genres_logic: Annotated[ + str, + Query( + description="Logic operator for genres filter: 'any' (OR) or 'all' (AND).", + ), + ] = "any", + franchises_logic: Annotated[ + str, + Query( + description="Logic operator for franchises filter: 'any' (OR) or 'all' (AND).", + ), + ] = "any", + collections_logic: Annotated[ + str, + Query( + description="Logic operator for collections filter: 'any' (OR) or 'all' (AND).", + ), + ] = "any", + companies_logic: Annotated[ + str, + Query( + description="Logic operator for companies filter: 'any' (OR) or 'all' (AND).", + ), + ] = "any", + age_ratings_logic: Annotated[ + str, + Query( + description="Logic operator for age ratings filter: 'any' (OR) or 'all' (AND).", + ), + ] = "any", + regions_logic: Annotated[ + str, + Query( + description="Logic operator for regions filter: 'any' (OR) or 'all' (AND).", + ), + ] = "any", + languages_logic: Annotated[ + str, + Query( + description="Logic operator for languages filter: 'any' (OR) or 'all' (AND).", + ), + ] = "any", + statuses_logic: Annotated[ + str, + Query( + description="Logic operator for statuses filter: 'any' (OR) or 'all' (AND).", + ), + ] = "any", order_by: Annotated[ str, Query(description="Field to order results by."), @@ -294,8 +392,6 @@ def get_roms( ] = "asc", ) -> CustomLimitOffsetPage[SimpleRomSchema]: """Retrieve roms.""" - - # Get the base roms query query, order_by_attr = db_rom_handler.get_roms_query( user_id=request.user.id, order_by=order_by.lower(), @@ -306,7 +402,7 @@ def get_roms( query = db_rom_handler.filter_roms( query=query, user_id=request.user.id, - platform_id=platform_id, + platform_ids=platform_ids, collection_id=collection_id, virtual_collection_id=virtual_collection_id, smart_collection_id=smart_collection_id, @@ -314,18 +410,28 @@ def get_roms( matched=matched, favorite=favorite, duplicate=duplicate, + last_played=last_played, playable=playable, has_ra=has_ra, missing=missing, verified=verified, - selected_genre=selected_genre, - selected_franchise=selected_franchise, - selected_collection=selected_collection, - selected_company=selected_company, - selected_age_rating=selected_age_rating, - selected_status=selected_status, - selected_region=selected_region, - selected_language=selected_language, + genres=genres, + franchises=franchises, + collections=collections, + companies=companies, + age_ratings=age_ratings, + selected_statuses=selected_statuses, + regions=regions, + languages=languages, + # Logic operators + genres_logic=genres_logic, + franchises_logic=franchises_logic, + collections_logic=collections_logic, + companies_logic=companies_logic, + age_ratings_logic=age_ratings_logic, + regions_logic=regions_logic, + languages_logic=languages_logic, + statuses_logic=statuses_logic, group_by_meta_id=group_by_meta_id, ) diff --git a/backend/endpoints/sockets/scan.py b/backend/endpoints/sockets/scan.py index a027a726a..3f8af1a84 100644 --- a/backend/endpoints/sockets/scan.py +++ b/backend/endpoints/sockets/scan.py @@ -253,9 +253,7 @@ async def _identify_rom( return # Update properties that don't require metadata - fs_regions, fs_revisions, fs_languages, fs_other_tags = fs_rom_handler.parse_tags( - fs_rom["fs_name"] - ) + parsed_tags = fs_rom_handler.parse_tags(fs_rom["fs_name"]) roms_path = fs_rom_handler.get_roms_fs_structure(platform.fs_slug) # Create the entry early so we have the ID @@ -272,10 +270,11 @@ async def _identify_rom( fs_rom["fs_name"] ), fs_extension=fs_rom_handler.parse_file_extension(fs_rom["fs_name"]), - regions=fs_regions, - revision=fs_revisions, - languages=fs_languages, - tags=fs_other_tags, + regions=parsed_tags.regions, + revision=parsed_tags.revision, + version=parsed_tags.version, + languages=parsed_tags.languages, + tags=parsed_tags.other_tags, platform_id=platform.id, name=fs_rom["fs_name"], url_cover="", @@ -296,20 +295,17 @@ async def _identify_rom( calculate_hashes = not cm.get_config().SKIP_HASH_CALCULATION if calculate_hashes: log.debug(f"Calculating file hashes for {rom.fs_name}...") - ( - rom_files, - rom_crc_c, - rom_md5_h, - rom_sha1_h, - rom_ra_h, - ) = await fs_rom_handler.get_rom_files(rom, calculate_hashes=calculate_hashes) + + parsed_rom_files = await fs_rom_handler.get_rom_files( + rom, calculate_hashes=calculate_hashes + ) fs_rom.update( { - "files": rom_files, - "crc_hash": rom_crc_c, - "md5_hash": rom_md5_h, - "sha1_hash": rom_sha1_h, - "ra_hash": rom_ra_h, + "files": parsed_rom_files.rom_files, + "crc_hash": parsed_rom_files.crc_hash, + "md5_hash": parsed_rom_files.md5_hash, + "sha1_hash": parsed_rom_files.sha1_hash, + "ra_hash": parsed_rom_files.ra_hash, } ) diff --git a/backend/endpoints/user.py b/backend/endpoints/user.py index 6488fa92e..48d31151f 100644 --- a/backend/endpoints/user.py +++ b/backend/endpoints/user.py @@ -1,3 +1,4 @@ +import json from typing import Annotated, Any, cast from fastapi import Body, Form, HTTPException @@ -335,6 +336,25 @@ async def update_user( if form_data.ra_username: cleaned_data["ra_username"] = form_data.ra_username # type: ignore[assignment] + if form_data.ui_settings is not None: + try: + ui_settings = json.loads(form_data.ui_settings) + if not isinstance(ui_settings, dict): + msg = f"Invalid ui_settings JSON: {ui_settings}" + log.error(msg) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=msg, + ) + cleaned_data["ui_settings"] = ui_settings # type: ignore[assignment] + except (json.JSONDecodeError, ValueError) as exc: + msg = f"Invalid ui_settings JSON: {str(exc)}" + log.error(msg) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=msg, + ) from exc + if form_data.avatar is not None and form_data.avatar.filename is not None: user_avatar_path = fs_asset_handler.build_avatar_path(user=db_user) file_extension = form_data.avatar.filename.split(".")[-1] diff --git a/backend/handler/database/collections_handler.py b/backend/handler/database/collections_handler.py index c104a10e3..82798dc47 100644 --- a/backend/handler/database/collections_handler.py +++ b/backend/handler/database/collections_handler.py @@ -250,9 +250,32 @@ class DBCollectionsHandler(DBBaseHandler): # Extract filter criteria criteria = smart_collection.filter_criteria + # Convert legacy single-value criteria to arrays for backward compatibility + def convert_legacy_filter(new_key: str, old_key: str) -> list[str] | None: + """Convert legacy single-value filter to array format.""" + if new_value := criteria.get(new_key): + return new_value if isinstance(new_value, list) else [new_value] + if old_value := criteria.get(old_key): + return [old_value] + return None + + # Apply conversions + genres = convert_legacy_filter("genres", "selected_genre") + franchises = convert_legacy_filter("franchises", "selected_franchise") + collections = convert_legacy_filter("collections", "selected_collection") + companies = convert_legacy_filter("companies", "selected_company") + age_ratings = convert_legacy_filter("age_ratings", "selected_age_rating") + regions = convert_legacy_filter("regions", "selected_region") + languages = convert_legacy_filter("languages", "selected_language") + # Use the existing filter_roms method with the stored criteria + platform_ids = criteria.get("platform_ids") + if platform_ids is None: + if platform_id := criteria.get("platform_id"): + platform_ids = [platform_id] + return db_rom_handler.get_roms_scalar( - platform_id=criteria.get("platform_id"), + platform_ids=platform_ids, collection_id=criteria.get("collection_id"), virtual_collection_id=criteria.get("virtual_collection_id"), search_term=criteria.get("search_term"), @@ -263,14 +286,23 @@ class DBCollectionsHandler(DBBaseHandler): has_ra=criteria.get("has_ra"), missing=criteria.get("missing"), verified=criteria.get("verified"), - selected_genre=criteria.get("selected_genre"), - selected_franchise=criteria.get("selected_franchise"), - selected_collection=criteria.get("selected_collection"), - selected_company=criteria.get("selected_company"), - selected_age_rating=criteria.get("selected_age_rating"), - selected_status=criteria.get("selected_status"), - selected_region=criteria.get("selected_region"), - selected_language=criteria.get("selected_language"), + genres=genres, + franchises=franchises, + collections=collections, + companies=companies, + age_ratings=age_ratings, + selected_statuses=criteria.get("selected_statuses"), + regions=regions, + languages=languages, + # Logic operators for multi-value filters + genres_logic=criteria.get("genres_logic", "any"), + franchises_logic=criteria.get("franchises_logic", "any"), + collections_logic=criteria.get("collections_logic", "any"), + companies_logic=criteria.get("companies_logic", "any"), + age_ratings_logic=criteria.get("age_ratings_logic", "any"), + regions_logic=criteria.get("regions_logic", "any"), + languages_logic=criteria.get("languages_logic", "any"), + statuses_logic=criteria.get("statuses_logic", "any"), user_id=user_id, order_by=criteria.get("order_by", "name"), order_dir=criteria.get("order_dir", "asc"), diff --git a/backend/handler/database/roms_handler.py b/backend/handler/database/roms_handler.py index 77acd2f74..700064a70 100644 --- a/backend/handler/database/roms_handler.py +++ b/backend/handler/database/roms_handler.py @@ -29,7 +29,11 @@ from handler.metadata.base_handler import UniversalPlatformSlug as UPS from models.assets import Save, Screenshot, State from models.platform import Platform from models.rom import Rom, RomFile, RomMetadata, RomNote, RomUser -from utils.database import json_array_contains_value +from utils.database import ( + json_array_contains_all, + json_array_contains_any, + json_array_contains_value, +) from .base_handler import DBBaseHandler @@ -106,7 +110,7 @@ def with_details(func): def wrapper(*args, **kwargs): kwargs["query"] = select(Rom).options( # Ensure platform is loaded for main ROM objects - joinedload(Rom.platform), + selectinload(Rom.platform), selectinload(Rom.saves).options( noload(Save.rom), noload(Save.user), @@ -127,6 +131,7 @@ def with_details(func): noload(Rom.platform), noload(Rom.metadatum) ), selectinload(Rom.collections), + selectinload(Rom.notes), ) return func(*args, **kwargs) @@ -138,7 +143,7 @@ def with_simple(func): def wrapper(*args, **kwargs): kwargs["query"] = select(Rom).options( # Ensure platform is loaded for main ROM objects - joinedload(Rom.platform), + selectinload(Rom.platform), # Display properties for the current user (last_played) selectinload(Rom.rom_users).options(noload(RomUser.rom)), # Sort table by metadata (first_release_date) @@ -151,6 +156,8 @@ def with_simple(func): selectinload(Rom.sibling_roms).options( noload(Rom.platform), noload(Rom.metadatum) ), + # Show notes indicator on cards + selectinload(Rom.notes), ) return func(*args, **kwargs) @@ -199,6 +206,11 @@ class DBRomsHandler(DBBaseHandler): def filter_by_platform_id(self, query: Query, platform_id: int): return query.filter(Rom.platform_id == platform_id) + def filter_by_platform_ids( + self, query: Query, platform_ids: Sequence[int] + ) -> Query: + return query.filter(Rom.platform_id.in_(platform_ids)) + def filter_by_collection_id( self, query: Query, session: Session, collection_id: int ): @@ -247,7 +259,11 @@ class DBRomsHandler(DBBaseHandler): ) def filter_by_matched(self, query: Query, value: bool) -> Query: - """Filter based on whether the rom is matched to a metadata provider.""" + """Filter based on whether the rom is matched to a metadata provider. + + Args: + value: True for matched ROMs, False for unmatched ROMs + """ predicate = or_( Rom.igdb_id.isnot(None), Rom.moby_id.isnot(None), @@ -298,6 +314,20 @@ class DBRomsHandler(DBBaseHandler): predicate = not_(predicate) return query.join(Platform).filter(predicate) + def filter_by_last_played( + self, query: Query, value: bool, user_id: int | None = None + ) -> Query: + """Filter based on whether the rom has a last played value for the user.""" + if not user_id: + return query + + has_last_played = ( + RomUser.last_played.is_(None) + if not value + else RomUser.last_played.isnot(None) + ) + return query.filter(has_last_played) + def filter_by_has_ra(self, query: Query, value: bool) -> Query: predicate = Rom.ra_id.isnot(None) if not value: @@ -333,60 +363,110 @@ class DBRomsHandler(DBBaseHandler): or_(*(Rom.hasheous_metadata[key].as_boolean() for key in keys_to_check)) ) - def filter_by_genre(self, query: Query, session: Session, value: str) -> Query: - return query.filter( - json_array_contains_value(RomMetadata.genres, value, session=session) - ) + def filter_by_genres( + self, + query: Query, + *, + session: Session, + values: Sequence[str], + match_all: bool = False, + ) -> Query: + op = json_array_contains_all if match_all else json_array_contains_any + return query.filter(op(RomMetadata.genres, values, session=session)) - def filter_by_franchise(self, query: Query, session: Session, value: str) -> Query: - return query.filter( - json_array_contains_value(RomMetadata.franchises, value, session=session) - ) + def filter_by_franchises( + self, + query: Query, + *, + session: Session, + values: Sequence[str], + match_all: bool = False, + ) -> Query: + op = json_array_contains_all if match_all else json_array_contains_any + return query.filter(op(RomMetadata.franchises, values, session=session)) - def filter_by_collection(self, query: Query, session: Session, value: str) -> Query: - return query.filter( - json_array_contains_value(RomMetadata.collections, value, session=session) - ) + def filter_by_collections( + self, + query: Query, + *, + session: Session, + values: Sequence[str], + match_all: bool = False, + ) -> Query: + op = json_array_contains_all if match_all else json_array_contains_any + return query.filter(op(RomMetadata.collections, values, session=session)) - def filter_by_company(self, query: Query, session: Session, value: str) -> Query: - return query.filter( - json_array_contains_value(RomMetadata.companies, value, session=session) - ) + def filter_by_companies( + self, + query: Query, + *, + session: Session, + values: Sequence[str], + match_all: bool = False, + ) -> Query: + op = json_array_contains_all if match_all else json_array_contains_any + return query.filter(op(RomMetadata.companies, values, session=session)) - def filter_by_age_rating(self, query: Query, session: Session, value: str) -> Query: - return query.filter( - json_array_contains_value(RomMetadata.age_ratings, value, session=session) - ) + def filter_by_age_ratings( + self, + query: Query, + *, + session: Session, + values: Sequence[str], + match_all: bool = False, + ) -> Query: + op = json_array_contains_all if match_all else json_array_contains_any + return query.filter(op(RomMetadata.age_ratings, values, session=session)) - def filter_by_status(self, query: Query, selected_status: str): - status_filter = RomUser.status == selected_status - if selected_status == "now_playing": - status_filter = RomUser.now_playing.is_(True) - elif selected_status == "backlogged": - status_filter = RomUser.backlogged.is_(True) - elif selected_status == "hidden": - status_filter = RomUser.hidden.is_(True) + def filter_by_status(self, query: Query, selected_statuses: Sequence[str]): + """Filter by one or more user statuses using OR logic.""" + if not selected_statuses: + return query - if selected_status == "hidden": - return query.filter(status_filter) + status_filters = [] + for selected_status in selected_statuses: + if selected_status == "now_playing": + status_filters.append(RomUser.now_playing.is_(True)) + elif selected_status == "backlogged": + status_filters.append(RomUser.backlogged.is_(True)) + elif selected_status == "hidden": + status_filters.append(RomUser.hidden.is_(True)) + else: + status_filters.append(RomUser.status == selected_status) - return query.filter(status_filter, RomUser.hidden.is_(False)) + # If hidden is in the list, don't apply the hidden filter at the end + if "hidden" in selected_statuses: + return query.filter(or_(*status_filters)) - def filter_by_region(self, query: Query, session: Session, value: str) -> Query: - return query.filter( - json_array_contains_value(Rom.regions, value, session=session) - ) + return query.filter(or_(*status_filters), RomUser.hidden.is_(False)) - def filter_by_language(self, query: Query, session: Session, value: str) -> Query: - return query.filter( - json_array_contains_value(Rom.languages, value, session=session) - ) + def filter_by_regions( + self, + query: Query, + *, + session: Session, + values: Sequence[str], + match_all: bool = False, + ) -> Query: + op = json_array_contains_all if match_all else json_array_contains_any + return query.filter(op(Rom.regions, values, session=session)) + + def filter_by_languages( + self, + query: Query, + *, + session: Session, + values: Sequence[str], + match_all: bool = False, + ) -> Query: + op = json_array_contains_all if match_all else json_array_contains_any + return query.filter(op(Rom.languages, values, session=session)) @begin_session def filter_roms( self, query: Query, - platform_id: int | None = None, + platform_ids: Sequence[int] | None = None, collection_id: int | None = None, virtual_collection_id: str | None = None, smart_collection_id: int | None = None, @@ -394,26 +474,37 @@ class DBRomsHandler(DBBaseHandler): matched: bool | None = None, favorite: bool | None = None, duplicate: bool | None = None, + last_played: bool | None = None, playable: bool | None = None, has_ra: bool | None = None, missing: bool | None = None, verified: bool | None = None, group_by_meta_id: bool = False, - selected_genre: str | None = None, - selected_franchise: str | None = None, - selected_collection: str | None = None, - selected_company: str | None = None, - selected_age_rating: str | None = None, - selected_status: str | None = None, - selected_region: str | None = None, - selected_language: str | None = None, + genres: Sequence[str] | None = None, + franchises: Sequence[str] | None = None, + collections: Sequence[str] | None = None, + companies: Sequence[str] | None = None, + age_ratings: Sequence[str] | None = None, + selected_statuses: Sequence[str] | None = None, + regions: Sequence[str] | None = None, + languages: Sequence[str] | None = None, + # Logic operators for multi-value filters + genres_logic: str = "any", + franchises_logic: str = "any", + collections_logic: str = "any", + companies_logic: str = "any", + age_ratings_logic: str = "any", + regions_logic: str = "any", + languages_logic: str = "any", + statuses_logic: str = "any", user_id: int | None = None, session: Session = None, # type: ignore ) -> Query[Rom]: from handler.scan_handler import MetadataSource - if platform_id: - query = self.filter_by_platform_id(query, platform_id) + # Handle platform filtering - platform filtering always uses OR logic since ROMs belong to only one platform + if platform_ids: + query = self.filter_by_platform_ids(query, platform_ids) if collection_id: query = self.filter_by_collection_id(query, session, collection_id) @@ -442,6 +533,11 @@ class DBRomsHandler(DBBaseHandler): if duplicate is not None: query = self.filter_by_duplicate(query, value=duplicate) + if last_played is not None: + query = self.filter_by_last_played( + query, value=last_played, user_id=user_id + ) + if playable is not None: query = self.filter_by_playable(query, value=playable) @@ -558,43 +654,34 @@ class DBRomsHandler(DBBaseHandler): ) ) - if ( - selected_genre - or selected_franchise - or selected_collection - or selected_company - or selected_age_rating - ): + # Optimize JOINs - only join tables when needed + needs_metadata_join = any( + [genres, franchises, collections, companies, age_ratings] + ) + + if needs_metadata_join: query = query.outerjoin(RomMetadata) - if selected_genre: - query = self.filter_by_genre(query, session=session, value=selected_genre) - if selected_franchise: - query = self.filter_by_franchise( - query, session=session, value=selected_franchise - ) - if selected_collection: - query = self.filter_by_collection( - query, session=session, value=selected_collection - ) - if selected_company: - query = self.filter_by_company( - query, session=session, value=selected_company - ) - if selected_age_rating: - query = self.filter_by_age_rating( - query, session=session, value=selected_age_rating - ) - if selected_region: - query = self.filter_by_region(query, session=session, value=selected_region) - if selected_language: - query = self.filter_by_language( - query, session=session, value=selected_language - ) + # Apply metadata and rom-level filters efficiently + filters_to_apply = [ + (genres, genres_logic, self.filter_by_genres), + (franchises, franchises_logic, self.filter_by_franchises), + (collections, collections_logic, self.filter_by_collections), + (companies, companies_logic, self.filter_by_companies), + (age_ratings, age_ratings_logic, self.filter_by_age_ratings), + (regions, regions_logic, self.filter_by_regions), + (languages, languages_logic, self.filter_by_languages), + ] + + for values, logic, filter_func in filters_to_apply: + if values: + query = filter_func( + query, session=session, values=values, match_all=(logic == "all") + ) # The RomUser table is already joined if user_id is set - if selected_status and user_id: - query = self.filter_by_status(query, selected_status) + if selected_statuses and user_id: + query = self.filter_by_status(query, selected_statuses) elif user_id: query = query.filter( or_(RomUser.hidden.is_(False), RomUser.hidden.is_(None)) @@ -620,12 +707,10 @@ class DBRomsHandler(DBBaseHandler): if user_id and hasattr(RomUser, order_by) and not hasattr(Rom, order_by): order_attr = getattr(RomUser, order_by) - query = query.filter(RomUser.user_id == user_id, order_attr.isnot(None)) + query = query.filter(RomUser.user_id == user_id) elif hasattr(RomMetadata, order_by) and not hasattr(Rom, order_by): order_attr = getattr(RomMetadata, order_by) - query = query.outerjoin(RomMetadata, RomMetadata.rom_id == Rom.id).filter( - order_attr.isnot(None) - ) + query = query.outerjoin(RomMetadata, RomMetadata.rom_id == Rom.id) elif hasattr(Rom, order_by): order_attr = getattr(Rom, order_by) else: @@ -661,25 +746,35 @@ class DBRomsHandler(DBBaseHandler): ) roms = self.filter_roms( query=query, - platform_id=kwargs.get("platform_id", None), + platform_ids=kwargs.get("platform_ids", None), collection_id=kwargs.get("collection_id", None), virtual_collection_id=kwargs.get("virtual_collection_id", None), search_term=kwargs.get("search_term", None), matched=kwargs.get("matched", None), favorite=kwargs.get("favorite", None), duplicate=kwargs.get("duplicate", None), + last_played=kwargs.get("last_played", None), playable=kwargs.get("playable", None), has_ra=kwargs.get("has_ra", None), missing=kwargs.get("missing", None), verified=kwargs.get("verified", None), - selected_genre=kwargs.get("selected_genre", None), - selected_franchise=kwargs.get("selected_franchise", None), - selected_collection=kwargs.get("selected_collection", None), - selected_company=kwargs.get("selected_company", None), - selected_age_rating=kwargs.get("selected_age_rating", None), - selected_status=kwargs.get("selected_status", None), - selected_region=kwargs.get("selected_region", None), - selected_language=kwargs.get("selected_language", None), + genres=kwargs.get("genres", None), + franchises=kwargs.get("franchises", None), + collections=kwargs.get("collections", None), + companies=kwargs.get("companies", None), + age_ratings=kwargs.get("age_ratings", None), + selected_statuses=kwargs.get("selected_statuses", None), + regions=kwargs.get("regions", None), + languages=kwargs.get("languages", None), + # Logic operators for multi-value filters + genres_logic=kwargs.get("genres_logic", "any"), + franchises_logic=kwargs.get("franchises_logic", "any"), + collections_logic=kwargs.get("collections_logic", "any"), + companies_logic=kwargs.get("companies_logic", "any"), + age_ratings_logic=kwargs.get("age_ratings_logic", "any"), + regions_logic=kwargs.get("regions_logic", "any"), + languages_logic=kwargs.get("languages_logic", "any"), + statuses_logic=kwargs.get("statuses_logic", "any"), user_id=kwargs.get("user_id", None), ) return session.scalars(roms).all() diff --git a/backend/handler/filesystem/roms_handler.py b/backend/handler/filesystem/roms_handler.py index 2eca5fee5..5c6401f59 100644 --- a/backend/handler/filesystem/roms_handler.py +++ b/backend/handler/filesystem/roms_handler.py @@ -8,6 +8,7 @@ import tarfile import zipfile import zlib from collections.abc import Callable, Iterator +from dataclasses import dataclass from pathlib import Path from typing import IO, Any, Final, Literal, TypedDict, cast @@ -288,6 +289,28 @@ DEFAULT_CRC_C = 0 DEFAULT_MD5_H_DIGEST = hashlib.md5(usedforsecurity=False).digest() DEFAULT_SHA1_H_DIGEST = hashlib.sha1(usedforsecurity=False).digest() +VERSION_TAG_REGEX = re.compile(r"^(?:version|ver|v)[\s_-]?(.*)", re.I) +REGION_TAG_REGEX = re.compile(r"^reg[\s|-](.*)$", re.I) +REVISION_TAG_REGEX = re.compile(r"^rev[\s|-](.*)$", re.I) + + +@dataclass(frozen=True) +class ParsedTags: + version: str + revision: str + regions: list[str] + languages: list[str] + other_tags: list[str] + + +@dataclass(frozen=True) +class ParsedRomFiles: + rom_files: list[RomFile] + crc_hash: str + md5_hash: str + sha1_hash: str + ra_hash: str + class FSRomsHandler(FSHandler): def __init__(self) -> None: @@ -301,50 +324,64 @@ class FSRomsHandler(FSHandler): else f"{fs_slug}/{cnfg.ROMS_FOLDER_NAME}" ) - def parse_tags(self, fs_name: str) -> tuple: - rev = "" - regs = [] - langs = [] - other_tags = [] - tags = [tag[0] or tag[1] for tag in TAG_REGEX.findall(fs_name)] - tags = [tag for subtags in tags for tag in subtags.split(",")] - tags = [tag.strip() for tag in tags] + def parse_tags(self, fs_name: str) -> ParsedTags: + tags = [ + chunk.strip() + for tag in (m[0] or m[1] for m in TAG_REGEX.findall(fs_name)) + for chunk in tag.split(",") + ] - for tag in tags: - if tag.lower() in REGIONS_BY_SHORTCODE.keys(): - regs.append(REGIONS_BY_SHORTCODE[tag.lower()]) + regions, languages, other_tags = [], [], [] + version = revision = "" + + for raw in tags: + tag = raw.lower() + + # Region by code + if tag in REGIONS_BY_SHORTCODE.keys(): + regions.append(REGIONS_BY_SHORTCODE[tag]) + continue + if tag in REGIONS_NAME_KEYS: + regions.append(raw) continue - if tag.lower() in REGIONS_NAME_KEYS: - regs.append(tag) + # Language by code + if tag in LANGUAGES_BY_SHORTCODE.keys(): + languages.append(LANGUAGES_BY_SHORTCODE[tag]) + continue + if tag in LANGUAGES_NAME_KEYS: + languages.append(raw) continue - if tag.lower() in LANGUAGES_BY_SHORTCODE.keys(): - langs.append(LANGUAGES_BY_SHORTCODE[tag.lower()]) + # Version + version_match = VERSION_TAG_REGEX.match(raw) + if version_match: + version = version_match[1] continue - if tag.lower() in LANGUAGES_NAME_KEYS: - langs.append(tag) + # Region prefix + region_match = REGION_TAG_REGEX.match(raw) + if region_match: + key = region_match[1].lower() + regions.append(REGIONS_BY_SHORTCODE.get(key, region_match[1])) continue - if "reg" in tag.lower(): - match = re.match(r"^reg[\s|-](.*)$", tag, re.IGNORECASE) - if match: - regs.append( - REGIONS_BY_SHORTCODE[match.group(1).lower()] - if match.group(1).lower() in REGIONS_BY_SHORTCODE.keys() - else match.group(1) - ) - continue + # Revision prefix + revision_match = REVISION_TAG_REGEX.match(raw) + if revision_match: + revision = revision_match[1] + continue - if "rev" in tag.lower(): - match = re.match(r"^rev[\s|-](.*)$", tag, re.IGNORECASE) - if match: - rev = match.group(1) - continue + # Anything else + other_tags.append(raw) - other_tags.append(tag) - return regs, rev, langs, other_tags + return ParsedTags( + version=version, + regions=regions, + languages=languages, + revision=revision, + other_tags=other_tags, + ) def exclude_multi_roms(self, roms: list[str]) -> list[str]: excluded_names = cm.get_config().EXCLUDED_MULTI_FILES @@ -387,7 +424,7 @@ class FSRomsHandler(FSHandler): async def get_rom_files( self, rom: Rom, calculate_hashes: bool = True - ) -> tuple[list[RomFile], str, str, str, str]: + ) -> ParsedRomFiles: from adapters.services.rahasher import RAHasherService from handler.metadata import meta_ra_handler @@ -546,20 +583,20 @@ class FSRomsHandler(FSHandler): ) ) - return ( - rom_files, - crc32_to_hex(rom_crc_c) if rom_crc_c != DEFAULT_CRC_C else "", - ( + return ParsedRomFiles( + rom_files=rom_files, + crc_hash=crc32_to_hex(rom_crc_c) if rom_crc_c != DEFAULT_CRC_C else "", + md5_hash=( rom_md5_h.hexdigest() if rom_md5_h and rom_md5_h.digest() != DEFAULT_MD5_H_DIGEST else "" ), - ( + sha1_hash=( rom_sha1_h.hexdigest() if rom_sha1_h and rom_sha1_h.digest() != DEFAULT_SHA1_H_DIGEST else "" ), - rom_ra_h, + ra_hash=rom_ra_h, ) def _calculate_rom_hashes( diff --git a/backend/handler/metadata/base_handler.py b/backend/handler/metadata/base_handler.py index 711fe5693..e7b02469c 100644 --- a/backend/handler/metadata/base_handler.py +++ b/backend/handler/metadata/base_handler.py @@ -377,6 +377,9 @@ class UniversalPlatformSlug(enum.StrEnum): COMPUCOLOR_II = "compucolor-ii" COMPUCORP_PROGRAMMABLE_CALCULATOR = "compucorp-programmable-calculator" CPET = "cpet" + CPS1 = "cps1" + CPS2 = "cps2" + CPS3 = "cps3" CPM = "cpm" CREATIVISION = "creativision" CYBERVISION = "cybervision" @@ -672,6 +675,7 @@ class UniversalPlatformSlug(enum.StrEnum): TI_99 = "ti-99" TI_994A = "ti-994a" TI_PROGRAMMABLE_CALCULATOR = "ti-programmable-calculator" + TIC_80 = "tic-80" TIKI_100 = "tiki-100" TIM = "tim" TIMEX_SINCLAIR_2068 = "timex-sinclair-2068" diff --git a/backend/handler/metadata/hltb_handler.py b/backend/handler/metadata/hltb_handler.py index 20cef8da3..40ff8147b 100644 --- a/backend/handler/metadata/hltb_handler.py +++ b/backend/handler/metadata/hltb_handler.py @@ -16,6 +16,7 @@ from .base_handler import BaseRom, MetadataHandler # Regex to detect HLTB ID tags in filenames like (hltb-12345) HLTB_TAG_REGEX = re.compile(r"\(hltb-(\d+)\)", re.IGNORECASE) +DASH_COLON_REGEX = re.compile(r"\s?-\s") class HLTBPlatform(TypedDict): @@ -411,10 +412,9 @@ class HLTBHandler(MetadataHandler): if not HLTB_API_ENABLED: return HLTBRom(hltb_id=None) - # We replace " - " with ": " to match HowLongToBeat's naming convention - search_term = fs_rom_handler.get_file_name_with_no_tags(fs_name).replace( - " - ", ": " - ) + search_term = fs_rom_handler.get_file_name_with_no_tags(fs_name) + # We replace " - "/"- " with ": " to match HowLongToBeat's naming convention + search_term = re.sub(DASH_COLON_REGEX, ": ", search_term) search_term = self.normalize_search_term(search_term, remove_punctuation=False) # Search for games diff --git a/backend/handler/metadata/launchbox_handler.py b/backend/handler/metadata/launchbox_handler.py index 8d5418bb1..ce081802d 100644 --- a/backend/handler/metadata/launchbox_handler.py +++ b/backend/handler/metadata/launchbox_handler.py @@ -25,6 +25,7 @@ LAUNCHBOX_FILES_KEY: Final[str] = "romm:launchbox_files" # Regex to detect LaunchBox ID tags in filenames like (launchbox-12345) LAUNCHBOX_TAG_REGEX = re.compile(r"\(launchbox-(\d+)\)", re.IGNORECASE) +DASH_COLON_REGEX = re.compile(r"\s?-\s") class LaunchboxPlatform(TypedDict): @@ -259,10 +260,9 @@ class LaunchboxHandler(MetadataHandler): # By default, tags are still stripped to keep scan behavior consistent with previous versions. # If `keep_tags` is True, the full `fs_name` is used for searching. if not keep_tags: - # We replace " - " with ": " to match Launchbox's naming convention - search_term = fs_rom_handler.get_file_name_with_no_tags(fs_name).replace( - " - ", ": " - ) + search_term = fs_rom_handler.get_file_name_with_no_tags(fs_name) + # We replace " - "/"- " with ": " to match Launchbox's naming convention + search_term = re.sub(DASH_COLON_REGEX, ": ", search_term) else: search_term = fs_name diff --git a/backend/handler/metadata/ss_handler.py b/backend/handler/metadata/ss_handler.py index c1646bd67..fba463d3b 100644 --- a/backend/handler/metadata/ss_handler.py +++ b/backend/handler/metadata/ss_handler.py @@ -61,76 +61,11 @@ PS1_SS_ID: Final = 57 PS2_SS_ID: Final = 58 PSP_SS_ID: Final = 61 SWITCH_SS_ID: Final = 225 -ARCADE_SS_IDS: Final = [ - 6, - 7, - 8, - 47, - 49, - 52, - 53, - 54, - 55, - 56, - 68, - 69, - 75, - 112, - 142, - 147, - 148, - 149, - 150, - 151, - 152, - 153, - 154, - 155, - 156, - 157, - 158, - 159, - 160, - 161, - 162, - 163, - 164, - 165, - 166, - 167, - 168, - 169, - 170, - 173, - 174, - 175, - 176, - 177, - 178, - 179, - 180, - 181, - 182, - 183, - 184, - 185, - 186, - 187, - 188, - 189, - 190, - 191, - 192, - 193, - 194, - 195, - 196, - 209, - 227, - 130, - 158, - 269, -] +ARCADE_SS_ID: Final = 75 +CPS1_SS_ID: Final = 6 +CPS2_SS_ID: Final = 7 +CPS3_SS_ID: Final = 8 +ARCADES_SS_IDS: Final = [ARCADE_SS_ID, CPS1_SS_ID, CPS2_SS_ID, CPS3_SS_ID] # Regex to detect ScreenScraper ID tags in filenames like (ssfr-12345) SS_TAG_REGEX = re.compile(r"\(ssfr-(\d+)\)", re.IGNORECASE) @@ -720,7 +655,7 @@ class SSHandler(MetadataHandler): ) # Support for MAME arcade filename format - if platform_ss_id in ARCADE_SS_IDS: + if platform_ss_id in ARCADES_SS_IDS: search_term = await self._mame_format(search_term) fallback_rom = SSRom(ss_id=None, name=search_term) @@ -805,6 +740,7 @@ SCREENSAVER_PLATFORM_LIST: dict[UPS, SlugToSSId] = { UPS.ANDROID: {"id": 63, "name": "Android"}, UPS.APPLEII: {"id": 86, "name": "Apple II"}, UPS.APPLE_IIGS: {"id": 51, "name": "Apple IIGS"}, + UPS.ARCADE: {"id": ARCADE_SS_ID, "name": "Arcade"}, UPS.ARCADIA_2001: {"id": 94, "name": "Arcadia 2001"}, UPS.ARDUBOY: {"id": 263, "name": "Arduboy"}, UPS.ATARI2600: {"id": 26, "name": "Atari 2600"}, @@ -814,6 +750,7 @@ SCREENSAVER_PLATFORM_LIST: dict[UPS, SlugToSSId] = { UPS.ATARI_ST: {"id": 42, "name": "Atari ST"}, UPS.ATOM: {"id": 36, "name": "Atom"}, UPS.BBCMICRO: {"id": 37, "name": "BBC Micro"}, + UPS.BK: {"id": 93, "name": "Elektronika BK"}, UPS.ASTROCADE: {"id": 44, "name": "Astrocade"}, UPS.PHILIPS_CD_I: {"id": 133, "name": "CD-i"}, UPS.COMMODORE_CDTV: {"id": 129, "name": "Amiga CDTV"}, @@ -828,6 +765,9 @@ SCREENSAVER_PLATFORM_LIST: dict[UPS, SlugToSSId] = { UPS.C_PLUS_4: {"id": 99, "name": "Plus/4"}, UPS.C16: {"id": 99, "name": "Plus/4"}, UPS.C64: {"id": 66, "name": "Commodore 64"}, + UPS.CPS1: {"id": CPS1_SS_ID, "name": "Capcom Play System"}, + UPS.CPS2: {"id": CPS2_SS_ID, "name": "Capcom Play System 2"}, + UPS.CPS3: {"id": CPS3_SS_ID, "name": "Capcom Play System 3"}, UPS.CPET: {"id": 240, "name": "PET"}, UPS.CREATIVISION: {"id": 241, "name": "CreatiVision"}, UPS.DOS: {"id": 135, "name": "PC Dos"}, @@ -851,6 +791,7 @@ SCREENSAVER_PLATFORM_LIST: dict[UPS, SlugToSSId] = { UPS.GB: {"id": 9, "name": "Game Boy"}, UPS.GBA: {"id": 12, "name": "Game Boy Advance"}, UPS.GBC: {"id": 10, "name": "Game Boy Color"}, + UPS.GAMATE: {"id": 266, "name": "Gamate"}, UPS.GAMEGEAR: {"id": 21, "name": "Game Gear"}, UPS.GAME_DOT_COM: {"id": 121, "name": "Game.com"}, UPS.NGC: {"id": 13, "name": "GameCube"}, @@ -882,25 +823,27 @@ SCREENSAVER_PLATFORM_LIST: dict[UPS, SlugToSSId] = { UPS.N64DD: {"id": 122, "name": "Nintendo 64DD"}, UPS.NDS: {"id": 15, "name": "Nintendo DS"}, UPS.NINTENDO_DSI: {"id": 15, "name": "Nintendo DS"}, - UPS.SWITCH: {"id": 225, "name": "Switch"}, + UPS.SWITCH: {"id": SWITCH_SS_ID, "name": "Switch"}, UPS.ODYSSEY_2: {"id": 104, "name": "Videopac G7000"}, UPS.OPENBOR: {"id": 214, "name": "OpenBOR"}, UPS.ORIC: {"id": 131, "name": "Oric 1 / Atmos"}, UPS.PC_8800_SERIES: {"id": 221, "name": "NEC PC-8801"}, UPS.PC_9800_SERIES: {"id": 208, "name": "NEC PC-9801"}, UPS.PC_FX: {"id": 72, "name": "PC-FX"}, + UPS.PEGASUS: {"id": 83, "name": "Aamber Pegasus"}, UPS.PICO: {"id": 234, "name": "Pico-8"}, UPS.PSVITA: {"id": 62, "name": "PS Vita"}, - UPS.PSP: {"id": 61, "name": "PSP"}, + UPS.PSP: {"id": PSP_SS_ID, "name": "PSP"}, UPS.PALM_OS: {"id": 219, "name": "Palm OS"}, UPS.PHILIPS_VG_5000: {"id": 261, "name": "Philips VG 5000"}, - UPS.PSX: {"id": 57, "name": "Playstation"}, - UPS.PS2: {"id": 58, "name": "Playstation 2"}, + UPS.PSX: {"id": PS1_SS_ID, "name": "Playstation"}, + UPS.PS2: {"id": PS2_SS_ID, "name": "Playstation 2"}, UPS.PS3: {"id": 59, "name": "Playstation 3"}, UPS.PS4: {"id": 60, "name": "Playstation 4"}, UPS.PS5: {"id": 284, "name": "Playstation 5"}, UPS.POKEMON_MINI: {"id": 211, "name": "Pokémon mini"}, UPS.SAM_COUPE: {"id": 213, "name": "MGT SAM Coupé"}, + UPS.SCUMMVM: {"id": 123, "name": "ScummVM"}, UPS.SEGA32: {"id": 19, "name": "Megadrive 32X"}, UPS.SEGACD: {"id": 20, "name": "Mega-CD"}, UPS.SMS: {"id": 2, "name": "Master System"}, @@ -917,6 +860,7 @@ SCREENSAVER_PLATFORM_LIST: dict[UPS, SlugToSSId] = { UPS.SUPERGRAFX: {"id": 105, "name": "PC Engine SuperGrafx"}, UPS.SUPERVISION: {"id": 207, "name": "Watara Supervision"}, UPS.TI_99: {"id": 205, "name": "TI-99/4A"}, + UPS.TIC_80: {"id": 222, "name": "TIC-80"}, UPS.TRS_80_COLOR_COMPUTER: { "id": 144, "name": "TRS-80 Color Computer", diff --git a/backend/models/rom.py b/backend/models/rom.py index 3ff7d9676..2dcdefd7b 100644 --- a/backend/models/rom.py +++ b/backend/models/rom.py @@ -1,5 +1,6 @@ from __future__ import annotations +import copy import enum from datetime import datetime from functools import cached_property @@ -227,6 +228,7 @@ class Rom(BaseModel): ) revision: Mapped[str | None] = mapped_column(String(length=100)) + version: Mapped[str | None] = mapped_column(String(length=100)) regions: Mapped[list[str] | None] = mapped_column(CustomJSON(), default=[]) languages: Mapped[list[str] | None] = mapped_column(CustomJSON(), default=[]) tags: Mapped[list[str] | None] = mapped_column(CustomJSON(), default=[]) @@ -307,13 +309,17 @@ class Rom(BaseModel): def has_simple_single_file(self) -> bool: return len(self.files) == 1 and not self.files[0].is_nested + @cached_property + def _top_level_files(self) -> list[RomFile]: + return [f for f in self.files if f.is_top_level] + @cached_property def has_nested_single_file(self) -> bool: - return len(self.files) == 1 and self.files[0].is_nested + return not self.has_simple_single_file and len(self._top_level_files) == 1 @cached_property def has_multiple_files(self) -> bool: - return len(self.files) > 1 + return len(self._top_level_files) > 1 @property def fs_resources_path(self) -> str: @@ -388,13 +394,18 @@ class Rom(BaseModel): @cached_property def merged_ra_metadata(self) -> dict[str, list] | None: if self.ra_metadata and "achievements" in self.ra_metadata: - for achievement in self.ra_metadata.get("achievements", []): + # Create a deep copy to avoid mutating the original metadata + # This ensures that badge paths remain relative for filesystem operations + # while the frontend receives absolute paths + metadata_copy = copy.deepcopy(self.ra_metadata) + for achievement in metadata_copy.get("achievements", []): achievement["badge_path_lock"] = ( f"{FRONTEND_RESOURCES_PATH}/{achievement['badge_path_lock']}" ) achievement["badge_path"] = ( f"{FRONTEND_RESOURCES_PATH}/{achievement['badge_path']}" ) + return metadata_copy return self.ra_metadata # Used only during scan process diff --git a/backend/models/user.py b/backend/models/user.py index 8e0f60a72..e980ec4d4 100644 --- a/backend/models/user.py +++ b/backend/models/user.py @@ -62,6 +62,9 @@ class User(BaseModel, SimpleUser): ra_progression: Mapped[dict[str, Any] | None] = mapped_column( CustomJSON(), default=dict ) + ui_settings: Mapped[dict[str, Any] | None] = mapped_column( + CustomJSON(), default=dict + ) saves: Mapped[list[Save]] = relationship(lazy="raise", back_populates="user") states: Mapped[list[State]] = relationship(lazy="raise", back_populates="user") diff --git a/backend/tasks/manual/cleanup_orphaned_resources.py b/backend/tasks/manual/cleanup_orphaned_resources.py index 337715503..b065c852b 100644 --- a/backend/tasks/manual/cleanup_orphaned_resources.py +++ b/backend/tasks/manual/cleanup_orphaned_resources.py @@ -68,7 +68,7 @@ class CleanupOrphanedResourcesTask(Task): existing_roms_by_platform: dict[int, set[int]] = { platform_id: { rom.id - for rom in db_rom_handler.get_roms_scalar(platform_id=platform_id) + for rom in db_rom_handler.get_roms_scalar(platform_ids=[platform_id]) } for platform_id in existing_platforms } diff --git a/backend/tests/endpoints/test_identity.py b/backend/tests/endpoints/test_identity.py index 39afbc424..5c1beed74 100644 --- a/backend/tests/endpoints/test_identity.py +++ b/backend/tests/endpoints/test_identity.py @@ -1,4 +1,5 @@ import base64 +import json from datetime import timedelta from http import HTTPStatus from unittest import mock @@ -247,3 +248,108 @@ async def test_logout_invalidates_session(client, admin_user: User): assert response.status_code in [HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN] await RedisSessionMiddleware.clear_user_sessions(admin_user.username) + + +def test_update_user_with_valid_ui_settings( + client, access_token: str, editor_user: User +): + """Test updating a user with valid ui_settings JSON object.""" + valid_ui_settings = { + "theme": "dark", + "language": "en", + "notifications_enabled": True, + "items_per_page": 50, + } + + response = client.put( + f"/api/users/{editor_user.id}", + data={"ui_settings": json.dumps(valid_ui_settings)}, + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == HTTPStatus.OK + + user = response.json() + assert user["ui_settings"] == valid_ui_settings + + # Verify settings are properly stored by retrieving the user again + response = client.get( + f"/api/users/{editor_user.id}", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == HTTPStatus.OK + user = response.json() + assert user["ui_settings"] == valid_ui_settings + + +def test_update_user_with_invalid_ui_settings_json( + client, access_token: str, editor_user: User +): + """Test that updating ui_settings with invalid JSON returns 400.""" + invalid_json = '{"theme": "dark", "language": "en"' # Missing closing brace + + response = client.put( + f"/api/users/{editor_user.id}", + data={"ui_settings": invalid_json}, + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == HTTPStatus.BAD_REQUEST + assert "Invalid ui_settings JSON" in response.json()["detail"] + + +@pytest.mark.parametrize( + "non_object_value", + [ + '["array", "value"]', # Array + '"string_value"', # String + "123", # Number + "true", # Boolean + "null", # Null + ], +) +def test_update_user_with_non_object_ui_settings( + client, access_token: str, editor_user: User, non_object_value: str +): + """Test that updating ui_settings with non-object JSON returns 400.""" + response = client.put( + f"/api/users/{editor_user.id}", + data={"ui_settings": non_object_value}, + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == HTTPStatus.BAD_REQUEST + assert "Invalid ui_settings JSON" in response.json()["detail"] + + +def test_update_user_ui_settings_empty_object( + client, access_token: str, editor_user: User +): + """Test that updating ui_settings with an empty object is valid.""" + response = client.put( + f"/api/users/{editor_user.id}", + data={"ui_settings": "{}"}, + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == HTTPStatus.OK + + user = response.json() + assert user["ui_settings"] == {} + + +def test_update_user_ui_settings_nested_object( + client, access_token: str, editor_user: User +): + """Test that nested objects in ui_settings are properly handled.""" + nested_settings = { + "theme": {"mode": "dark", "accent": "blue"}, + "layout": {"sidebar": {"collapsed": False, "width": 250}}, + "preferences": {"autoSave": True, "confirmDelete": True}, + } + + response = client.put( + f"/api/users/{editor_user.id}", + data={"ui_settings": json.dumps(nested_settings)}, + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == HTTPStatus.OK + + user = response.json() + assert user["ui_settings"] == nested_settings diff --git a/backend/tests/handler/cassettes/test_fastapi/test_scan_rom.yaml b/backend/tests/handler/cassettes/test_fastapi/test_scan_rom.yaml index f218541ee..bc3d67db9 100644 --- a/backend/tests/handler/cassettes/test_fastapi/test_scan_rom.yaml +++ b/backend/tests/handler/cassettes/test_fastapi/test_scan_rom.yaml @@ -16,8 +16,7 @@ interactions: uri: https://playmatch.retrorealm.dev/api/identify/ids?fileName=Paper+Mario+(USA).z64&fileSize=1024 response: body: - string: !!binary | - CxKAeyJnYW1lTWF0Y2hUeXBlIjoiTm9NYXRjaCIsImlkIjpudWxsfQM= + string: '{"gameMatchType":"NoMatch","id":null}' headers: Age: - "81" @@ -29,8 +28,6 @@ interactions: - HIT Connection: - keep-alive - Content-Encoding: - - br Content-Type: - application/json Date: @@ -43,8 +40,6 @@ interactions: - '{"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=mjZ4A7YtYFmLhvxBpW6QPl821crHqZZvQpN%2B8KgRa1zLBnsoeewii%2FWWpbQC7wKBCHw7a818u5WLZOYV60b7as%2Far6YDNSodkqsgt%2BNs4w9z%2F2bhZL22Sw%3D%3D"}]}' Server: - cloudflare - Transfer-Encoding: - - chunked Vary: - accept-encoding X-Ratelimit-Limit: @@ -101,7 +96,7 @@ interactions: message: OK - request: body: - search "paper mario"; fields id,name,slug,summary,total_rating,aggregated_rating,first_release_date,artworks.url,cover.url,screenshots.url,platforms.id,platforms.name,alternative_names.name,genres.name,franchise.name,franchises.name,collections.name,game_modes.name,involved_companies.company.name,expansions.id,expansions.slug,expansions.name,expansions.cover.url,expanded_games.id,expanded_games.slug,expanded_games.name,expanded_games.cover.url,dlcs.id,dlcs.name,dlcs.slug,dlcs.cover.url,remakes.id,remakes.slug,remakes.name,remakes.cover.url,remasters.id,remasters.slug,remasters.name,remasters.cover.url,ports.id,ports.slug,ports.name,ports.cover.url,similar_games.id,similar_games.slug,similar_games.name,similar_games.cover.url,age_ratings.rating_category,videos.video_id; + search "paper mario"; fields id,name,slug,summary,total_rating,aggregated_rating,first_release_date,artworks.url,cover.url,screenshots.url,platforms.id,platforms.name,alternative_names.name,genres.name,franchise.name,franchises.name,collections.name,game_modes.name,involved_companies.company.name,expansions.id,expansions.slug,expansions.name,expansions.cover.url,expanded_games.id,expanded_games.slug,expanded_games.name,expanded_games.cover.url,dlcs.id,dlcs.name,dlcs.slug,dlcs.cover.url,remakes.id,remakes.slug,remakes.name,remakes.cover.url,remasters.id,remasters.slug,remasters.name,remasters.cover.url,ports.id,ports.slug,ports.name,ports.cover.url,similar_games.id,similar_games.slug,similar_games.name,similar_games.cover.url,age_ratings.rating_category,videos.video_id,game_localizations.id,game_localizations.name,game_localizations.cover.url,game_localizations.region.identifier,game_localizations.region.category; where platforms=[4] & game_type=(10,0,11,8,9); limit 200; headers: accept: diff --git a/backend/tests/handler/filesystem/test_roms_handler.py b/backend/tests/handler/filesystem/test_roms_handler.py index a5633f4e5..4694d64bc 100644 --- a/backend/tests/handler/filesystem/test_roms_handler.py +++ b/backend/tests/handler/filesystem/test_roms_handler.py @@ -157,40 +157,69 @@ class TestFSRomsHandler: """Test parse_tags method with regions and languages""" fs_name = "Zelda (USA) (Rev 1) [En,Fr] [Test].n64" - regions, revision, languages, other_tags = handler.parse_tags(fs_name) + parsed_tags = handler.parse_tags(fs_name) - assert "USA" in regions - assert revision == "1" - assert "English" in languages - assert "French" in languages - assert "Test" in other_tags + assert "USA" in parsed_tags.regions + assert parsed_tags.revision == "1" + assert parsed_tags.version == "" + assert "English" in parsed_tags.languages + assert "French" in parsed_tags.languages + assert "Test" in parsed_tags.other_tags def test_parse_tags_complex_tags(self, handler: FSRomsHandler): """Test parse_tags with complex tag structures""" fs_name = "Game (Europe) (En,De,Fr,Es,It) (Rev A) [Reg-PAL] [Beta].rom" - regions, revision, languages, other_tags = handler.parse_tags(fs_name) + parsed_tags = handler.parse_tags(fs_name) - assert "Europe" in regions - assert "PAL" in regions - assert revision == "A" - assert "English" in languages - assert "German" in languages - assert "French" in languages - assert "Spanish" in languages - assert "Italian" in languages - assert "Beta" in other_tags + assert "Europe" in parsed_tags.regions + assert "PAL" in parsed_tags.regions + assert parsed_tags.revision == "A" + assert parsed_tags.version == "" + assert "English" in parsed_tags.languages + assert "German" in parsed_tags.languages + assert "French" in parsed_tags.languages + assert "Spanish" in parsed_tags.languages + assert "Italian" in parsed_tags.languages + assert "Beta" in parsed_tags.other_tags def test_parse_tags_no_tags(self, handler: FSRomsHandler): """Test parse_tags with no tags""" fs_name = "Simple Game.rom" - regions, revision, languages, other_tags = handler.parse_tags(fs_name) + parsed_tags = handler.parse_tags(fs_name) - assert regions == [] - assert revision == "" - assert languages == [] - assert other_tags == [] + assert parsed_tags.regions == [] + assert parsed_tags.revision == "" + assert parsed_tags.version == "" + assert parsed_tags.languages == [] + assert parsed_tags.other_tags == [] + + def test_parse_tags_version(self, handler: FSRomsHandler): + """Test parse_tags method with version tags""" + fs_name = "stardew_valley(v1.5.6.1988831614)(53038).exe" + parsed_tags = handler.parse_tags(fs_name) + assert parsed_tags.version == "1.5.6.1988831614" + assert "53038" in parsed_tags.other_tags + assert parsed_tags.regions == [] + assert parsed_tags.revision == "" + assert parsed_tags.languages == [] + + fs_name = "My Game (Version 1.2.3).rom" + parsed_tags = handler.parse_tags(fs_name) + assert parsed_tags.version == "1.2.3" + + fs_name = "My Game (Ver-1.2.3).rom" + parsed_tags = handler.parse_tags(fs_name) + assert parsed_tags.version == "1.2.3" + + fs_name = "My Game (v_1.2.3).rom" + parsed_tags = handler.parse_tags(fs_name) + assert parsed_tags.version == "1.2.3" + + fs_name = "My Game (v 1.2.3).rom" + parsed_tags = handler.parse_tags(fs_name) + assert parsed_tags.version == "1.2.3" def test_exclude_multi_roms_filters_excluded(self, handler: FSRomsHandler, config): """Test exclude_multi_roms filters out excluded multi-file ROMs""" @@ -318,20 +347,20 @@ class TestFSRomsHandler: m.setattr("handler.filesystem.roms_handler.cm.get_config", lambda: config) m.setattr("os.path.exists", lambda x: False) # Normal structure - rom_files, crc_hash, md5_hash, sha1_hash, ra_hash = ( - await handler.get_rom_files(rom_single) + parsed_rom_files = await handler.get_rom_files(rom_single) + + assert len(parsed_rom_files.rom_files) == 1 + assert isinstance(parsed_rom_files.rom_files[0], RomFile) + assert parsed_rom_files.rom_files[0].file_name == "Paper Mario (USA).z64" + assert parsed_rom_files.rom_files[0].file_path == "n64/roms" + assert parsed_rom_files.rom_files[0].file_size_bytes > 0 + + assert parsed_rom_files.crc_hash == "efb5af2e" + assert parsed_rom_files.md5_hash == "0f343b0931126a20f133d67c2b018a3b" + assert ( + parsed_rom_files.sha1_hash == "60cacbf3d72e1e7834203da608037b1bf83b40e8" ) - assert len(rom_files) == 1 - assert isinstance(rom_files[0], RomFile) - assert rom_files[0].file_name == "Paper Mario (USA).z64" - assert rom_files[0].file_path == "n64/roms" - assert rom_files[0].file_size_bytes > 0 - - assert crc_hash == "efb5af2e" - assert md5_hash == "0f343b0931126a20f133d67c2b018a3b" - assert sha1_hash == "60cacbf3d72e1e7834203da608037b1bf83b40e8" - @pytest.mark.asyncio async def test_get_rom_files_multi_rom( self, handler: FSRomsHandler, rom_multi, config @@ -341,17 +370,15 @@ class TestFSRomsHandler: m.setattr("handler.filesystem.roms_handler.cm.get_config", lambda: config) m.setattr("os.path.exists", lambda x: False) # Normal structure - rom_files, crc_hash, md5_hash, sha1_hash, ra_hash = ( - await handler.get_rom_files(rom_multi) - ) + parsed_rom_files = await handler.get_rom_files(rom_multi) - assert len(rom_files) >= 2 # Should have multiple parts + assert len(parsed_rom_files.rom_files) >= 2 # Should have multiple parts - file_names = [rf.file_name for rf in rom_files] + file_names = [rf.file_name for rf in parsed_rom_files.rom_files] assert "Super Mario 64 (J) (Rev A) [Part 1].z64" in file_names assert "Super Mario 64 (J) (Rev A) [Part 2].z64" in file_names - for rom_file in rom_files: + for rom_file in parsed_rom_files.rom_files: assert isinstance(rom_file, RomFile) assert rom_file.file_size_bytes > 0 assert rom_file.last_modified is not None @@ -462,26 +489,23 @@ class TestFSRomsHandler: def test_tag_parsing_edge_cases(self, handler: FSRomsHandler): """Test tag parsing with edge cases""" # Test with comma-separated tags - regions, revision, languages, other_tags = handler.parse_tags( - "Game (USA,Europe) [En,Fr,De].rom" - ) - assert "USA" in regions - assert "Europe" in regions - assert "English" in languages - assert "French" in languages - assert "German" in languages + parsed_tags = handler.parse_tags("Game (USA,Europe) [En,Fr,De].rom") + assert "USA" in parsed_tags.regions + assert "Europe" in parsed_tags.regions + assert "English" in parsed_tags.languages + assert "French" in parsed_tags.languages + assert "German" in parsed_tags.languages + assert parsed_tags.version == "" # Test with reg- prefix - regions, revision, languages, other_tags = handler.parse_tags( - "Game [Reg-NTSC].rom" - ) - assert "NTSC" in regions + parsed_tags = handler.parse_tags("Game [Reg-NTSC].rom") + assert "NTSC" in parsed_tags.regions + assert parsed_tags.version == "" # Test with rev- prefix - regions, revision, languages, other_tags = handler.parse_tags( - "Game [Rev-B].rom" - ) - assert revision == "B" + parsed_tags = handler.parse_tags("Game [Rev-B].rom") + assert parsed_tags.revision == "B" + assert parsed_tags.version == "" def test_platform_specific_behavior(self, handler: FSRomsHandler): """Test platform-specific behavior differences""" @@ -590,17 +614,15 @@ class TestFSRomsHandler: self, handler: FSRomsHandler, rom_single_nested ): """Test that only top-level files contribute to main ROM hash calculation""" - rom_files, rom_crc, rom_md5, rom_sha1, rom_ra = await handler.get_rom_files( - rom_single_nested - ) + parsed_rom_files = await handler.get_rom_files(rom_single_nested) # Verify we have multiple files (base game + translation) - assert len(rom_files) == 2 + assert len(parsed_rom_files.rom_files) == 2 base_game_rom_file = None translation_rom_file = None - for rom_file in rom_files: + for rom_file in parsed_rom_files.rom_files: if rom_file.file_name == "Sonic (EU) [T].n64": base_game_rom_file = rom_file elif rom_file.file_name == "Sonic (EU) [T-En].z64": @@ -617,17 +639,17 @@ class TestFSRomsHandler: # (this verifies that the translation is not included in the main hash) assert ( - rom_md5 == base_game_rom_file.md5_hash + parsed_rom_files.md5_hash == base_game_rom_file.md5_hash ), "Main ROM hash should include base game file" assert ( - rom_md5 != translation_rom_file.md5_hash + parsed_rom_files.md5_hash != translation_rom_file.md5_hash ), "Main ROM hash should not include translation file" assert ( - rom_sha1 == base_game_rom_file.sha1_hash + parsed_rom_files.sha1_hash == base_game_rom_file.sha1_hash ), "Main ROM hash should include base game file" assert ( - rom_sha1 != translation_rom_file.sha1_hash + parsed_rom_files.sha1_hash != translation_rom_file.sha1_hash ), "Main ROM hash should not include translation file" @pytest.mark.asyncio @@ -670,20 +692,24 @@ class TestFSRomsHandler: ) # Run the hashing process - rom_files, crc_hash, md5_hash, sha1_hash, _ = await test_handler.get_rom_files( - rom - ) + parsed_rom_files = await test_handler.get_rom_files(rom) # Assert that only SHA1 is populated, and it's from the header - assert len(rom_files) == 1 - assert sha1_hash == internal_sha1, "SHA1 should be from CHD v5 header" - assert rom_files[0].sha1_hash == internal_sha1 + assert len(parsed_rom_files.rom_files) == 1 + assert ( + parsed_rom_files.sha1_hash == internal_sha1 + ), "SHA1 should be from CHD v5 header" + assert parsed_rom_files.rom_files[0].sha1_hash == internal_sha1 # CRC32 and MD5 should be empty/zero (not calculated) - assert crc_hash == "", f"CRC hash should be empty, got: {crc_hash}" - assert md5_hash == "", f"MD5 hash should be empty, got: {md5_hash}" - assert rom_files[0].crc_hash == "" - assert rom_files[0].md5_hash == "" + assert ( + parsed_rom_files.crc_hash == "" + ), f"CRC hash should be empty, got: {parsed_rom_files.crc_hash}" + assert ( + parsed_rom_files.md5_hash == "" + ), f"MD5 hash should be empty, got: {parsed_rom_files.md5_hash}" + assert parsed_rom_files.rom_files[0].crc_hash == "" + assert parsed_rom_files.rom_files[0].md5_hash == "" @pytest.mark.asyncio async def test_get_rom_files_with_non_v5_chd_fallback_to_std_hashing( @@ -721,20 +747,24 @@ class TestFSRomsHandler: ) # Run the hashing process - rom_files, crc_hash, md5_hash, sha1_hash, _ = await test_handler.get_rom_files( - rom - ) + parsed_rom_files = await test_handler.get_rom_files(rom) # All hashes should be populated (calculated from file content) - assert len(rom_files) == 1 - assert crc_hash != "", "CRC hash should be calculated for non-v5 CHD" - assert md5_hash != "", "MD5 hash should be calculated for non-v5 CHD" - assert sha1_hash != "", "SHA1 hash should be calculated for non-v5 CHD" + assert len(parsed_rom_files.rom_files) == 1 + assert ( + parsed_rom_files.crc_hash != "" + ), "CRC hash should be calculated for non-v5 CHD" + assert ( + parsed_rom_files.md5_hash != "" + ), "MD5 hash should be calculated for non-v5 CHD" + assert ( + parsed_rom_files.sha1_hash != "" + ), "SHA1 hash should be calculated for non-v5 CHD" # Verify they're actual hash values (not from an internal header) - assert rom_files[0].crc_hash == crc_hash - assert rom_files[0].md5_hash == md5_hash - assert rom_files[0].sha1_hash == sha1_hash + assert parsed_rom_files.rom_files[0].crc_hash == parsed_rom_files.crc_hash + assert parsed_rom_files.rom_files[0].md5_hash == parsed_rom_files.md5_hash + assert parsed_rom_files.rom_files[0].sha1_hash == parsed_rom_files.sha1_hash class TestExtractCHDHash: diff --git a/backend/tests/handler/test_db_handler.py b/backend/tests/handler/test_db_handler.py index 2715ada79..0f373d525 100644 --- a/backend/tests/handler/test_db_handler.py +++ b/backend/tests/handler/test_db_handler.py @@ -1,3 +1,5 @@ +from datetime import datetime, timezone + from sqlalchemy.exc import IntegrityError from handler.auth import auth_handler @@ -47,7 +49,7 @@ def test_roms(rom: Rom, platform: Platform): ) ) - roms = db_rom_handler.get_roms_scalar(platform_id=platform.id) + roms = db_rom_handler.get_roms_scalar(platform_ids=[platform.id]) assert len(roms) == 2 rom_1 = db_rom_handler.get_rom(roms[0].id) @@ -61,15 +63,48 @@ def test_roms(rom: Rom, platform: Platform): db_rom_handler.delete_rom(rom.id) - roms = db_rom_handler.get_roms_scalar(platform_id=platform.id) + roms = db_rom_handler.get_roms_scalar(platform_ids=[platform.id]) assert len(roms) == 1 db_rom_handler.mark_missing_roms(rom_2.platform_id, []) - roms = db_rom_handler.get_roms_scalar(platform_id=platform.id) + roms = db_rom_handler.get_roms_scalar(platform_ids=[platform.id]) assert len(roms) == 1 +def test_filter_last_played(rom: Rom, platform: Platform, admin_user: User): + second_rom = db_rom_handler.add_rom( + Rom( + platform_id=platform.id, + name="test_rom_unplayed", + slug="test_rom_unplayed_slug", + fs_name="test_rom_unplayed.zip", + fs_name_no_tags="test_rom_unplayed", + fs_name_no_ext="test_rom_unplayed", + fs_extension="zip", + fs_path=f"{platform.slug}/roms", + ) + ) + db_rom_handler.add_rom_user(rom_id=second_rom.id, user_id=admin_user.id) + + rom_user = db_rom_handler.get_rom_user(rom.id, admin_user.id) + assert rom_user is not None + + db_rom_handler.update_rom_user( + rom_user.id, {"last_played": datetime(2024, 1, 1, tzinfo=timezone.utc)} + ) + + played_roms = db_rom_handler.get_roms_scalar( + user_id=admin_user.id, last_played=True + ) + assert {r.id for r in played_roms} == {rom.id} + + unplayed_roms = db_rom_handler.get_roms_scalar( + user_id=admin_user.id, last_played=False + ) + assert {r.id for r in unplayed_roms} == {second_rom.id} + + def test_users(admin_user): db_user_handler.add_user( User( diff --git a/backend/utils/database.py b/backend/utils/database.py index d923f8ba8..e1dcfa948 100644 --- a/backend/utils/database.py +++ b/backend/utils/database.py @@ -44,7 +44,7 @@ def is_mariadb(conn: sa.Connection, min_version: tuple[int, ...] | None = None) def json_array_contains_value( - column: sa.Column, value: str | int, *, session: Session + column: sa.Column | Any, value: str | int, *, session: Session ) -> ColumnElement: """Check if a JSON array column contains the given value.""" conn = session.get_bind() @@ -66,12 +66,16 @@ def json_array_contains_value( def json_array_contains_any( - column: sa.Column, values: Sequence[str] | Sequence[int], *, session: Session + column: sa.Column | Any, values: Sequence[str] | Sequence[int], *, session: Session ) -> ColumnElement: """Check if a JSON array column contains any of the given values.""" if not values: return sa.false() + # Optimize for single value case + if len(values) == 1: + return json_array_contains_value(column, values[0], session=session) + conn = session.get_bind() if is_postgresql(conn): # In PostgreSQL, string arrays can be checked for overlap using the `?|` operator. @@ -98,7 +102,7 @@ def json_array_contains_any( def json_array_contains_all( - column: sa.Column, values: Sequence[Any], *, session: Session + column: sa.Column | Any, values: Sequence[Any], *, session: Session ) -> ColumnElement: """Check if a JSON array column contains all of the given values.""" if not values: diff --git a/backend/utils/gamelist_exporter.py b/backend/utils/gamelist_exporter.py index 581b02c88..66987cce8 100644 --- a/backend/utils/gamelist_exporter.py +++ b/backend/utils/gamelist_exporter.py @@ -168,7 +168,7 @@ class GamelistExporter: if not platform: raise ValueError(f"Platform with ID {platform_id} not found") - roms = db_rom_handler.get_roms_scalar(platform_id=platform_id) + roms = db_rom_handler.get_roms_scalar(platform_ids=[platform_id]) # Create root element root = Element("gameList") diff --git a/examples/config.batocera-retrobat.yml b/examples/config.batocera-retrobat.yml index 0c125064c..5924ce148 100644 --- a/examples/config.batocera-retrobat.yml +++ b/examples/config.batocera-retrobat.yml @@ -133,7 +133,7 @@ system: switch: switch thomson: thomson-mo5 ti99: ti-99 - tic80: tic + tic80: tic-80 triforce: arcade tutor: tomy-tutor vectrex: vectrex diff --git a/examples/config.example.yml b/examples/config.example.yml index 514029e0a..3d8c40fe8 100644 --- a/examples/config.example.yml +++ b/examples/config.example.yml @@ -146,13 +146,13 @@ # snes9x_region: ntsc # default: # These settings apply to all cores # fps: show -# netplay: -# enabled: true -# ice_servers: -# - urls: "stun:stun.relay.metered.ca:80" -# - urls: "turn:global.relay.metered.ca:80" -# username: "" -# credential: "" +# netplay: +# enabled: true +# ice_servers: +# - urls: "stun:stun.relay.metered.ca:80" +# - urls: "turn:global.relay.metered.ca:80" +# username: "" +# credential: "" # controls: # https://emulatorjs.org/docs4devs/control-mapping/ # snes9x: # 0: # Player 1 diff --git a/frontend/assets/platforms/systematic/tic.svg b/frontend/assets/platforms/systematic/tic-80.svg similarity index 98% rename from frontend/assets/platforms/systematic/tic.svg rename to frontend/assets/platforms/systematic/tic-80.svg index 1adb81684..5a7964d4e 100755 --- a/frontend/assets/platforms/systematic/tic.svg +++ b/frontend/assets/platforms/systematic/tic-80.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/frontend/assets/platforms/tic.ico b/frontend/assets/platforms/tic-80.ico similarity index 100% rename from frontend/assets/platforms/tic.ico rename to frontend/assets/platforms/tic-80.ico diff --git a/frontend/src/__generated__/models/SimpleRomSchema.ts b/frontend/src/__generated__/models/SimpleRomSchema.ts index b55eb0970..2574a28aa 100644 --- a/frontend/src/__generated__/models/SimpleRomSchema.ts +++ b/frontend/src/__generated__/models/SimpleRomSchema.ts @@ -81,5 +81,6 @@ export type SimpleRomSchema = { rom_user: RomUserSchema; merged_screenshots: Array; merged_ra_metadata: (RomRAMetadata | null); + has_notes?: boolean; }; diff --git a/frontend/src/__generated__/models/UserForm.ts b/frontend/src/__generated__/models/UserForm.ts index 2e5b2bd2d..14526d99c 100644 --- a/frontend/src/__generated__/models/UserForm.ts +++ b/frontend/src/__generated__/models/UserForm.ts @@ -10,5 +10,6 @@ export type UserForm = { enabled?: (boolean | null); ra_username?: (string | null); avatar?: (Blob | null); + ui_settings?: (string | null); }; diff --git a/frontend/src/__generated__/models/UserSchema.ts b/frontend/src/__generated__/models/UserSchema.ts index f5c8b1fb2..4bd19e7d6 100644 --- a/frontend/src/__generated__/models/UserSchema.ts +++ b/frontend/src/__generated__/models/UserSchema.ts @@ -16,6 +16,7 @@ export type UserSchema = { last_active: (string | null); ra_username?: (string | null); ra_progression?: (RAProgression | null); + ui_settings?: (Record | null); created_at: string; updated_at: string; }; diff --git a/frontend/src/components/Details/MultiNoteManager.vue b/frontend/src/components/Details/MultiNoteManager.vue index cd0b5c9b7..cdb398f31 100644 --- a/frontend/src/components/Details/MultiNoteManager.vue +++ b/frontend/src/components/Details/MultiNoteManager.vue @@ -508,11 +508,11 @@ watch(