mirror of
https://github.com/rommapp/romm.git
synced 2026-02-18 00:27:41 +01:00
Merge remote-tracking branch 'origin/master' into romm-1371
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""empty message
|
||||
"""Refactor platforms and roms tables
|
||||
|
||||
Revision ID: 0009_models_refactor
|
||||
Revises: 2.0.0
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""empty message
|
||||
"""Change igdb_id and sgdb_id to integer
|
||||
|
||||
Revision ID: 0010_igdb_id_integerr
|
||||
Revises: 0009_models_refactor
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""empty message
|
||||
"""Remove has_cover column from roms table
|
||||
|
||||
Revision ID: 0011_drop_has_cover
|
||||
Revises: 0010_igdb_id_integerr
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""empty message
|
||||
"""Add support for multiple regions and languages
|
||||
|
||||
Revision ID: 0012_add_regions_languages
|
||||
Revises: 0011_drop_has_cover
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""empty message
|
||||
"""Increase length of file_extension column
|
||||
|
||||
Revision ID: 0013_upgrade_file_extension
|
||||
Revises: 0012_add_regions_languages
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""empty message
|
||||
"""Add MobyGames data
|
||||
|
||||
Revision ID: 0015_mobygames_data
|
||||
Revises: 0014_asset_files
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""empty message
|
||||
"""Track user last login and active times
|
||||
|
||||
Revision ID: 0016_user_last_login_active
|
||||
Revises: 0015_mobygames_data
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""empty message
|
||||
"""Create rom_notes table
|
||||
|
||||
Revision ID: 0017_rom_notes
|
||||
Revises: 0016_user_last_login_active
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""empty message
|
||||
"""Create firmware table
|
||||
|
||||
Revision ID: 0018_firmware
|
||||
Revises: 0017_rom_notes
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""empty message
|
||||
"""Refactor resource storage
|
||||
|
||||
Revision ID: 0019_resources_refactor
|
||||
Revises: 0018_firmware
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""empty message
|
||||
"""Add created_at and updated_at columns
|
||||
|
||||
Revision ID: 0020_created_and_updated
|
||||
Revises: 0019_resources_refactor
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""empty message
|
||||
"""Migrate resources and create collections table
|
||||
|
||||
Revision ID: 0022_collections
|
||||
Revises: 0021_rom_user
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""empty message
|
||||
"""Create sibling_roms view
|
||||
|
||||
Revision ID: 0024_sibling_roms_db_view
|
||||
Revises: 0023_make_columns_non_nullable
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""empty message
|
||||
"""Add hashes to roms
|
||||
|
||||
Revision ID: 0025_roms_hashes
|
||||
Revises: 0024_sibling_roms_db_view
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""empty message
|
||||
"""Add status fields to rom_user table
|
||||
|
||||
Revision ID: 0026_romuser_status_fields
|
||||
Revises: 0025_roms_hashes
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""empty message
|
||||
"""Add email to users table
|
||||
|
||||
Revision ID: 0028_user_email
|
||||
Revises: 0027_platforms_data
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""empty message
|
||||
"""Change DateTime columns to TIMESTAMP
|
||||
|
||||
Revision ID: 0031_datetime_to_timestamp
|
||||
Revises: 0030_user_email_null
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""empty message
|
||||
"""Increase length of platform fields
|
||||
|
||||
Revision ID: 0032_longer_fs_fields
|
||||
Revises: 0031_datetime_to_timestamp
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""empty message
|
||||
"""Add ScreenScraper data
|
||||
|
||||
Revision ID: 0035_screenscraper
|
||||
Revises: 0034_virtual_collections_db_view
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""empty message
|
||||
"""Populate ScreenScraper platform IDs
|
||||
|
||||
Revision ID: 0036_screenscraper_platforms_id
|
||||
Revises: 0035_screenscraper
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""empty message
|
||||
"""Use higher resolution images for assets
|
||||
|
||||
Revision ID: 0041_assets_t_thumb_cleanup
|
||||
Revises: 0040_migrate_assets_paths
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""empty message
|
||||
"""Add Hasheous and TGDB data
|
||||
|
||||
Revision ID: 0044_hasheous_id
|
||||
Revises: 0043_launchbox_id
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""empty message
|
||||
"""Create or update roms_metadata view
|
||||
|
||||
Revision ID: 0045_roms_metadata_update
|
||||
Revises: 0044_hasheous_id
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""empty message
|
||||
"""Standardize platform slugs
|
||||
|
||||
Revision ID: 0046_migrate_platform_slugs
|
||||
Revises: 0045_roms_metadata_update
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""empty message
|
||||
"""Add Flashpoint metadata
|
||||
|
||||
Revision ID: 0051_flashpoint_metadata
|
||||
Revises: 0050_firmware_add_is_verified
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""empty message
|
||||
"""Update roms_metadata view to include flashpoint metadata
|
||||
|
||||
Revision ID: 0052_roms_metadata_flashpoint
|
||||
Revises: 0051_flashpoint_metadata
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""empty message
|
||||
"""Add metadata slugs to platform
|
||||
|
||||
Revision ID: 0054_add_platform_metadata_slugs
|
||||
Revises: 0053_add_hltb_metadata
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""empty message
|
||||
"""Add is_favorite to collections
|
||||
|
||||
Revision ID: 0055_collection_is_favorite
|
||||
Revises: 0054_add_platform_metadata_slugs
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""empty message
|
||||
"""Add launchbox to roms_metadata view
|
||||
|
||||
Revision ID: 0058_roms_metadata_launchbox
|
||||
Revises: 0057_multi_notes
|
||||
|
||||
26
backend/alembic/versions/0059_rom_version_tag.py
Normal file
26
backend/alembic/versions/0059_rom_version_tag.py
Normal file
@@ -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")
|
||||
37
backend/alembic/versions/0060_user_ui_settings.py
Normal file
37
backend/alembic/versions/0060_user_ui_settings.py
Normal file
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: "<username>"
|
||||
# credential: "<password>"
|
||||
# netplay:
|
||||
# enabled: true
|
||||
# ice_servers:
|
||||
# - urls: "stun:stun.relay.metered.ca:80"
|
||||
# - urls: "turn:global.relay.metered.ca:80"
|
||||
# username: "<username>"
|
||||
# credential: "<password>"
|
||||
# controls: # https://emulatorjs.org/docs4devs/control-mapping/
|
||||
# snes9x:
|
||||
# 0: # Player 1
|
||||
|
||||
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="320" height="320" viewBox="0 0 320 320"><defs><clipPath id="a" clipPathUnits="userSpaceOnUse"><path d="M0 256h256V0H0Z"/></clipPath><clipPath id="b" clipPathUnits="userSpaceOnUse"><path d="M8 248h240V8H8Z"/></clipPath></defs><g clip-path="url(#a)" transform="matrix(1.33333 0 0 -1.33333 -10.667 330.667)"><g clip-path="url(#b)" style="opacity:.5"><path d="M0 0h-24v48h-16v16h-176V48h-16v-208h16v-16h64v16h32v-16h64v16h16v16h16v64h16v16H8V0z" style="fill:#fff;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="translate(240 184)"/></g></g><path d="M0 0h-20v-176H0v64h16v16H0Z" style="fill:#000;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="matrix(1.33333 0 0 -1.33333 266.667 32)"/><path d="M224 128h16v48h-16zM176 16h-48v20h48zM32 36h48V16H32Z" style="fill:#000;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="matrix(1.33333 0 0 -1.33333 -10.667 330.667)"/><path d="M192 52H16V32h176zm0 168H32v20h160z" style="fill:#354f9f;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="matrix(1.33333 0 0 -1.33333 -10.667 330.667)"/><path d="M192 48H16v176h176z" style="fill:#4b5da7;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="matrix(1.33333 0 0 -1.33333 -10.667 330.667)"/><path d="M176 188H32v20h144z" style="fill:#354f9f;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="matrix(1.33333 0 0 -1.33333 -10.667 330.667)"/><path d="M176 112H32v80h144z" style="fill:#e6e6e6;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="matrix(1.33333 0 0 -1.33333 -10.667 330.667)"/><path d="M144 128h-16v48h16zm-64 0H64v48h16z" style="fill:#000;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="matrix(1.33333 0 0 -1.33333 -10.667 330.667)"/><path d="M144 96h-16V80h16zm-64 0H64V80h16Zm48-32H80v16h48z" style="fill:#e6e6e6;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="matrix(1.33333 0 0 -1.33333 -10.667 330.667)"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="320" height="320" viewBox="0 0 320 320"><defs><clipPath id="a" clipPathUnits="userSpaceOnUse"><path d="M0 256h256V0H0Z"/></clipPath><clipPath id="b" clipPathUnits="userSpaceOnUse"><path d="M8 248h240V8H8Z"/></clipPath></defs><g clip-path="url(#a)" transform="matrix(1.33333 0 0 -1.33333 -10.667 330.667)"><g clip-path="url(#b)" style="opacity:.5"><path d="M0 0h-24v48h-16v16h-176V48h-16v-208h16v-16h64v16h32v-16h64v16h16v16h16v64h16v16H8V0z" style="fill:#fff;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="translate(240 184)"/></g></g><path d="M0 0h-20v-176H0v64h16v16H0Z" style="fill:#000;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="matrix(1.33333 0 0 -1.33333 266.667 32)"/><path d="M224 128h16v48h-16zM176 16h-48v20h48zM32 36h48V16H32Z" style="fill:#000;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="matrix(1.33333 0 0 -1.33333 -10.667 330.667)"/><path d="M192 52H16V32h176zm0 168H32v20h160z" style="fill:#354f9f;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="matrix(1.33333 0 0 -1.33333 -10.667 330.667)"/><path d="M192 48H16v176h176z" style="fill:#4b5da7;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="matrix(1.33333 0 0 -1.33333 -10.667 330.667)"/><path d="M176 188H32v20h144z" style="fill:#354f9f;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="matrix(1.33333 0 0 -1.33333 -10.667 330.667)"/><path d="M176 112H32v80h144z" style="fill:#e6e6e6;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="matrix(1.33333 0 0 -1.33333 -10.667 330.667)"/><path d="M144 128h-16v48h16zm-64 0H64v48h16z" style="fill:#000;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="matrix(1.33333 0 0 -1.33333 -10.667 330.667)"/><path d="M144 96h-16V80h16zm-64 0H64V80h16Zm48-32H80v16h48z" style="fill:#e6e6e6;fill-opacity:1;fill-rule:nonzero;stroke:none" transform="matrix(1.33333 0 0 -1.33333 -10.667 330.667)"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 108 KiB |
@@ -81,5 +81,6 @@ export type SimpleRomSchema = {
|
||||
rom_user: RomUserSchema;
|
||||
merged_screenshots: Array<string>;
|
||||
merged_ra_metadata: (RomRAMetadata | null);
|
||||
has_notes?: boolean;
|
||||
};
|
||||
|
||||
|
||||
1
frontend/src/__generated__/models/UserForm.ts
generated
1
frontend/src/__generated__/models/UserForm.ts
generated
@@ -10,5 +10,6 @@ export type UserForm = {
|
||||
enabled?: (boolean | null);
|
||||
ra_username?: (string | null);
|
||||
avatar?: (Blob | null);
|
||||
ui_settings?: (string | null);
|
||||
};
|
||||
|
||||
|
||||
1
frontend/src/__generated__/models/UserSchema.ts
generated
1
frontend/src/__generated__/models/UserSchema.ts
generated
@@ -16,6 +16,7 @@ export type UserSchema = {
|
||||
last_active: (string | null);
|
||||
ra_username?: (string | null);
|
||||
ra_progression?: (RAProgression | null);
|
||||
ui_settings?: (Record<string, any> | null);
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
@@ -508,11 +508,11 @@ watch(
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<v-row class="justify-center my-2" no-gutters>
|
||||
<v-row class="justify-center pa-2" no-gutters>
|
||||
<v-btn-group divided density="compact">
|
||||
<v-btn class="bg-toplayer" @click="closeAddNote">
|
||||
{{ t("common.cancel") }}
|
||||
</v-btn>
|
||||
<v-btn class="bg-toplayer" @click="closeAddNote">{{
|
||||
t("common.cancel")
|
||||
}}</v-btn>
|
||||
<v-btn
|
||||
class="bg-toplayer text-romm-green"
|
||||
:disabled="!newNoteTitle.trim() || newNoteTitleErrors.length > 0"
|
||||
|
||||
@@ -24,7 +24,6 @@ const {
|
||||
|
||||
const {
|
||||
searchTerm,
|
||||
filterUnmatched,
|
||||
filterMatched,
|
||||
filterFavorites,
|
||||
filterDuplicates,
|
||||
@@ -32,14 +31,14 @@ const {
|
||||
filterRA,
|
||||
filterMissing,
|
||||
filterVerified,
|
||||
selectedGenre,
|
||||
selectedFranchise,
|
||||
selectedCollection,
|
||||
selectedCompany,
|
||||
selectedAgeRating,
|
||||
selectedStatus,
|
||||
selectedRegion,
|
||||
selectedLanguage,
|
||||
selectedGenres,
|
||||
selectedFranchises,
|
||||
selectedCollections,
|
||||
selectedCompanies,
|
||||
selectedAgeRatings,
|
||||
selectedStatuses,
|
||||
selectedRegions,
|
||||
selectedLanguages,
|
||||
} = storeToRefs(galleryFilterStore);
|
||||
|
||||
async function goToRandomGame() {
|
||||
@@ -55,7 +54,6 @@ async function goToRandomGame() {
|
||||
searchTerm.value && searchTerm.value.trim()
|
||||
? searchTerm.value.trim()
|
||||
: null,
|
||||
filterUnmatched: filterUnmatched.value,
|
||||
filterMatched: filterMatched.value,
|
||||
filterFavorites: filterFavorites.value,
|
||||
filterDuplicates: filterDuplicates.value,
|
||||
@@ -63,14 +61,14 @@ async function goToRandomGame() {
|
||||
filterRA: filterRA.value,
|
||||
filterMissing: filterMissing.value,
|
||||
filterVerified: filterVerified.value,
|
||||
selectedGenre: selectedGenre.value,
|
||||
selectedFranchise: selectedFranchise.value,
|
||||
selectedCollection: selectedCollection.value,
|
||||
selectedCompany: selectedCompany.value,
|
||||
selectedAgeRating: selectedAgeRating.value,
|
||||
selectedStatus: selectedStatus.value,
|
||||
selectedRegion: selectedRegion.value,
|
||||
selectedLanguage: selectedLanguage.value,
|
||||
selectedGenres: selectedGenres.value,
|
||||
selectedFranchises: selectedFranchises.value,
|
||||
selectedCollections: selectedCollections.value,
|
||||
selectedCompanies: selectedCompanies.value,
|
||||
selectedAgeRatings: selectedAgeRatings.value,
|
||||
selectedStatuses: selectedStatuses.value,
|
||||
selectedRegions: selectedRegions.value,
|
||||
selectedLanguages: selectedLanguages.value,
|
||||
};
|
||||
|
||||
// Get the total count first
|
||||
|
||||
@@ -2,24 +2,23 @@
|
||||
import { debounce } from "lodash";
|
||||
import type { Emitter } from "mitt";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { inject, nextTick, onMounted, watch } from "vue";
|
||||
import { inject, nextTick, onMounted, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useDisplay } from "vuetify";
|
||||
import SearchTextField from "@/components/Gallery/AppBar/Search/SearchTextField.vue";
|
||||
import FilterDuplicatesBtn from "@/components/Gallery/AppBar/common/FilterDrawer/FilterDuplicatesBtn.vue";
|
||||
import FilterFavoritesBtn from "@/components/Gallery/AppBar/common/FilterDrawer/FilterFavoritesBtn.vue";
|
||||
import FilterMatchedBtn from "@/components/Gallery/AppBar/common/FilterDrawer/FilterMatchedBtn.vue";
|
||||
import FilterMatchStateBtn from "@/components/Gallery/AppBar/common/FilterDrawer/FilterMatchStateBtn.vue";
|
||||
import FilterMissingBtn from "@/components/Gallery/AppBar/common/FilterDrawer/FilterMissingBtn.vue";
|
||||
import FilterPlatformBtn from "@/components/Gallery/AppBar/common/FilterDrawer/FilterPlatformBtn.vue";
|
||||
import FilterPlayablesBtn from "@/components/Gallery/AppBar/common/FilterDrawer/FilterPlayablesBtn.vue";
|
||||
import FilterRaBtn from "@/components/Gallery/AppBar/common/FilterDrawer/FilterRaBtn.vue";
|
||||
import FilterUnmatchedBtn from "@/components/Gallery/AppBar/common/FilterDrawer/FilterUnmatchedBtn.vue";
|
||||
import FilterVerifiedBtn from "@/components/Gallery/AppBar/common/FilterDrawer/FilterVerifiedBtn.vue";
|
||||
import MissingFromFSIcon from "@/components/common/MissingFromFSIcon.vue";
|
||||
import PlatformIcon from "@/components/common/Platform/PlatformIcon.vue";
|
||||
import cachedApiService from "@/services/cache/api";
|
||||
import storeGalleryFilter from "@/stores/galleryFilter";
|
||||
import storePlatforms from "@/stores/platforms";
|
||||
import storeRoms from "@/stores/roms";
|
||||
import storeRoms, { type SimpleRom } from "@/stores/roms";
|
||||
import type { Events } from "@/types/emitter";
|
||||
|
||||
withDefaults(
|
||||
@@ -44,7 +43,6 @@ const platformsStore = storePlatforms();
|
||||
const {
|
||||
searchTerm,
|
||||
activeFilterDrawer,
|
||||
filterUnmatched,
|
||||
filterMatched,
|
||||
filterFavorites,
|
||||
filterDuplicates,
|
||||
@@ -52,26 +50,32 @@ const {
|
||||
filterRA,
|
||||
filterMissing,
|
||||
filterVerified,
|
||||
selectedGenre,
|
||||
filterGenres,
|
||||
selectedFranchise,
|
||||
selectedGenres,
|
||||
genresLogic,
|
||||
filterFranchises,
|
||||
selectedCollection,
|
||||
selectedFranchises,
|
||||
franchisesLogic,
|
||||
filterCollections,
|
||||
selectedCompany,
|
||||
selectedCollections,
|
||||
collectionsLogic,
|
||||
filterCompanies,
|
||||
selectedAgeRating,
|
||||
selectedCompanies,
|
||||
companiesLogic,
|
||||
filterAgeRatings,
|
||||
selectedStatus,
|
||||
selectedAgeRatings,
|
||||
ageRatingsLogic,
|
||||
filterStatuses,
|
||||
selectedPlatform,
|
||||
filterPlatforms,
|
||||
selectedRegion,
|
||||
selectedStatuses,
|
||||
statusesLogic,
|
||||
selectedPlatforms,
|
||||
filterRegions,
|
||||
selectedLanguage,
|
||||
selectedRegions,
|
||||
regionsLogic,
|
||||
filterLanguages,
|
||||
selectedLanguages,
|
||||
languagesLogic,
|
||||
} = storeToRefs(galleryFilterStore);
|
||||
const { filteredRoms } = storeToRefs(romsStore);
|
||||
const { allPlatforms } = storeToRefs(platformsStore);
|
||||
const emitter = inject<Emitter<Events>>("emitter");
|
||||
|
||||
@@ -84,25 +88,68 @@ const onFilterChange = debounce(
|
||||
// Update URL with filters
|
||||
Object.entries({
|
||||
search: searchTerm.value,
|
||||
filterMatched: filterMatched.value ? "1" : null,
|
||||
filterUnmatched: filterUnmatched.value ? "1" : null,
|
||||
filterFavorites: filterFavorites.value ? "1" : null,
|
||||
filterDuplicates: filterDuplicates.value ? "1" : null,
|
||||
filterPlayables: filterPlayables.value ? "1" : null,
|
||||
filterMissing: filterMissing.value ? "1" : null,
|
||||
filterVerified: filterVerified.value ? "1" : null,
|
||||
filterRA: filterRA.value ? "1" : null,
|
||||
platform: selectedPlatform.value
|
||||
? String(selectedPlatform.value.id)
|
||||
: null,
|
||||
genre: selectedGenre.value,
|
||||
franchise: selectedFranchise.value,
|
||||
collection: selectedCollection.value,
|
||||
company: selectedCompany.value,
|
||||
ageRating: selectedAgeRating.value,
|
||||
region: selectedRegion.value,
|
||||
language: selectedLanguage.value,
|
||||
status: selectedStatus.value,
|
||||
matched:
|
||||
filterMatched.value === null ? null : String(filterMatched.value),
|
||||
filterFavorites:
|
||||
filterFavorites.value === null ? null : String(filterFavorites.value),
|
||||
filterDuplicates:
|
||||
filterDuplicates.value === null ? null : String(filterDuplicates.value),
|
||||
filterPlayables:
|
||||
filterPlayables.value === null ? null : String(filterPlayables.value),
|
||||
filterMissing:
|
||||
filterMissing.value === null ? null : String(filterMissing.value),
|
||||
filterVerified:
|
||||
filterVerified.value === null ? null : String(filterVerified.value),
|
||||
filterRA: filterRA.value === null ? null : String(filterRA.value),
|
||||
platforms:
|
||||
selectedPlatforms.value.length > 0
|
||||
? selectedPlatforms.value.map((p) => String(p.id)).join(",")
|
||||
: null,
|
||||
genres:
|
||||
selectedGenres.value.length > 0 ? selectedGenres.value.join(",") : null,
|
||||
genresLogic: selectedGenres.value.length > 1 ? genresLogic.value : null,
|
||||
franchises:
|
||||
selectedFranchises.value.length > 0
|
||||
? selectedFranchises.value.join(",")
|
||||
: null,
|
||||
franchisesLogic:
|
||||
selectedFranchises.value.length > 1 ? franchisesLogic.value : null,
|
||||
collections:
|
||||
selectedCollections.value.length > 0
|
||||
? selectedCollections.value.join(",")
|
||||
: null,
|
||||
collectionsLogic:
|
||||
selectedCollections.value.length > 1 ? collectionsLogic.value : null,
|
||||
companies:
|
||||
selectedCompanies.value.length > 0
|
||||
? selectedCompanies.value.join(",")
|
||||
: null,
|
||||
companiesLogic:
|
||||
selectedCompanies.value.length > 1 ? companiesLogic.value : null,
|
||||
ageRatings:
|
||||
selectedAgeRatings.value.length > 0
|
||||
? selectedAgeRatings.value.join(",")
|
||||
: null,
|
||||
ageRatingsLogic:
|
||||
selectedAgeRatings.value.length > 1 ? ageRatingsLogic.value : null,
|
||||
regions:
|
||||
selectedRegions.value.length > 0
|
||||
? selectedRegions.value.join(",")
|
||||
: null,
|
||||
regionsLogic:
|
||||
selectedRegions.value.length > 1 ? regionsLogic.value : null,
|
||||
languages:
|
||||
selectedLanguages.value.length > 0
|
||||
? selectedLanguages.value.join(",")
|
||||
: null,
|
||||
languagesLogic:
|
||||
selectedLanguages.value.length > 1 ? languagesLogic.value : null,
|
||||
statuses:
|
||||
selectedStatuses.value.length > 0
|
||||
? selectedStatuses.value.join(",")
|
||||
: null,
|
||||
statusesLogic:
|
||||
selectedStatuses.value.length > 1 ? statusesLogic.value : null,
|
||||
}).forEach(([key, value]) => {
|
||||
if (value) {
|
||||
url.searchParams.set(key, value);
|
||||
@@ -118,103 +165,189 @@ const onFilterChange = debounce(
|
||||
{ leading: false, trailing: true },
|
||||
);
|
||||
|
||||
// Separate debounced function for search term changes
|
||||
const onSearchChange = debounce(
|
||||
async () => {
|
||||
await fetchSearchFilteredRoms();
|
||||
setFilters();
|
||||
},
|
||||
500,
|
||||
{ leading: false, trailing: true },
|
||||
);
|
||||
|
||||
emitter?.on("filterRoms", onFilterChange);
|
||||
|
||||
const filters = [
|
||||
{
|
||||
label: t("platform.genre"),
|
||||
selected: selectedGenre,
|
||||
selected: selectedGenres,
|
||||
items: filterGenres,
|
||||
logic: genresLogic,
|
||||
setLogic: (logic: "any" | "all") =>
|
||||
galleryFilterStore.setGenresLogic(logic),
|
||||
},
|
||||
{
|
||||
label: t("platform.franchise"),
|
||||
selected: selectedFranchise,
|
||||
selected: selectedFranchises,
|
||||
items: filterFranchises,
|
||||
logic: franchisesLogic,
|
||||
setLogic: (logic: "any" | "all") =>
|
||||
galleryFilterStore.setFranchisesLogic(logic),
|
||||
},
|
||||
{
|
||||
label: t("platform.collection"),
|
||||
selected: selectedCollection,
|
||||
selected: selectedCollections,
|
||||
items: filterCollections,
|
||||
logic: collectionsLogic,
|
||||
setLogic: (logic: "any" | "all") =>
|
||||
galleryFilterStore.setCollectionsLogic(logic),
|
||||
},
|
||||
{
|
||||
label: t("platform.company"),
|
||||
selected: selectedCompany,
|
||||
selected: selectedCompanies,
|
||||
items: filterCompanies,
|
||||
logic: companiesLogic,
|
||||
setLogic: (logic: "any" | "all") =>
|
||||
galleryFilterStore.setCompaniesLogic(logic),
|
||||
},
|
||||
{
|
||||
label: t("platform.age-rating"),
|
||||
selected: selectedAgeRating,
|
||||
selected: selectedAgeRatings,
|
||||
items: filterAgeRatings,
|
||||
logic: ageRatingsLogic,
|
||||
setLogic: (logic: "any" | "all") =>
|
||||
galleryFilterStore.setAgeRatingsLogic(logic),
|
||||
},
|
||||
{
|
||||
label: t("platform.region"),
|
||||
selected: selectedRegion,
|
||||
selected: selectedRegions,
|
||||
items: filterRegions,
|
||||
logic: regionsLogic,
|
||||
setLogic: (logic: "any" | "all") =>
|
||||
galleryFilterStore.setRegionsLogic(logic),
|
||||
},
|
||||
{
|
||||
label: t("platform.language"),
|
||||
selected: selectedLanguage,
|
||||
selected: selectedLanguages,
|
||||
items: filterLanguages,
|
||||
logic: languagesLogic,
|
||||
setLogic: (logic: "any" | "all") =>
|
||||
galleryFilterStore.setLanguagesLogic(logic),
|
||||
},
|
||||
{
|
||||
label: t("platform.status"),
|
||||
selected: selectedStatus,
|
||||
selected: selectedStatuses,
|
||||
items: filterStatuses,
|
||||
logic: statusesLogic,
|
||||
setLogic: (logic: "any" | "all") =>
|
||||
galleryFilterStore.setStatusesLogic(logic),
|
||||
},
|
||||
];
|
||||
|
||||
function resetFilters() {
|
||||
galleryFilterStore.resetFilters();
|
||||
nextTick(() => emitter?.emit("filterRoms", null));
|
||||
nextTick(async () => {
|
||||
await fetchSearchFilteredRoms();
|
||||
setFilters();
|
||||
emitter?.emit("filterRoms", null);
|
||||
});
|
||||
}
|
||||
|
||||
// Store search-filtered ROMs for populating filter options
|
||||
let searchFilteredRoms = ref<SimpleRom[]>([]);
|
||||
|
||||
async function fetchSearchFilteredRoms() {
|
||||
try {
|
||||
const params = {
|
||||
searchTerm: searchTerm.value,
|
||||
platformIds: romsStore.currentPlatform
|
||||
? [romsStore.currentPlatform.id]
|
||||
: null,
|
||||
collectionId: romsStore.currentCollection?.id ?? null,
|
||||
virtualCollectionId: romsStore.currentVirtualCollection?.id ?? null,
|
||||
smartCollectionId: romsStore.currentSmartCollection?.id ?? null,
|
||||
limit: 10000, // Get enough ROMs to populate filters
|
||||
offset: 0,
|
||||
orderBy: romsStore.orderBy,
|
||||
orderDir: romsStore.orderDir,
|
||||
// Exclude all other filters to get comprehensive filter options
|
||||
filterMatched: null,
|
||||
filterFavorites: null,
|
||||
filterDuplicates: null,
|
||||
filterPlayables: null,
|
||||
filterRA: null,
|
||||
filterMissing: null,
|
||||
filterVerified: null,
|
||||
// Exclude all multi-value filters to get all possible options
|
||||
selectedGenres: null,
|
||||
selectedFranchises: null,
|
||||
selectedCollections: null,
|
||||
selectedCompanies: null,
|
||||
selectedAgeRatings: null,
|
||||
selectedRegions: null,
|
||||
selectedLanguages: null,
|
||||
selectedStatuses: null,
|
||||
};
|
||||
|
||||
// Fetch ROMs with only search term applied (and current platform/collection context)
|
||||
const response = await cachedApiService.getRoms(params, () => {}); // No background update callback needed
|
||||
|
||||
searchFilteredRoms.value = response.data.items;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch search-filtered ROMs:", error);
|
||||
// Fall back to current filtered ROMs if search-only fetch fails
|
||||
searchFilteredRoms.value = romsStore.filteredRoms;
|
||||
}
|
||||
}
|
||||
|
||||
function setFilters() {
|
||||
const romsForFilters =
|
||||
searchFilteredRoms.value.length > 0
|
||||
? searchFilteredRoms.value
|
||||
: romsStore.filteredRoms;
|
||||
|
||||
galleryFilterStore.setFilterPlatforms([
|
||||
...new Set(
|
||||
romsStore.filteredRoms
|
||||
romsForFilters
|
||||
.flatMap((rom) => platformsStore.get(rom.platform_id))
|
||||
.filter((platform) => !!platform)
|
||||
.sort(),
|
||||
),
|
||||
]);
|
||||
galleryFilterStore.setFilterGenres([
|
||||
...new Set(
|
||||
romsStore.filteredRoms.flatMap((rom) => rom.metadatum.genres).sort(),
|
||||
),
|
||||
...new Set(romsForFilters.flatMap((rom) => rom.metadatum.genres).sort()),
|
||||
]);
|
||||
galleryFilterStore.setFilterFranchises([
|
||||
...new Set(
|
||||
romsStore.filteredRoms.flatMap((rom) => rom.metadatum.franchises).sort(),
|
||||
romsForFilters.flatMap((rom) => rom.metadatum.franchises).sort(),
|
||||
),
|
||||
]);
|
||||
galleryFilterStore.setFilterCompanies([
|
||||
...new Set(
|
||||
romsStore.filteredRoms.flatMap((rom) => rom.metadatum.companies).sort(),
|
||||
),
|
||||
...new Set(romsForFilters.flatMap((rom) => rom.metadatum.companies).sort()),
|
||||
]);
|
||||
galleryFilterStore.setFilterCollections([
|
||||
...new Set(
|
||||
romsStore.filteredRoms.flatMap((rom) => rom.metadatum.collections).sort(),
|
||||
romsForFilters.flatMap((rom) => rom.metadatum.collections).sort(),
|
||||
),
|
||||
]);
|
||||
galleryFilterStore.setFilterAgeRatings([
|
||||
...new Set(
|
||||
romsStore.filteredRoms.flatMap((rom) => rom.metadatum.age_ratings).sort(),
|
||||
romsForFilters.flatMap((rom) => rom.metadatum.age_ratings).sort(),
|
||||
),
|
||||
]);
|
||||
galleryFilterStore.setFilterRegions([
|
||||
...new Set(romsStore.filteredRoms.flatMap((rom) => rom.regions).sort()),
|
||||
...new Set(romsForFilters.flatMap((rom) => rom.regions).sort()),
|
||||
]);
|
||||
galleryFilterStore.setFilterLanguages([
|
||||
...new Set(romsStore.filteredRoms.flatMap((rom) => rom.languages).sort()),
|
||||
...new Set(romsForFilters.flatMap((rom) => rom.languages).sort()),
|
||||
]);
|
||||
// Note: filterStatuses is static and doesn't need to be set dynamically
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const {
|
||||
search: urlSearch,
|
||||
filterMatched: urlFilteredMatch,
|
||||
filterUnmatched: urlFilteredUnmatched,
|
||||
matched: urlMatched,
|
||||
filterFavorites: urlFilteredFavorites,
|
||||
filterDuplicates: urlFilteredDuplicates,
|
||||
filterPlayables: urlFilteredPlayables,
|
||||
@@ -222,68 +355,184 @@ onMounted(async () => {
|
||||
filterVerified: urlFilteredVerified,
|
||||
filterRA: urlFilteredRa,
|
||||
platform: urlPlatform,
|
||||
genre: urlGenre,
|
||||
franchise: urlFranchise,
|
||||
collection: urlCollection,
|
||||
company: urlCompany,
|
||||
ageRating: urlAgeRating,
|
||||
region: urlRegion,
|
||||
language: urlLanguage,
|
||||
status: urlStatus,
|
||||
platforms: urlPlatforms,
|
||||
// Multi-value URL params
|
||||
genres: urlGenres,
|
||||
genresLogic: urlGenresLogic,
|
||||
franchises: urlFranchises,
|
||||
franchisesLogic: urlFranchisesLogic,
|
||||
collections: urlCollections,
|
||||
collectionsLogic: urlCollectionsLogic,
|
||||
companies: urlCompanies,
|
||||
companiesLogic: urlCompaniesLogic,
|
||||
ageRatings: urlAgeRatings,
|
||||
ageRatingsLogic: urlAgeRatingsLogic,
|
||||
regions: urlRegions,
|
||||
regionsLogic: urlRegionsLogic,
|
||||
languages: urlLanguages,
|
||||
languagesLogic: urlLanguagesLogic,
|
||||
statuses: urlStatuses,
|
||||
statusesLogic: urlStatusesLogic,
|
||||
} = router.currentRoute.value.query;
|
||||
|
||||
// Check for query params to set filters
|
||||
if (urlFilteredMatch !== undefined) {
|
||||
galleryFilterStore.setFilterMatched(true);
|
||||
}
|
||||
if (urlFilteredUnmatched !== undefined) {
|
||||
galleryFilterStore.setFilterUnmatched(true);
|
||||
if (urlMatched !== undefined) {
|
||||
if (urlMatched === "true") {
|
||||
galleryFilterStore.setFilterMatched(true);
|
||||
} else if (urlMatched === "false") {
|
||||
galleryFilterStore.setFilterMatched(false);
|
||||
}
|
||||
// Any other value means no filter (both remain null)
|
||||
}
|
||||
if (urlFilteredFavorites !== undefined) {
|
||||
galleryFilterStore.setFilterFavorites(true);
|
||||
if (urlFilteredFavorites === "true") {
|
||||
galleryFilterStore.setFilterFavorites(true);
|
||||
} else if (urlFilteredFavorites === "false") {
|
||||
galleryFilterStore.setFilterFavorites(false);
|
||||
} else {
|
||||
galleryFilterStore.setFilterFavorites(null);
|
||||
}
|
||||
}
|
||||
if (urlFilteredDuplicates !== undefined) {
|
||||
galleryFilterStore.setFilterDuplicates(true);
|
||||
if (urlFilteredDuplicates === "true") {
|
||||
galleryFilterStore.setFilterDuplicates(true);
|
||||
} else if (urlFilteredDuplicates === "false") {
|
||||
galleryFilterStore.setFilterDuplicates(false);
|
||||
} else {
|
||||
galleryFilterStore.setFilterDuplicates(null);
|
||||
}
|
||||
}
|
||||
if (urlFilteredPlayables !== undefined) {
|
||||
galleryFilterStore.setFilterPlayables(true);
|
||||
if (urlFilteredPlayables === "true") {
|
||||
galleryFilterStore.setFilterPlayables(true);
|
||||
} else if (urlFilteredPlayables === "false") {
|
||||
galleryFilterStore.setFilterPlayables(false);
|
||||
} else {
|
||||
galleryFilterStore.setFilterPlayables(null);
|
||||
}
|
||||
}
|
||||
if (urlFilteredMissing !== undefined) {
|
||||
galleryFilterStore.setFilterMissing(true);
|
||||
if (urlFilteredMissing === "true") {
|
||||
galleryFilterStore.setFilterMissing(true);
|
||||
} else if (urlFilteredMissing === "false") {
|
||||
galleryFilterStore.setFilterMissing(false);
|
||||
} else {
|
||||
galleryFilterStore.setFilterMissing(null);
|
||||
}
|
||||
}
|
||||
if (urlFilteredVerified !== undefined) {
|
||||
galleryFilterStore.setFilterVerified(true);
|
||||
if (urlFilteredVerified === "true") {
|
||||
galleryFilterStore.setFilterVerified(true);
|
||||
} else if (urlFilteredVerified === "false") {
|
||||
galleryFilterStore.setFilterVerified(false);
|
||||
} else {
|
||||
galleryFilterStore.setFilterVerified(null);
|
||||
}
|
||||
}
|
||||
if (urlFilteredRa !== undefined) {
|
||||
galleryFilterStore.setFilterRA(true);
|
||||
if (urlFilteredRa === "true") {
|
||||
galleryFilterStore.setFilterRA(true);
|
||||
} else if (urlFilteredRa === "false") {
|
||||
galleryFilterStore.setFilterRA(false);
|
||||
} else {
|
||||
galleryFilterStore.setFilterRA(null);
|
||||
}
|
||||
}
|
||||
if (urlPlatform !== undefined) {
|
||||
// Check for query params to set multi-value filters (prioritize over single values)
|
||||
if (urlPlatforms !== undefined) {
|
||||
const platformIds = (urlPlatforms as string)
|
||||
.split(",")
|
||||
.filter((p) => p.trim())
|
||||
.map(Number);
|
||||
const platforms = platformIds
|
||||
.map((id) => platformsStore.get(id))
|
||||
.filter((p): p is NonNullable<typeof p> => p !== undefined);
|
||||
if (platforms.length > 0) {
|
||||
galleryFilterStore.setSelectedFilterPlatforms(platforms);
|
||||
}
|
||||
} else if (urlPlatform !== undefined) {
|
||||
// Backward compatibility: if single platform is set, convert to multiselect
|
||||
const platform = platformsStore.get(Number(urlPlatform));
|
||||
if (platform) galleryFilterStore.setSelectedFilterPlatform(platform);
|
||||
if (platform) galleryFilterStore.setSelectedFilterPlatforms([platform]);
|
||||
}
|
||||
if (urlGenre !== undefined) {
|
||||
galleryFilterStore.setSelectedFilterGenre(urlGenre as string);
|
||||
if (urlGenres !== undefined) {
|
||||
const genres = (urlGenres as string).split(",").filter((g) => g.trim());
|
||||
galleryFilterStore.setSelectedFilterGenres(genres);
|
||||
if (urlGenresLogic !== undefined) {
|
||||
galleryFilterStore.setGenresLogic(urlGenresLogic as "any" | "all");
|
||||
}
|
||||
}
|
||||
if (urlFranchise !== undefined) {
|
||||
galleryFilterStore.setSelectedFilterFranchise(urlFranchise as string);
|
||||
|
||||
if (urlFranchises !== undefined) {
|
||||
const franchises = (urlFranchises as string)
|
||||
.split(",")
|
||||
.filter((f) => f.trim());
|
||||
galleryFilterStore.setSelectedFilterFranchises(franchises);
|
||||
if (urlFranchisesLogic !== undefined) {
|
||||
galleryFilterStore.setFranchisesLogic(
|
||||
urlFranchisesLogic as "any" | "all",
|
||||
);
|
||||
}
|
||||
}
|
||||
if (urlCollection !== undefined) {
|
||||
galleryFilterStore.setSelectedFilterCollection(urlCollection as string);
|
||||
|
||||
if (urlCollections !== undefined) {
|
||||
const collections = (urlCollections as string)
|
||||
.split(",")
|
||||
.filter((c) => c.trim());
|
||||
galleryFilterStore.setSelectedFilterCollections(collections);
|
||||
if (urlCollectionsLogic !== undefined) {
|
||||
galleryFilterStore.setCollectionsLogic(
|
||||
urlCollectionsLogic as "any" | "all",
|
||||
);
|
||||
}
|
||||
}
|
||||
if (urlCompany !== undefined) {
|
||||
galleryFilterStore.setSelectedFilterCompany(urlCompany as string);
|
||||
|
||||
if (urlCompanies !== undefined) {
|
||||
const companies = (urlCompanies as string)
|
||||
.split(",")
|
||||
.filter((c) => c.trim());
|
||||
galleryFilterStore.setSelectedFilterCompanies(companies);
|
||||
if (urlCompaniesLogic !== undefined) {
|
||||
galleryFilterStore.setCompaniesLogic(urlCompaniesLogic as "any" | "all");
|
||||
}
|
||||
}
|
||||
if (urlAgeRating !== undefined) {
|
||||
galleryFilterStore.setSelectedFilterAgeRating(urlAgeRating as string);
|
||||
|
||||
if (urlAgeRatings !== undefined) {
|
||||
const ageRatings = (urlAgeRatings as string)
|
||||
.split(",")
|
||||
.filter((a) => a.trim());
|
||||
galleryFilterStore.setSelectedFilterAgeRatings(ageRatings);
|
||||
if (urlAgeRatingsLogic !== undefined) {
|
||||
galleryFilterStore.setAgeRatingsLogic(
|
||||
urlAgeRatingsLogic as "any" | "all",
|
||||
);
|
||||
}
|
||||
}
|
||||
if (urlRegion !== undefined) {
|
||||
galleryFilterStore.setSelectedFilterRegion(urlRegion as string);
|
||||
|
||||
if (urlRegions !== undefined) {
|
||||
const regions = (urlRegions as string).split(",").filter((r) => r.trim());
|
||||
galleryFilterStore.setSelectedFilterRegions(regions);
|
||||
if (urlRegionsLogic !== undefined) {
|
||||
galleryFilterStore.setRegionsLogic(urlRegionsLogic as "any" | "all");
|
||||
}
|
||||
}
|
||||
if (urlLanguage !== undefined) {
|
||||
galleryFilterStore.setSelectedFilterLanguage(urlLanguage as string);
|
||||
|
||||
if (urlLanguages !== undefined) {
|
||||
const languages = (urlLanguages as string)
|
||||
.split(",")
|
||||
.filter((l) => l.trim());
|
||||
galleryFilterStore.setSelectedFilterLanguages(languages);
|
||||
if (urlLanguagesLogic !== undefined) {
|
||||
galleryFilterStore.setLanguagesLogic(urlLanguagesLogic as "any" | "all");
|
||||
}
|
||||
}
|
||||
if (urlStatus !== undefined) {
|
||||
galleryFilterStore.setSelectedFilterStatus(urlStatus as string);
|
||||
|
||||
if (urlStatuses !== undefined) {
|
||||
const statuses = (urlStatuses as string).split(",").filter((s) => s.trim());
|
||||
galleryFilterStore.setSelectedFilterStatuses(statuses);
|
||||
if (urlStatusesLogic !== undefined) {
|
||||
galleryFilterStore.setStatusesLogic(urlStatusesLogic as "any" | "all");
|
||||
}
|
||||
}
|
||||
|
||||
// Check if search term is set in the URL (empty string is ok)
|
||||
@@ -293,21 +542,32 @@ onMounted(async () => {
|
||||
romsStore.resetPagination();
|
||||
}
|
||||
|
||||
// Initial fetch of search-filtered ROMs for filter options
|
||||
await fetchSearchFilteredRoms();
|
||||
setFilters();
|
||||
|
||||
// Fire off search if URL state prepopulated
|
||||
if (freshSearch || galleryFilterStore.isFiltered()) {
|
||||
emitter?.emit("filterRoms", null);
|
||||
}
|
||||
|
||||
// Watch for search term changes to update filter options
|
||||
watch(
|
||||
() => filteredRoms.value,
|
||||
async () => setFilters(),
|
||||
{ immediate: true }, // Ensure watcher is triggered immediately
|
||||
() => searchTerm.value,
|
||||
async () => {
|
||||
await onSearchChange();
|
||||
},
|
||||
{ immediate: false },
|
||||
);
|
||||
|
||||
// Watch for platform changes to update filter options
|
||||
watch(
|
||||
() => allPlatforms.value,
|
||||
async () => setFilters(),
|
||||
{ immediate: true }, // Ensure watcher is triggered immediately
|
||||
async () => {
|
||||
await fetchSearchFilteredRoms();
|
||||
setFilters();
|
||||
},
|
||||
{ immediate: false },
|
||||
);
|
||||
});
|
||||
</script>
|
||||
@@ -317,7 +577,7 @@ onMounted(async () => {
|
||||
v-model="activeFilterDrawer"
|
||||
mobile
|
||||
floating
|
||||
width="400"
|
||||
width="500"
|
||||
:class="{
|
||||
'ml-2': activeFilterDrawer,
|
||||
'drawer-mobile': smAndDown && activeFilterDrawer,
|
||||
@@ -325,17 +585,11 @@ onMounted(async () => {
|
||||
class="bg-surface rounded mt-4 mb-2 pa-1 unset-height"
|
||||
>
|
||||
<v-list tabindex="-1">
|
||||
<template v-if="showSearchBar && xs">
|
||||
<v-list-item>
|
||||
<SearchTextField :tabindex="activeFilterDrawer ? 0 : -1" />
|
||||
</v-list-item>
|
||||
</template>
|
||||
<v-list-item v-if="showSearchBar && xs">
|
||||
<SearchTextField :tabindex="activeFilterDrawer ? 0 : -1" />
|
||||
</v-list-item>
|
||||
<v-list-item>
|
||||
<FilterUnmatchedBtn :tabindex="activeFilterDrawer ? 0 : -1" />
|
||||
<FilterMatchedBtn
|
||||
class="mt-2"
|
||||
:tabindex="activeFilterDrawer ? 0 : -1"
|
||||
/>
|
||||
<FilterMatchStateBtn :tabindex="activeFilterDrawer ? 0 : -1" />
|
||||
<FilterFavoritesBtn
|
||||
class="mt-2"
|
||||
:tabindex="activeFilterDrawer ? 0 : -1"
|
||||
@@ -359,88 +613,71 @@ onMounted(async () => {
|
||||
/>
|
||||
<FilterRaBtn class="mt-2" :tabindex="activeFilterDrawer ? 0 : -1" />
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
v-if="showPlatformsFilter"
|
||||
:tabindex="activeFilterDrawer ? 0 : -1"
|
||||
>
|
||||
<v-autocomplete
|
||||
v-model="selectedPlatform"
|
||||
:tabindex="activeFilterDrawer ? 0 : -1"
|
||||
hide-details
|
||||
prepend-inner-icon="mdi-controller"
|
||||
clearable
|
||||
:label="t('common.platform')"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
:items="filterPlatforms"
|
||||
@update:model-value="
|
||||
nextTick(() => emitter?.emit('filterRoms', null))
|
||||
"
|
||||
>
|
||||
<template #item="{ props, item }">
|
||||
<v-list-item
|
||||
v-bind="props"
|
||||
class="py-4"
|
||||
:title="item.raw.name ?? ''"
|
||||
:subtitle="item.raw.fs_slug"
|
||||
>
|
||||
<template #prepend>
|
||||
<PlatformIcon
|
||||
:key="item.raw.slug"
|
||||
:size="35"
|
||||
:slug="item.raw.slug"
|
||||
:name="item.raw.name"
|
||||
:fs-slug="item.raw.fs_slug"
|
||||
/>
|
||||
</template>
|
||||
<template #append>
|
||||
<MissingFromFSIcon
|
||||
v-if="item.raw.missing_from_fs"
|
||||
text="Missing platform from filesystem"
|
||||
chip
|
||||
chip-label
|
||||
chip-density="compact"
|
||||
class="ml-2"
|
||||
/>
|
||||
<v-chip class="ml-2" size="x-small" label>
|
||||
{{ item.raw.rom_count }}
|
||||
</v-chip>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
<template #chip="{ item }">
|
||||
<v-chip>
|
||||
<PlatformIcon
|
||||
:key="item.raw.slug"
|
||||
:slug="item.raw.slug"
|
||||
:name="item.raw.name"
|
||||
:fs-slug="item.raw.fs_slug"
|
||||
:size="20"
|
||||
class="mr-2"
|
||||
/>
|
||||
{{ item.raw.name }}
|
||||
</v-chip>
|
||||
</template>
|
||||
</v-autocomplete>
|
||||
<v-list-item v-if="showPlatformsFilter">
|
||||
<FilterPlatformBtn :tabindex="activeFilterDrawer ? 0 : -1" />
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
v-for="filter in filters"
|
||||
:key="filter.label"
|
||||
:tabindex="activeFilterDrawer ? 0 : -1"
|
||||
class="py-2"
|
||||
>
|
||||
<v-autocomplete
|
||||
v-model="filter.selected.value"
|
||||
:tabindex="activeFilterDrawer ? 0 : -1"
|
||||
hide-details
|
||||
clearable
|
||||
:label="filter.label"
|
||||
variant="solo-filled"
|
||||
density="comfortable"
|
||||
:items="filter.items.value"
|
||||
@update:model-value="
|
||||
nextTick(() => emitter?.emit('filterRoms', null))
|
||||
"
|
||||
/>
|
||||
<div class="d-flex align-center ga-2 w-100">
|
||||
<v-select
|
||||
v-model="filter.selected.value"
|
||||
:tabindex="activeFilterDrawer ? 0 : -1"
|
||||
hide-details
|
||||
clearable
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
:label="filter.label"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
:items="filter.items.value"
|
||||
class="flex-grow-1"
|
||||
@update:model-value="
|
||||
nextTick(() => emitter?.emit('filterRoms', null))
|
||||
"
|
||||
/>
|
||||
<!-- AND/OR Logic Toggle - always visible -->
|
||||
<v-btn-toggle
|
||||
:model-value="filter.logic.value"
|
||||
mandatory
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
class="flex-shrink-0"
|
||||
@update:model-value="
|
||||
(value) => {
|
||||
filter.setLogic(value);
|
||||
nextTick(() => emitter?.emit('filterRoms', null));
|
||||
}
|
||||
"
|
||||
>
|
||||
<v-tooltip
|
||||
:text="t('platform.match-any-logic')"
|
||||
location="bottom"
|
||||
open-delay="500"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<v-btn value="any" size="small" v-bind="props">
|
||||
<v-icon size="x-large">mdi-set-none</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
<v-tooltip
|
||||
:text="t('platform.match-all-logic')"
|
||||
location="bottom"
|
||||
open-delay="500"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<v-btn value="all" size="small" v-bind="props">
|
||||
<v-icon size="x-large">mdi-set-all</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</v-btn-toggle>
|
||||
</div>
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
class="justify-center d-flex"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { Emitter } from "mitt";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { computed } from "vue";
|
||||
import { inject } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import storeGalleryFilter from "@/stores/galleryFilter";
|
||||
@@ -13,29 +14,91 @@ const galleryFilterStore = storeGalleryFilter();
|
||||
const { fetchTotalRoms } = storeToRefs(romsStore);
|
||||
const { filterDuplicates } = storeToRefs(galleryFilterStore);
|
||||
const emitter = inject<Emitter<Events>>("emitter");
|
||||
function setDuplicates() {
|
||||
galleryFilterStore.switchFilterDuplicates();
|
||||
|
||||
// Computed property to determine current state
|
||||
const currentState = computed(() => {
|
||||
if (filterDuplicates.value === true) return "duplicates";
|
||||
if (filterDuplicates.value === false) return "not-duplicates";
|
||||
return "all"; // null
|
||||
});
|
||||
|
||||
// Handler for state changes
|
||||
function setState(state: string | null) {
|
||||
if (!state) return;
|
||||
|
||||
galleryFilterStore.setFilterDuplicatesState(
|
||||
state as "all" | "duplicates" | "not-duplicates",
|
||||
);
|
||||
emitter?.emit("filterRoms", null);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-btn
|
||||
block
|
||||
variant="tonal"
|
||||
:color="filterDuplicates ? 'primary' : ''"
|
||||
:disabled="fetchTotalRoms > 10000"
|
||||
@click="setDuplicates"
|
||||
<div
|
||||
class="d-flex align-center justify-space-between py-2"
|
||||
:class="{ 'opacity-50': fetchTotalRoms > 10000 }"
|
||||
>
|
||||
<v-icon :color="filterDuplicates ? 'primary' : ''">
|
||||
mdi-card-multiple
|
||||
</v-icon>
|
||||
<span
|
||||
class="ml-2"
|
||||
:class="{
|
||||
'text-primary': filterDuplicates,
|
||||
}"
|
||||
>{{ t("platform.show-duplicates") }}
|
||||
</span>
|
||||
</v-btn>
|
||||
<div class="d-flex align-center">
|
||||
<v-icon
|
||||
:color="currentState !== 'all' ? 'primary' : 'grey-lighten-1'"
|
||||
class="mr-3"
|
||||
>
|
||||
mdi-card-multiple
|
||||
</v-icon>
|
||||
<span
|
||||
:class="
|
||||
currentState !== 'all'
|
||||
? 'text-primary font-weight-medium'
|
||||
: 'text-medium-emphasis'
|
||||
"
|
||||
class="text-body-1"
|
||||
>
|
||||
{{ t("platform.show-duplicates") }}
|
||||
</span>
|
||||
</div>
|
||||
<v-btn-toggle
|
||||
:model-value="currentState"
|
||||
color="primary"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
:disabled="fetchTotalRoms > 10000"
|
||||
@update:model-value="setState"
|
||||
>
|
||||
<v-btn value="all" size="small"
|
||||
><v-icon size="x-large">mdi-cancel</v-icon>
|
||||
</v-btn>
|
||||
<v-tooltip
|
||||
:text="t('platform.show-duplicates-only')"
|
||||
location="bottom"
|
||||
open-delay="500"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
value="duplicates"
|
||||
size="small"
|
||||
v-bind="props"
|
||||
:disabled="fetchTotalRoms > 10000"
|
||||
>
|
||||
<v-icon size="x-large">mdi-card-multiple</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
<v-tooltip
|
||||
:text="t('platform.show-not-duplicates-only')"
|
||||
location="bottom"
|
||||
open-delay="500"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
value="not-duplicates"
|
||||
size="small"
|
||||
v-bind="props"
|
||||
:disabled="fetchTotalRoms > 10000"
|
||||
>
|
||||
<v-icon size="x-large">mdi-card-outline</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</v-btn-toggle>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { Emitter } from "mitt";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { computed } from "vue";
|
||||
import { inject } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import storeGalleryFilter from "@/stores/galleryFilter";
|
||||
@@ -10,26 +11,77 @@ const { t } = useI18n();
|
||||
const galleryFilterStore = storeGalleryFilter();
|
||||
const { filterFavorites } = storeToRefs(galleryFilterStore);
|
||||
const emitter = inject<Emitter<Events>>("emitter");
|
||||
function setFavorites() {
|
||||
galleryFilterStore.switchFilterFavorites();
|
||||
|
||||
// Computed property to determine current state
|
||||
const currentState = computed(() => {
|
||||
if (filterFavorites.value === true) return "favorites";
|
||||
if (filterFavorites.value === false) return "not-favorites";
|
||||
return "all"; // null
|
||||
});
|
||||
|
||||
// Handler for state changes
|
||||
function setState(state: string | null) {
|
||||
if (!state) return;
|
||||
|
||||
galleryFilterStore.setFilterFavoritesState(
|
||||
state as "all" | "favorites" | "not-favorites",
|
||||
);
|
||||
emitter?.emit("filterRoms", null);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-btn
|
||||
block
|
||||
variant="tonal"
|
||||
:color="filterFavorites ? 'primary' : ''"
|
||||
@click="setFavorites"
|
||||
>
|
||||
<v-icon :color="filterFavorites ? 'primary' : ''"> mdi-star </v-icon
|
||||
><span
|
||||
class="ml-2"
|
||||
:class="{
|
||||
'text-primary': filterFavorites,
|
||||
}"
|
||||
>{{ t("platform.show-favorites") }}</span
|
||||
<div class="d-flex align-center justify-space-between py-2">
|
||||
<div class="d-flex align-center">
|
||||
<v-icon
|
||||
:color="currentState !== 'all' ? 'primary' : 'grey-lighten-1'"
|
||||
class="mr-3"
|
||||
>
|
||||
mdi-star
|
||||
</v-icon>
|
||||
<span
|
||||
:class="
|
||||
currentState !== 'all'
|
||||
? 'text-primary font-weight-medium'
|
||||
: 'text-medium-emphasis'
|
||||
"
|
||||
class="text-body-1"
|
||||
>
|
||||
{{ t("platform.show-favorites") }}
|
||||
</span>
|
||||
</div>
|
||||
<v-btn-toggle
|
||||
:model-value="currentState"
|
||||
color="primary"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
@update:model-value="setState"
|
||||
>
|
||||
</v-btn>
|
||||
<v-btn value="all" size="small"
|
||||
><v-icon size="x-large">mdi-cancel</v-icon>
|
||||
</v-btn>
|
||||
<v-tooltip
|
||||
:text="t('platform.show-favorites-only')"
|
||||
location="bottom"
|
||||
open-delay="500"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<v-btn value="favorites" size="small" v-bind="props">
|
||||
<v-icon size="x-large">mdi-star</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
<v-tooltip
|
||||
:text="t('platform.show-not-favorites-only')"
|
||||
location="bottom"
|
||||
open-delay="500"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<v-btn value="not-favorites" size="small" v-bind="props">
|
||||
<v-icon size="x-large">mdi-star-outline</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</v-btn-toggle>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import type { Emitter } from "mitt";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { computed } from "vue";
|
||||
import { inject } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import storeGalleryFilter from "@/stores/galleryFilter";
|
||||
import type { Events } from "@/types/emitter";
|
||||
|
||||
const { t } = useI18n();
|
||||
const galleryFilterStore = storeGalleryFilter();
|
||||
const { filterMatched } = storeToRefs(galleryFilterStore);
|
||||
const emitter = inject<Emitter<Events>>("emitter");
|
||||
|
||||
// Computed property to determine current state
|
||||
const currentState = computed(() => {
|
||||
if (filterMatched.value === true) return "matched";
|
||||
if (filterMatched.value === false) return "unmatched";
|
||||
return "all"; // null
|
||||
});
|
||||
|
||||
// Handler for state changes
|
||||
function setState(state: string | null) {
|
||||
if (!state) return;
|
||||
|
||||
galleryFilterStore.setFilterMatchedState(
|
||||
state as "all" | "matched" | "unmatched",
|
||||
);
|
||||
emitter?.emit("filterRoms", null);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="d-flex align-center justify-space-between py-2">
|
||||
<div class="d-flex align-center">
|
||||
<v-icon
|
||||
:color="currentState !== 'all' ? 'primary' : 'grey-lighten-1'"
|
||||
class="mr-3"
|
||||
>
|
||||
mdi-file-search-outline
|
||||
</v-icon>
|
||||
<span
|
||||
:class="
|
||||
currentState !== 'all'
|
||||
? 'text-primary font-weight-medium'
|
||||
: 'text-medium-emphasis'
|
||||
"
|
||||
class="text-body-1"
|
||||
>
|
||||
Match Status
|
||||
</span>
|
||||
</div>
|
||||
<v-btn-toggle
|
||||
:model-value="currentState"
|
||||
color="primary"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
@update:model-value="setState"
|
||||
>
|
||||
<v-btn value="all" size="small"
|
||||
><v-icon size="x-large">mdi-cancel</v-icon>
|
||||
</v-btn>
|
||||
<v-tooltip
|
||||
:text="t('platform.show-matched')"
|
||||
location="bottom"
|
||||
open-delay="500"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<v-btn value="matched" size="small" v-bind="props">
|
||||
<v-icon size="x-large">mdi-file-find</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
<v-tooltip
|
||||
:text="t('platform.show-unmatched')"
|
||||
location="bottom"
|
||||
open-delay="500"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<v-btn value="unmatched" size="small" v-bind="props">
|
||||
<v-icon size="x-large">mdi-file-find-outline</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</v-btn-toggle>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,35 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Emitter } from "mitt";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { inject } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import storeGalleryFilter from "@/stores/galleryFilter";
|
||||
import type { Events } from "@/types/emitter";
|
||||
|
||||
const { t } = useI18n();
|
||||
const galleryFilterStore = storeGalleryFilter();
|
||||
const { filterMatched } = storeToRefs(galleryFilterStore);
|
||||
const emitter = inject<Emitter<Events>>("emitter");
|
||||
function setUnmatched() {
|
||||
galleryFilterStore.switchFilterMatched();
|
||||
emitter?.emit("filterRoms", null);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-btn
|
||||
block
|
||||
variant="tonal"
|
||||
:color="filterMatched ? 'primary' : ''"
|
||||
@click="setUnmatched"
|
||||
>
|
||||
<v-icon :color="filterMatched ? 'primary' : ''"> mdi-file-find </v-icon
|
||||
><span
|
||||
class="ml-2"
|
||||
:class="{
|
||||
'text-primary': filterMatched,
|
||||
}"
|
||||
>{{ t("platform.show-matched") }}</span
|
||||
>
|
||||
</v-btn>
|
||||
</template>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { Emitter } from "mitt";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { computed } from "vue";
|
||||
import { inject } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import storeGalleryFilter from "@/stores/galleryFilter";
|
||||
@@ -10,27 +11,77 @@ const { t } = useI18n();
|
||||
const galleryFilterStore = storeGalleryFilter();
|
||||
const { filterMissing } = storeToRefs(galleryFilterStore);
|
||||
const emitter = inject<Emitter<Events>>("emitter");
|
||||
function setMissing() {
|
||||
galleryFilterStore.switchFilterMissing();
|
||||
|
||||
// Computed property to determine current state
|
||||
const currentState = computed(() => {
|
||||
if (filterMissing.value === true) return "missing";
|
||||
if (filterMissing.value === false) return "not-missing";
|
||||
return "all"; // null
|
||||
});
|
||||
|
||||
// Handler for state changes
|
||||
function setState(state: string | null) {
|
||||
if (!state) return;
|
||||
|
||||
galleryFilterStore.setFilterMissingState(
|
||||
state as "all" | "missing" | "not-missing",
|
||||
);
|
||||
emitter?.emit("filterRoms", null);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-btn
|
||||
block
|
||||
variant="tonal"
|
||||
:color="filterMissing ? 'primary' : ''"
|
||||
@click="setMissing"
|
||||
>
|
||||
<v-icon :color="filterMissing ? 'primary' : ''">
|
||||
mdi-folder-question </v-icon
|
||||
><span
|
||||
class="ml-2"
|
||||
:class="{
|
||||
'text-primary': filterMissing,
|
||||
}"
|
||||
>{{ t("platform.show-missing") }}</span
|
||||
<div class="d-flex align-center justify-space-between py-2">
|
||||
<div class="d-flex align-center">
|
||||
<v-icon
|
||||
:color="currentState !== 'all' ? 'primary' : 'grey-lighten-1'"
|
||||
class="mr-3"
|
||||
>
|
||||
mdi-folder-question
|
||||
</v-icon>
|
||||
<span
|
||||
:class="
|
||||
currentState !== 'all'
|
||||
? 'text-primary font-weight-medium'
|
||||
: 'text-medium-emphasis'
|
||||
"
|
||||
class="text-body-1"
|
||||
>
|
||||
{{ t("platform.show-missing") }}
|
||||
</span>
|
||||
</div>
|
||||
<v-btn-toggle
|
||||
:model-value="currentState"
|
||||
color="primary"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
@update:model-value="setState"
|
||||
>
|
||||
</v-btn>
|
||||
<v-btn value="all" size="small"
|
||||
><v-icon size="x-large">mdi-cancel</v-icon>
|
||||
</v-btn>
|
||||
<v-tooltip
|
||||
:text="t('platform.show-missing-only')"
|
||||
location="bottom"
|
||||
open-delay="500"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<v-btn value="missing" size="small" v-bind="props">
|
||||
<v-icon size="x-large">mdi-file-question</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
<v-tooltip
|
||||
:text="t('platform.show-not-missing-only')"
|
||||
location="bottom"
|
||||
open-delay="500"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<v-btn value="not-missing" size="small" v-bind="props">
|
||||
<v-icon size="x-large">mdi-file-check</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</v-btn-toggle>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import type { Emitter } from "mitt";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { inject, nextTick } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import MissingFromFSIcon from "@/components/common/MissingFromFSIcon.vue";
|
||||
import PlatformIcon from "@/components/common/Platform/PlatformIcon.vue";
|
||||
import storeGalleryFilter from "@/stores/galleryFilter";
|
||||
import type { Events } from "@/types/emitter";
|
||||
|
||||
defineProps<{
|
||||
tabindex?: number;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const galleryFilterStore = storeGalleryFilter();
|
||||
const { selectedPlatforms, filterPlatforms } = storeToRefs(galleryFilterStore);
|
||||
const emitter = inject<Emitter<Events>>("emitter");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="d-flex align-center ga-2 w-100">
|
||||
<v-select
|
||||
v-model="selectedPlatforms"
|
||||
:tabindex="tabindex"
|
||||
hide-details
|
||||
:label="t('common.platform')"
|
||||
clearable
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
:items="filterPlatforms"
|
||||
class="flex-grow-1"
|
||||
@update:model-value="nextTick(() => emitter?.emit('filterRoms', null))"
|
||||
>
|
||||
<template #prepend-inner>
|
||||
<v-icon class="ml-2 mr-1">mdi-controller</v-icon>
|
||||
</template>
|
||||
<template #item="{ props, item }">
|
||||
<v-list-item
|
||||
v-bind="props"
|
||||
class="py-4"
|
||||
:title="item.raw.name ?? ''"
|
||||
:subtitle="item.raw.fs_slug"
|
||||
>
|
||||
<template #prepend>
|
||||
<PlatformIcon
|
||||
:key="item.raw.slug"
|
||||
:size="35"
|
||||
:slug="item.raw.slug"
|
||||
:name="item.raw.name"
|
||||
:fs-slug="item.raw.fs_slug"
|
||||
/>
|
||||
</template>
|
||||
<template #append>
|
||||
<MissingFromFSIcon
|
||||
v-if="item.raw.missing_from_fs"
|
||||
:text="t('platform.missing-from-filesystem')"
|
||||
chip
|
||||
chip-label
|
||||
chip-density="compact"
|
||||
class="ml-2"
|
||||
/>
|
||||
<v-chip class="ml-2" size="x-small" label>
|
||||
{{ item.raw.rom_count }}
|
||||
</v-chip>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
<template #chip="{ item, props }">
|
||||
<v-chip v-bind="props">
|
||||
<PlatformIcon
|
||||
:key="item.raw.slug"
|
||||
:slug="item.raw.slug"
|
||||
:name="item.raw.name"
|
||||
:fs-slug="item.raw.fs_slug"
|
||||
:size="20"
|
||||
class="mr-2"
|
||||
/>
|
||||
{{ item.raw.name }}
|
||||
</v-chip>
|
||||
</template>
|
||||
</v-select>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { Emitter } from "mitt";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { computed } from "vue";
|
||||
import { inject } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import storeGalleryFilter from "@/stores/galleryFilter";
|
||||
@@ -14,27 +15,90 @@ const { fetchTotalRoms } = storeToRefs(romsStore);
|
||||
const { filterPlayables } = storeToRefs(galleryFilterStore);
|
||||
const emitter = inject<Emitter<Events>>("emitter");
|
||||
|
||||
function setPlayables() {
|
||||
galleryFilterStore.switchFilterPlayables();
|
||||
// Computed property to determine current state
|
||||
const currentState = computed(() => {
|
||||
if (filterPlayables.value === true) return "playables";
|
||||
if (filterPlayables.value === false) return "not-playables";
|
||||
return "all"; // null
|
||||
});
|
||||
|
||||
// Handler for state changes
|
||||
function setState(state: string | null) {
|
||||
if (!state) return;
|
||||
|
||||
galleryFilterStore.setFilterPlayablesState(
|
||||
state as "all" | "playables" | "not-playables",
|
||||
);
|
||||
emitter?.emit("filterRoms", null);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-btn
|
||||
block
|
||||
variant="tonal"
|
||||
:color="filterPlayables ? 'primary' : ''"
|
||||
:disabled="fetchTotalRoms > 10000"
|
||||
@click="setPlayables"
|
||||
<div
|
||||
class="d-flex align-center justify-space-between py-2"
|
||||
:class="{ 'opacity-50': fetchTotalRoms > 10000 }"
|
||||
>
|
||||
<v-icon :color="filterPlayables ? 'primary' : ''"> mdi-play </v-icon>
|
||||
<span
|
||||
class="ml-2"
|
||||
:class="{
|
||||
'text-primary': filterPlayables,
|
||||
}"
|
||||
>{{ t("platform.show-playables") }}
|
||||
</span>
|
||||
</v-btn>
|
||||
<div class="d-flex align-center">
|
||||
<v-icon
|
||||
:color="currentState !== 'all' ? 'primary' : 'grey-lighten-1'"
|
||||
class="mr-3"
|
||||
>
|
||||
mdi-play
|
||||
</v-icon>
|
||||
<span
|
||||
:class="
|
||||
currentState !== 'all'
|
||||
? 'text-primary font-weight-medium'
|
||||
: 'text-medium-emphasis'
|
||||
"
|
||||
class="text-body-1"
|
||||
>
|
||||
{{ t("platform.show-playables") }}
|
||||
</span>
|
||||
</div>
|
||||
<v-btn-toggle
|
||||
:model-value="currentState"
|
||||
color="primary"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
:disabled="fetchTotalRoms > 10000"
|
||||
@update:model-value="setState"
|
||||
>
|
||||
<v-btn value="all" size="small"
|
||||
><v-icon size="x-large">mdi-cancel</v-icon>
|
||||
</v-btn>
|
||||
<v-tooltip
|
||||
:text="t('platform.show-playables-only')"
|
||||
location="bottom"
|
||||
open-delay="500"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
value="playables"
|
||||
size="small"
|
||||
v-bind="props"
|
||||
:disabled="fetchTotalRoms > 10000"
|
||||
>
|
||||
<v-icon size="x-large">mdi-play</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
<v-tooltip
|
||||
:text="t('platform.show-not-playables-only')"
|
||||
location="bottom"
|
||||
open-delay="500"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
value="not-playables"
|
||||
size="small"
|
||||
v-bind="props"
|
||||
:disabled="fetchTotalRoms > 10000"
|
||||
>
|
||||
<v-icon size="x-large">mdi-play-outline</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</v-btn-toggle>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { Emitter } from "mitt";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { computed } from "vue";
|
||||
import { inject } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import storeGalleryFilter from "@/stores/galleryFilter";
|
||||
@@ -14,27 +15,88 @@ const { fetchTotalRoms } = storeToRefs(romsStore);
|
||||
const { filterRA } = storeToRefs(galleryFilterStore);
|
||||
const emitter = inject<Emitter<Events>>("emitter");
|
||||
|
||||
function setRA() {
|
||||
galleryFilterStore.switchFilterRA();
|
||||
// Computed property to determine current state
|
||||
const currentState = computed(() => {
|
||||
if (filterRA.value === true) return "has-ra";
|
||||
if (filterRA.value === false) return "no-ra";
|
||||
return "all"; // null
|
||||
});
|
||||
|
||||
// Handler for state changes
|
||||
function setState(state: string | null) {
|
||||
if (!state) return;
|
||||
|
||||
galleryFilterStore.setFilterRAState(state as "all" | "has-ra" | "no-ra");
|
||||
emitter?.emit("filterRoms", null);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-btn
|
||||
block
|
||||
variant="tonal"
|
||||
:color="filterRA ? 'primary' : ''"
|
||||
:disabled="fetchTotalRoms > 10000"
|
||||
@click="setRA"
|
||||
<div
|
||||
class="d-flex align-center justify-space-between py-2"
|
||||
:class="{ 'opacity-50': fetchTotalRoms > 10000 }"
|
||||
>
|
||||
<v-icon :color="filterRA ? 'primary' : ''"> mdi-trophy </v-icon>
|
||||
<span
|
||||
class="ml-2"
|
||||
:class="{
|
||||
'text-primary': filterRA,
|
||||
}"
|
||||
>{{ t("platform.show-ra") }}
|
||||
</span>
|
||||
</v-btn>
|
||||
<div class="d-flex align-center">
|
||||
<v-icon
|
||||
:color="currentState !== 'all' ? 'primary' : 'grey-lighten-1'"
|
||||
class="mr-3"
|
||||
>
|
||||
mdi-trophy
|
||||
</v-icon>
|
||||
<span
|
||||
:class="
|
||||
currentState !== 'all'
|
||||
? 'text-primary font-weight-medium'
|
||||
: 'text-medium-emphasis'
|
||||
"
|
||||
class="text-body-1"
|
||||
>
|
||||
{{ t("platform.show-ra") }}
|
||||
</span>
|
||||
</div>
|
||||
<v-btn-toggle
|
||||
:model-value="currentState"
|
||||
color="primary"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
:disabled="fetchTotalRoms > 10000"
|
||||
@update:model-value="setState"
|
||||
>
|
||||
<v-btn value="all" size="small"
|
||||
><v-icon size="x-large">mdi-cancel</v-icon>
|
||||
</v-btn>
|
||||
<v-tooltip
|
||||
:text="t('platform.show-ra-only')"
|
||||
location="bottom"
|
||||
open-delay="500"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
value="has-ra"
|
||||
size="small"
|
||||
v-bind="props"
|
||||
:disabled="fetchTotalRoms > 10000"
|
||||
>
|
||||
<v-icon size="x-large">mdi-trophy</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
<v-tooltip
|
||||
:text="t('platform.show-not-ra-only')"
|
||||
location="bottom"
|
||||
open-delay="500"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
value="no-ra"
|
||||
size="small"
|
||||
v-bind="props"
|
||||
:disabled="fetchTotalRoms > 10000"
|
||||
>
|
||||
<v-icon size="x-large">mdi-trophy-outline</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</v-btn-toggle>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Emitter } from "mitt";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { inject } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import storeGalleryFilter from "@/stores/galleryFilter";
|
||||
import type { Events } from "@/types/emitter";
|
||||
|
||||
const { t } = useI18n();
|
||||
const galleryFilterStore = storeGalleryFilter();
|
||||
const { filterUnmatched } = storeToRefs(galleryFilterStore);
|
||||
const emitter = inject<Emitter<Events>>("emitter");
|
||||
function setUnmatched() {
|
||||
galleryFilterStore.switchFilterUnmatched();
|
||||
emitter?.emit("filterRoms", null);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-btn
|
||||
block
|
||||
variant="tonal"
|
||||
:color="filterUnmatched ? 'primary' : ''"
|
||||
@click="setUnmatched"
|
||||
>
|
||||
<v-icon :color="filterUnmatched ? 'primary' : ''">
|
||||
mdi-file-find-outline </v-icon
|
||||
><span
|
||||
class="ml-2"
|
||||
:class="{
|
||||
'text-primary': filterUnmatched,
|
||||
}"
|
||||
>{{ t("platform.show-unmatched") }}</span
|
||||
>
|
||||
</v-btn>
|
||||
</template>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { Emitter } from "mitt";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { computed } from "vue";
|
||||
import { inject } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import storeGalleryFilter from "@/stores/galleryFilter";
|
||||
@@ -10,27 +11,77 @@ const { t } = useI18n();
|
||||
const galleryFilterStore = storeGalleryFilter();
|
||||
const { filterVerified } = storeToRefs(galleryFilterStore);
|
||||
const emitter = inject<Emitter<Events>>("emitter");
|
||||
function setVerified() {
|
||||
galleryFilterStore.switchFilterVerified();
|
||||
|
||||
// Computed property to determine current state
|
||||
const currentState = computed(() => {
|
||||
if (filterVerified.value === true) return "verified";
|
||||
if (filterVerified.value === false) return "not-verified";
|
||||
return "all"; // null
|
||||
});
|
||||
|
||||
// Handler for state changes
|
||||
function setState(state: string | null) {
|
||||
if (!state) return;
|
||||
|
||||
galleryFilterStore.setFilterVerifiedState(
|
||||
state as "all" | "verified" | "not-verified",
|
||||
);
|
||||
emitter?.emit("filterRoms", null);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-btn
|
||||
block
|
||||
variant="tonal"
|
||||
:color="filterVerified ? 'primary' : ''"
|
||||
@click="setVerified"
|
||||
>
|
||||
<v-icon :color="filterVerified ? 'primary' : ''">
|
||||
mdi-check-decagram </v-icon
|
||||
><span
|
||||
class="ml-2"
|
||||
:class="{
|
||||
'text-primary': filterVerified,
|
||||
}"
|
||||
>{{ t("platform.show-verified") }}</span
|
||||
<div class="d-flex align-center justify-space-between py-2">
|
||||
<div class="d-flex align-center">
|
||||
<v-icon
|
||||
:color="currentState !== 'all' ? 'primary' : 'grey-lighten-1'"
|
||||
class="mr-3"
|
||||
>
|
||||
mdi-check-decagram
|
||||
</v-icon>
|
||||
<span
|
||||
:class="
|
||||
currentState !== 'all'
|
||||
? 'text-primary font-weight-medium'
|
||||
: 'text-medium-emphasis'
|
||||
"
|
||||
class="text-body-1"
|
||||
>
|
||||
{{ t("platform.show-verified") }}
|
||||
</span>
|
||||
</div>
|
||||
<v-btn-toggle
|
||||
:model-value="currentState"
|
||||
color="primary"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
@update:model-value="setState"
|
||||
>
|
||||
</v-btn>
|
||||
<v-btn value="all" size="small"
|
||||
><v-icon size="x-large">mdi-cancel</v-icon>
|
||||
</v-btn>
|
||||
<v-tooltip
|
||||
:text="t('platform.show-verified-only')"
|
||||
location="bottom"
|
||||
open-delay="500"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<v-btn value="verified" size="small" v-bind="props">
|
||||
<v-icon size="x-large">mdi-check-decagram-outline</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
<v-tooltip
|
||||
:text="t('platform.show-not-verified-only')"
|
||||
location="bottom"
|
||||
open-delay="500"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<v-btn value="not-verified" size="small" v-bind="props">
|
||||
<v-icon size="x-large">mdi-close-octagon-outline</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</v-btn-toggle>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -30,12 +30,12 @@ function onHover(emitData: { isHovering: boolean; id: number }) {
|
||||
hoveringCollectionId.value = emitData.id;
|
||||
}
|
||||
|
||||
const { y: documentY } = useScroll(document.body, { throttle: 100 });
|
||||
const { y: windowY } = useScroll(window, { throttle: 100 });
|
||||
|
||||
// Watch for scroll changes and trigger the throttled function
|
||||
watch(documentY, () => {
|
||||
watch(windowY, () => {
|
||||
if (
|
||||
documentY.value + window.innerHeight >= document.body.scrollHeight - 300 &&
|
||||
windowY.value + window.innerHeight >= document.body.scrollHeight - 300 &&
|
||||
visibleCollections.value < props.collections.length
|
||||
) {
|
||||
visibleCollections.value += 72;
|
||||
|
||||
@@ -1,59 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { useLocalStorage } from "@vueuse/core";
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useDisplay } from "vuetify";
|
||||
import InterfaceOption from "@/components/Settings/UserInterface/InterfaceOption.vue";
|
||||
import RSection from "@/components/common/RSection.vue";
|
||||
import { useUISettings } from "@/composables/useUISettings";
|
||||
import storeCollections from "@/stores/collections";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { smAndDown } = useDisplay();
|
||||
const collectionsStore = storeCollections();
|
||||
|
||||
// Home
|
||||
const showStatsRef = useLocalStorage("settings.showStats", true);
|
||||
const showRecentRomsRef = useLocalStorage("settings.showRecentRoms", true);
|
||||
const showContinuePlayingRef = useLocalStorage(
|
||||
"settings.showContinuePlaying",
|
||||
true,
|
||||
);
|
||||
const showPlatformsRef = useLocalStorage("settings.showPlatforms", true);
|
||||
const showCollectionsRef = useLocalStorage("settings.showCollections", true);
|
||||
|
||||
// Virtual collections
|
||||
const showVirtualCollectionsRef = useLocalStorage(
|
||||
"settings.showVirtualCollections",
|
||||
true,
|
||||
);
|
||||
const virtualCollectionTypeRef = useLocalStorage(
|
||||
"settings.virtualCollectionType",
|
||||
"collection",
|
||||
);
|
||||
|
||||
// Platforms drawer
|
||||
const platformsGroupByRef = useLocalStorage<string | null>(
|
||||
"settings.platformsGroupBy",
|
||||
null,
|
||||
);
|
||||
|
||||
// Gallery
|
||||
const groupRomsRef = useLocalStorage("settings.groupRoms", true);
|
||||
const siblingsRef = useLocalStorage("settings.showSiblings", true);
|
||||
const regionsRef = useLocalStorage("settings.showRegions", true);
|
||||
const languagesRef = useLocalStorage("settings.showLanguages", true);
|
||||
const statusRef = useLocalStorage("settings.showStatus", true);
|
||||
const actionBarRef = useLocalStorage("settings.showActionBar", false);
|
||||
const gameTitleRef = useLocalStorage("settings.showGameTitle", false);
|
||||
const enable3DEffectRef = useLocalStorage("settings.enable3DEffect", false);
|
||||
const enableExperimentalCacheRef = useLocalStorage(
|
||||
"settings.enableExperimentalCache",
|
||||
false,
|
||||
);
|
||||
const disableAnimationsRef = useLocalStorage(
|
||||
"settings.disableAnimations",
|
||||
false,
|
||||
);
|
||||
// Get UI settings from the composable
|
||||
const {
|
||||
showStats: showStatsRef,
|
||||
showRecentRoms: showRecentRomsRef,
|
||||
showContinuePlaying: showContinuePlayingRef,
|
||||
showPlatforms: showPlatformsRef,
|
||||
showCollections: showCollectionsRef,
|
||||
showVirtualCollections: showVirtualCollectionsRef,
|
||||
virtualCollectionType: virtualCollectionTypeRef,
|
||||
platformsGroupBy: platformsGroupByRef,
|
||||
groupRoms: groupRomsRef,
|
||||
showSiblings: siblingsRef,
|
||||
showRegions: regionsRef,
|
||||
showLanguages: languagesRef,
|
||||
showStatus: statusRef,
|
||||
showActionBar: actionBarRef,
|
||||
showGameTitle: gameTitleRef,
|
||||
enable3DEffect: enable3DEffectRef,
|
||||
enableExperimentalCache: enableExperimentalCacheRef,
|
||||
disableAnimations: disableAnimationsRef,
|
||||
boxartStyle: boxartStyleRef,
|
||||
} = useUISettings();
|
||||
|
||||
// Boxart
|
||||
export type BoxartStyleOption =
|
||||
@@ -61,10 +40,6 @@ export type BoxartStyleOption =
|
||||
| "box3d_path"
|
||||
| "physical_path"
|
||||
| "miximage_path";
|
||||
const boxartStyleRef = useLocalStorage<BoxartStyleOption>(
|
||||
"settings.boxartStyle",
|
||||
"cover_path",
|
||||
);
|
||||
|
||||
const homeOptions = computed(() => [
|
||||
{
|
||||
@@ -211,7 +186,7 @@ const boxartStyleOptions = computed(() => [
|
||||
{ title: t("settings.boxart-miximage"), value: "miximage_path" },
|
||||
]);
|
||||
|
||||
const setPlatformDrawerGroupBy = (value: string) => {
|
||||
const setPlatformDrawerGroupBy = (value: string | null) => {
|
||||
platformsGroupByRef.value = value;
|
||||
};
|
||||
const toggleShowContinuePlaying = (value: boolean) => {
|
||||
@@ -230,8 +205,8 @@ const setVirtualCollectionType = async (value: string) => {
|
||||
virtualCollectionTypeRef.value = value;
|
||||
collectionsStore.fetchVirtualCollections(value);
|
||||
};
|
||||
const setBoxartStyle = (value: BoxartStyleOption) => {
|
||||
boxartStyleRef.value = value;
|
||||
const setBoxartStyle = (value: string) => {
|
||||
boxartStyleRef.value = value as BoxartStyleOption;
|
||||
};
|
||||
const toggleShowStats = (value: boolean) => {
|
||||
showStatsRef.value = value;
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { useLocalStorage } from "@vueuse/core";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useUISettings } from "@/composables/useUISettings";
|
||||
import storeLanguage from "@/stores/language";
|
||||
|
||||
const { locale } = useI18n();
|
||||
const languageStore = storeLanguage();
|
||||
const { languages, selectedLanguage } = storeToRefs(languageStore);
|
||||
const localeStorage = useLocalStorage("settings.locale", "");
|
||||
|
||||
const { locale: localeStorage } = useUISettings();
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { useLocalStorage } from "@vueuse/core";
|
||||
import { computed } from "vue";
|
||||
import { computed, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useTheme } from "vuetify";
|
||||
import ThemeOption from "@/components/Settings/UserInterface/ThemeOption.vue";
|
||||
import RSection from "@/components/common/RSection.vue";
|
||||
import { autoThemeKey, themes } from "@/styles/themes";
|
||||
import { isKeyof } from "@/types";
|
||||
import { useUISettings } from "@/composables/useUISettings";
|
||||
|
||||
const { t } = useI18n();
|
||||
const theme = useTheme();
|
||||
const selectedTheme = useLocalStorage("settings.theme", autoThemeKey);
|
||||
const { theme: selectedTheme } = useUISettings();
|
||||
const themeOptions = computed(() => [
|
||||
{
|
||||
name: "dark",
|
||||
@@ -26,23 +24,24 @@ const themeOptions = computed(() => [
|
||||
},
|
||||
]);
|
||||
|
||||
function toggleTheme() {
|
||||
function applyTheme() {
|
||||
const mediaMatch = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
if (selectedTheme.value === autoThemeKey) {
|
||||
theme.global.name.value = mediaMatch.matches ? "dark" : "light";
|
||||
} else if (isKeyof(selectedTheme.value, themes)) {
|
||||
theme.global.name.value = themes[selectedTheme.value];
|
||||
const themeValue = selectedTheme.value;
|
||||
|
||||
if (themeValue === "auto") {
|
||||
theme.change(mediaMatch.matches ? "dark" : "light");
|
||||
} else if (themeValue === "dark" || themeValue === "light") {
|
||||
theme.change(themeValue);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply theme when it changes (including from backend sync)
|
||||
watch(selectedTheme, applyTheme, { immediate: true });
|
||||
</script>
|
||||
<template>
|
||||
<RSection icon="mdi-brush-variant" :title="t('settings.theme')" class="ma-2">
|
||||
<template #content>
|
||||
<v-item-group
|
||||
v-model="selectedTheme"
|
||||
mandatory
|
||||
@update:model-value="toggleTheme"
|
||||
>
|
||||
<v-item-group v-model="selectedTheme" mandatory>
|
||||
<v-row no-gutters>
|
||||
<v-col
|
||||
v-for="themeOption in themeOptions"
|
||||
|
||||
@@ -26,7 +26,6 @@ const isPublic = ref(false);
|
||||
|
||||
const {
|
||||
searchTerm,
|
||||
filterUnmatched,
|
||||
filterMatched,
|
||||
filterFavorites,
|
||||
filterDuplicates,
|
||||
@@ -34,15 +33,22 @@ const {
|
||||
filterRA,
|
||||
filterMissing,
|
||||
filterVerified,
|
||||
selectedGenre,
|
||||
selectedFranchise,
|
||||
selectedCollection,
|
||||
selectedCompany,
|
||||
selectedAgeRating,
|
||||
selectedStatus,
|
||||
selectedPlatform,
|
||||
selectedRegion,
|
||||
selectedLanguage,
|
||||
selectedGenres,
|
||||
selectedFranchises,
|
||||
selectedCollections,
|
||||
selectedCompanies,
|
||||
selectedAgeRatings,
|
||||
selectedStatuses,
|
||||
selectedPlatforms,
|
||||
selectedRegions,
|
||||
selectedLanguages,
|
||||
genresLogic,
|
||||
franchisesLogic,
|
||||
collectionsLogic,
|
||||
companiesLogic,
|
||||
ageRatingsLogic,
|
||||
regionsLogic,
|
||||
languagesLogic,
|
||||
} = storeToRefs(galleryFilterStore);
|
||||
|
||||
const emitter = inject<Emitter<Events>>("emitter");
|
||||
@@ -64,32 +70,41 @@ const filterSummary = computed(() => {
|
||||
const filters = [];
|
||||
|
||||
if (searchTerm.value) filters.push(`Search: "${searchTerm.value}"`);
|
||||
if (selectedPlatform.value)
|
||||
filters.push(`Platform: ${selectedPlatform.value.name}`);
|
||||
if (selectedPlatforms.value && selectedPlatforms.value.length > 0)
|
||||
filters.push(
|
||||
`Platforms: ${selectedPlatforms.value.map((p) => p.name).join(", ")}`,
|
||||
);
|
||||
if (filterMatched.value) filters.push("Matched only");
|
||||
if (filterUnmatched.value) filters.push("Unmatched only");
|
||||
if (filterFavorites.value) filters.push("Favorites");
|
||||
if (filterDuplicates.value) filters.push("Duplicates");
|
||||
if (filterPlayables.value) filters.push("Playable");
|
||||
if (filterRA.value) filters.push("Has RetroAchievements");
|
||||
if (filterMissing.value) filters.push("Missing from filesystem");
|
||||
if (filterVerified.value) filters.push("Verified");
|
||||
if (selectedGenre.value) filters.push(`Genre: ${selectedGenre.value}`);
|
||||
if (selectedFranchise.value)
|
||||
filters.push(`Franchise: ${selectedFranchise.value}`);
|
||||
if (selectedCollection.value)
|
||||
filters.push(`Collection: ${selectedCollection.value}`);
|
||||
if (selectedCompany.value) filters.push(`Company: ${selectedCompany.value}`);
|
||||
if (selectedAgeRating.value)
|
||||
filters.push(`Age Rating: ${selectedAgeRating.value}`);
|
||||
if (selectedStatus.value) filters.push(`Status: ${selectedStatus.value}`);
|
||||
if (selectedRegion.value) filters.push(`Region: ${selectedRegion.value}`);
|
||||
if (selectedLanguage.value)
|
||||
filters.push(`Language: ${selectedLanguage.value}`);
|
||||
if (selectedGenres.value && selectedGenres.value.length > 0)
|
||||
filters.push(`Genres: ${selectedGenres.value.join(", ")}`);
|
||||
if (selectedFranchises.value && selectedFranchises.value.length > 0)
|
||||
filters.push(`Franchises: ${selectedFranchises.value.join(", ")}`);
|
||||
if (selectedCollections.value && selectedCollections.value.length > 0)
|
||||
filters.push(`Collections: ${selectedCollections.value.join(", ")}`);
|
||||
if (selectedCompanies.value && selectedCompanies.value.length > 0)
|
||||
filters.push(`Companies: ${selectedCompanies.value.join(", ")}`);
|
||||
if (selectedAgeRatings.value && selectedAgeRatings.value.length > 0)
|
||||
filters.push(`Age Ratings: ${selectedAgeRatings.value.join(", ")}`);
|
||||
if (selectedStatuses.value && selectedStatuses.value.length > 0)
|
||||
filters.push(`Statuses: ${selectedStatuses.value.join(", ")}`);
|
||||
if (selectedRegions.value && selectedRegions.value.length > 0)
|
||||
filters.push(`Regions: ${selectedRegions.value.join(", ")}`);
|
||||
if (selectedLanguages.value && selectedLanguages.value.length > 0)
|
||||
filters.push(`Languages: ${selectedLanguages.value.join(", ")}`);
|
||||
|
||||
return filters || ["No filters applied"];
|
||||
});
|
||||
|
||||
function toggleCollectionVisibility() {
|
||||
isPublic.value = !isPublic.value;
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
show.value = false;
|
||||
name.value = "";
|
||||
@@ -110,37 +125,65 @@ async function createSmartCollection() {
|
||||
emitter?.emit("showLoadingDialog", { loading: true, scrim: true });
|
||||
|
||||
try {
|
||||
const filterCriteria: Record<string, number | boolean | string | null> = {};
|
||||
const filterCriteria: Record<
|
||||
string,
|
||||
number | boolean | string | string[] | number[] | (string | null)[] | null
|
||||
> = {};
|
||||
|
||||
if (searchTerm.value) filterCriteria.search_term = searchTerm.value;
|
||||
if (selectedPlatform.value)
|
||||
filterCriteria.platform_id = selectedPlatform.value.id;
|
||||
if (selectedPlatforms.value && selectedPlatforms.value.length > 0)
|
||||
filterCriteria.platform_ids = selectedPlatforms.value.map((p) => p.id);
|
||||
if (filterMatched.value) filterCriteria.matched = true;
|
||||
if (filterUnmatched.value) filterCriteria.matched = false;
|
||||
if (filterFavorites.value) filterCriteria.favorite = true;
|
||||
if (filterDuplicates.value) filterCriteria.duplicate = true;
|
||||
if (filterPlayables.value) filterCriteria.playable = true;
|
||||
if (filterRA.value) filterCriteria.has_ra = true;
|
||||
if (filterMissing.value) filterCriteria.missing = true;
|
||||
if (filterVerified.value) filterCriteria.verified = true;
|
||||
if (selectedGenre.value)
|
||||
filterCriteria.selected_genre = selectedGenre.value;
|
||||
if (selectedFranchise.value)
|
||||
filterCriteria.selected_franchise = selectedFranchise.value;
|
||||
if (selectedCollection.value)
|
||||
filterCriteria.selected_collection = selectedCollection.value;
|
||||
if (selectedCompany.value)
|
||||
filterCriteria.selected_company = selectedCompany.value;
|
||||
if (selectedAgeRating.value)
|
||||
filterCriteria.selected_age_rating = selectedAgeRating.value;
|
||||
if (selectedStatus.value)
|
||||
filterCriteria.selected_status = getStatusKeyForText(
|
||||
selectedStatus.value,
|
||||
);
|
||||
if (selectedRegion.value)
|
||||
filterCriteria.selected_region = selectedRegion.value;
|
||||
if (selectedLanguage.value)
|
||||
filterCriteria.selected_language = selectedLanguage.value;
|
||||
if (selectedGenres.value && selectedGenres.value.length > 0) {
|
||||
filterCriteria.genres = selectedGenres.value;
|
||||
if (selectedGenres.value.length > 1)
|
||||
filterCriteria.genres_logic = genresLogic.value;
|
||||
}
|
||||
if (selectedFranchises.value && selectedFranchises.value.length > 0) {
|
||||
filterCriteria.franchises = selectedFranchises.value;
|
||||
if (selectedFranchises.value.length > 1)
|
||||
filterCriteria.franchises_logic = franchisesLogic.value;
|
||||
}
|
||||
if (selectedCollections.value && selectedCollections.value.length > 0) {
|
||||
filterCriteria.collections = selectedCollections.value;
|
||||
if (selectedCollections.value.length > 1)
|
||||
filterCriteria.collections_logic = collectionsLogic.value;
|
||||
}
|
||||
if (selectedCompanies.value && selectedCompanies.value.length > 0) {
|
||||
filterCriteria.companies = selectedCompanies.value;
|
||||
if (selectedCompanies.value.length > 1)
|
||||
filterCriteria.companies_logic = companiesLogic.value;
|
||||
}
|
||||
if (selectedAgeRatings.value && selectedAgeRatings.value.length > 0) {
|
||||
filterCriteria.age_ratings = selectedAgeRatings.value;
|
||||
if (selectedAgeRatings.value.length > 1)
|
||||
filterCriteria.age_ratings_logic = ageRatingsLogic.value;
|
||||
}
|
||||
if (selectedStatuses.value && selectedStatuses.value.length > 0) {
|
||||
const statusKeys = selectedStatuses.value
|
||||
.filter((s): s is string => s !== null)
|
||||
.map((s) => getStatusKeyForText(s))
|
||||
.filter((key) => key !== null);
|
||||
if (statusKeys.length > 0) {
|
||||
filterCriteria.selected_status = statusKeys;
|
||||
}
|
||||
}
|
||||
if (selectedRegions.value && selectedRegions.value.length > 0) {
|
||||
filterCriteria.regions = selectedRegions.value;
|
||||
if (selectedRegions.value.length > 1)
|
||||
filterCriteria.regions_logic = regionsLogic.value;
|
||||
}
|
||||
if (selectedLanguages.value && selectedLanguages.value.length > 0) {
|
||||
filterCriteria.languages = selectedLanguages.value;
|
||||
if (selectedLanguages.value.length > 1)
|
||||
filterCriteria.languages_logic = languagesLogic.value;
|
||||
}
|
||||
|
||||
const { data } = await collectionApi.createSmartCollection({
|
||||
smartCollection: {
|
||||
@@ -217,7 +260,7 @@ async function createSmartCollection() {
|
||||
<v-btn
|
||||
:color="isPublic ? 'romm-green' : 'accent'"
|
||||
variant="outlined"
|
||||
@click="isPublic = !isPublic"
|
||||
@click="toggleCollectionVisibility"
|
||||
>
|
||||
<v-icon class="mr-2">
|
||||
{{ isPublic ? "mdi-lock-open-variant" : "mdi-lock" }}
|
||||
@@ -251,7 +294,7 @@ async function createSmartCollection() {
|
||||
</v-row>
|
||||
</template>
|
||||
<template #footer>
|
||||
<v-row class="justify-center my-2" no-gutters>
|
||||
<v-row class="justify-center pa-2" no-gutters>
|
||||
<v-btn-group divided density="compact">
|
||||
<v-btn class="bg-toplayer" @click="closeDialog">
|
||||
{{ t("common.cancel") }}
|
||||
|
||||
@@ -154,12 +154,6 @@ const {
|
||||
forceBoxart: props.forceBoxart,
|
||||
});
|
||||
|
||||
const hasNotes = computed(() => {
|
||||
// TODO: Add note count to SimpleRom or check all_user_notes
|
||||
// For now, return false until we implement proper note counting
|
||||
return false;
|
||||
});
|
||||
|
||||
const computedAspectRatio = computed(() => {
|
||||
return galleryViewStore.getAspectRatio({
|
||||
platformId: props.rom.platform_id,
|
||||
@@ -389,7 +383,7 @@ onBeforeUnmount(() => {
|
||||
<v-icon>mdi-star</v-icon>
|
||||
</v-chip>
|
||||
<v-chip
|
||||
v-if="hasNotes && showChips"
|
||||
v-if="rom.has_notes && showChips"
|
||||
class="translucent text-white mr-1 mb-1 px-1"
|
||||
density="compact"
|
||||
title="View notes"
|
||||
|
||||
@@ -308,7 +308,7 @@ function handleRomUpdateFromMetadata(updatedRom: UpdateRom) {
|
||||
v-model="rom.fs_name"
|
||||
:rules="[(value: string) => !!value || t('common.required')]"
|
||||
:label="
|
||||
rom.has_multiple_files
|
||||
rom.has_nested_single_file || rom.has_multiple_files
|
||||
? t('rom.folder-name')
|
||||
: t('rom.filename')
|
||||
"
|
||||
|
||||
@@ -1,55 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import { MdPreview } from "md-editor-v3";
|
||||
import "md-editor-v3/lib/style.css";
|
||||
import type { Emitter } from "mitt";
|
||||
import { inject, ref, computed } from "vue";
|
||||
import { inject, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useTheme } from "vuetify";
|
||||
import MultiNoteManager from "@/components/Details/MultiNoteManager.vue";
|
||||
import RDialog from "@/components/common/RDialog.vue";
|
||||
import romApi from "@/services/api/rom";
|
||||
import type { SimpleRom } from "@/stores/roms";
|
||||
import type { DetailedRom } from "@/stores/roms";
|
||||
import type { Events } from "@/types/emitter";
|
||||
import { toBrowserLocale } from "@/utils";
|
||||
|
||||
const theme = useTheme();
|
||||
const emitter = inject<Emitter<Events>>("emitter");
|
||||
const { t, locale } = useI18n();
|
||||
const { t } = useI18n();
|
||||
|
||||
const rom = ref<SimpleRom | null>(null);
|
||||
const rom = ref<DetailedRom | null>(null);
|
||||
const show = ref(false);
|
||||
const notes = ref<any[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
// Computed to get current user notes
|
||||
const currentUserNotes = computed(() => {
|
||||
return notes.value
|
||||
.filter((note) => note.user_id === rom.value?.rom_user?.user_id)
|
||||
.sort((a, b) => a.title.localeCompare(b.title));
|
||||
});
|
||||
|
||||
emitter?.on("showNoteDialog", async (romToShow) => {
|
||||
rom.value = romToShow;
|
||||
show.value = true;
|
||||
|
||||
// Fetch notes for this ROM
|
||||
if (romToShow.id) {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await romApi.getRomNotes({ romId: romToShow.id });
|
||||
notes.value = response.data;
|
||||
const response = await romApi.getRom({ romId: romToShow.id });
|
||||
rom.value = response.data;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch notes:", error);
|
||||
notes.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
console.error("Failed to fetch ROM details:", error);
|
||||
rom.value = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function onNotesUpdated() {
|
||||
if (rom.value?.id) {
|
||||
try {
|
||||
const updatedRom = await romApi.getRom({ romId: rom.value.id });
|
||||
// Update the rom with the new data
|
||||
Object.assign(rom.value, updatedRom.data);
|
||||
} catch (error) {
|
||||
console.error("Failed to refetch ROM data:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
show.value = false;
|
||||
rom.value = null;
|
||||
notes.value = [];
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -68,69 +61,8 @@ function closeDialog() {
|
||||
</v-toolbar-title>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="pa-4">
|
||||
<div v-if="loading" class="text-center py-8">
|
||||
<v-progress-circular indeterminate color="primary" />
|
||||
<p class="text-body-2 mt-2">{{ t("common.loading") }}...</p>
|
||||
</div>
|
||||
<div v-else-if="currentUserNotes.length > 0">
|
||||
<v-expansion-panels multiple flat variant="accordion">
|
||||
<v-expansion-panel
|
||||
v-for="note in currentUserNotes"
|
||||
:key="note.title"
|
||||
:value="note.title"
|
||||
rounded="0"
|
||||
>
|
||||
<v-expansion-panel-title class="bg-toplayer">
|
||||
<div class="d-flex justify-space-between align-center w-100">
|
||||
<span class="text-body-1">{{ note.title }}</span>
|
||||
<div class="d-flex gap-2 align-center mr-4">
|
||||
<v-chip
|
||||
:color="note.is_public ? 'success' : 'warning'"
|
||||
variant="text"
|
||||
class="mr-2"
|
||||
>
|
||||
<v-icon>
|
||||
{{
|
||||
note.is_public ? "mdi-lock-open-variant" : "mdi-lock"
|
||||
}}
|
||||
</v-icon>
|
||||
</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
</v-expansion-panel-title>
|
||||
<v-expansion-panel-text class="bg-surface">
|
||||
<MdPreview
|
||||
no-highlight
|
||||
no-katex
|
||||
no-mermaid
|
||||
:model-value="note.content"
|
||||
:theme="theme.global.name.value === 'dark' ? 'dark' : 'light'"
|
||||
language="en-US"
|
||||
preview-theme="vuepress"
|
||||
code-theme="github"
|
||||
class="py-4 px-6"
|
||||
/>
|
||||
<v-card-subtitle
|
||||
v-if="note.updated_at"
|
||||
class="text-caption mt-2 mb-2"
|
||||
>
|
||||
{{ t("common.last-updated") }}:
|
||||
{{
|
||||
new Date(note.updated_at).toLocaleString(
|
||||
toBrowserLocale(locale),
|
||||
)
|
||||
}}
|
||||
</v-card-subtitle>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
</div>
|
||||
<div v-else class="text-center py-8">
|
||||
<v-icon color="grey" size="64">mdi-note-text-outline</v-icon>
|
||||
<p class="text-h6 text-grey mt-4 mb-2">{{ t("rom.no-notes") }}</p>
|
||||
<p class="text-body-2 text-grey">{{ t("rom.no-notes-desc") }}</p>
|
||||
</div>
|
||||
<div class="pa-2">
|
||||
<MultiNoteManager :rom="rom" @notes-updated="onNotesUpdated" />
|
||||
</div>
|
||||
</template>
|
||||
</RDialog>
|
||||
|
||||
@@ -99,12 +99,6 @@ const HEADERS = [
|
||||
|
||||
const selectedRomIDs = computed(() => selectedRoms.value.map((rom) => rom.id));
|
||||
|
||||
function hasNotes(item: SimpleRom): boolean {
|
||||
// TODO: Add note count to SimpleRom or check all_user_notes
|
||||
// For now, return false until we implement proper note counting
|
||||
return false;
|
||||
}
|
||||
|
||||
function showNoteDialog(event: MouseEvent | KeyboardEvent, item: SimpleRom) {
|
||||
event.preventDefault();
|
||||
emitter?.emit("showNoteDialog", item);
|
||||
@@ -312,7 +306,7 @@ function updateOptions({ sortBy }: { sortBy: SortBy }) {
|
||||
<v-icon>mdi-card-multiple-outline</v-icon>
|
||||
</v-chip>
|
||||
<v-chip
|
||||
v-if="hasNotes(item)"
|
||||
v-if="item.has_notes"
|
||||
class="translucent text-white mr-1 px-1"
|
||||
chip
|
||||
size="x-small"
|
||||
@@ -326,7 +320,7 @@ function updateOptions({ sortBy }: { sortBy: SortBy }) {
|
||||
:text="`Missing from filesystem: ${item.fs_path}/${item.fs_name}`"
|
||||
class="mr-1 px-1 item-chip"
|
||||
chip
|
||||
chip-size="x-small"
|
||||
chip-size="small"
|
||||
/>
|
||||
</template>
|
||||
</v-list-item>
|
||||
|
||||
@@ -66,11 +66,6 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<v-card-text>
|
||||
<v-row class="pa-1 justify-center align-center bg-background">
|
||||
<MissingFromFSIcon
|
||||
v-if="platform.missing_from_fs"
|
||||
text="Missing platform from filesystem"
|
||||
:size="15"
|
||||
/>
|
||||
<div
|
||||
:title="platform.display_name"
|
||||
class="px-2 text-truncate text-caption"
|
||||
@@ -87,6 +82,13 @@ onBeforeUnmount(() => {
|
||||
:size="105"
|
||||
class="mt-2"
|
||||
/>
|
||||
<MissingFromFSIcon
|
||||
v-if="platform.missing_from_fs"
|
||||
text="Missing platform from filesystem"
|
||||
class="position-absolute"
|
||||
style="top: 2.5rem; right: 1rem"
|
||||
:size="22"
|
||||
/>
|
||||
<v-chip
|
||||
class="bg-background position-absolute"
|
||||
size="x-small"
|
||||
|
||||
179
frontend/src/composables/useUISettings.ts
Normal file
179
frontend/src/composables/useUISettings.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { useLocalStorage } from "@vueuse/core";
|
||||
import type { RemovableRef } from "@vueuse/core";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { watch, ref } from "vue";
|
||||
import type { UserSchema } from "@/__generated__";
|
||||
import userApi from "@/services/api/user";
|
||||
import storeAuth from "@/stores/auth";
|
||||
|
||||
export const UI_SETTINGS_KEYS = {
|
||||
// Language
|
||||
locale: { key: "settings.locale", default: "" },
|
||||
|
||||
// Theme
|
||||
theme: { key: "settings.theme", default: "auto" },
|
||||
|
||||
// Home section
|
||||
showStats: { key: "settings.showStats", default: true },
|
||||
showRecentRoms: { key: "settings.showRecentRoms", default: true },
|
||||
showContinuePlaying: { key: "settings.showContinuePlaying", default: true },
|
||||
showPlatforms: { key: "settings.showPlatforms", default: true },
|
||||
showCollections: { key: "settings.showCollections", default: true },
|
||||
|
||||
// Platforms drawer
|
||||
platformsGroupBy: { key: "settings.platformsGroupBy", default: null },
|
||||
|
||||
// Gallery section
|
||||
groupRoms: { key: "settings.groupRoms", default: true },
|
||||
showSiblings: { key: "settings.showSiblings", default: true },
|
||||
showRegions: { key: "settings.showRegions", default: true },
|
||||
showLanguages: { key: "settings.showLanguages", default: true },
|
||||
showStatus: { key: "settings.showStatus", default: true },
|
||||
showActionBar: { key: "settings.showActionBar", default: false },
|
||||
showGameTitle: { key: "settings.showGameTitle", default: false },
|
||||
enable3DEffect: { key: "settings.enable3DEffect", default: false },
|
||||
disableAnimations: { key: "settings.disableAnimations", default: false },
|
||||
enableExperimentalCache: {
|
||||
key: "settings.enableExperimentalCache",
|
||||
default: false,
|
||||
},
|
||||
boxartStyle: { key: "settings.boxartStyle", default: "cover_path" },
|
||||
|
||||
// Virtual collections
|
||||
showVirtualCollections: {
|
||||
key: "settings.showVirtualCollections",
|
||||
default: true,
|
||||
},
|
||||
virtualCollectionType: {
|
||||
key: "settings.virtualCollectionType",
|
||||
default: "collection",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type UISettingsKey = keyof typeof UI_SETTINGS_KEYS;
|
||||
|
||||
// Helper type to extract the default value type for each setting
|
||||
// Widens literal types to their base types (e.g., true -> boolean, "auto" -> string)
|
||||
type WidenLiteral<T> = T extends string
|
||||
? string
|
||||
: T extends number
|
||||
? number
|
||||
: T extends boolean
|
||||
? boolean
|
||||
: T extends null
|
||||
? string | null
|
||||
: T;
|
||||
|
||||
type UISettingDefaultType<K extends UISettingsKey> = WidenLiteral<
|
||||
(typeof UI_SETTINGS_KEYS)[K]["default"]
|
||||
>;
|
||||
|
||||
// Strongly-typed refs for each UI setting
|
||||
type UISettingsRefs = {
|
||||
[K in UISettingsKey]: RemovableRef<UISettingDefaultType<K>>;
|
||||
};
|
||||
|
||||
// Singleton state to prevent multiple instances
|
||||
let uiSettingsInstance: ReturnType<typeof createUISettings> | null = null;
|
||||
const isSyncing = ref(false); // Global flag to prevent sync loops
|
||||
|
||||
function createUISettings() {
|
||||
const authStore = storeAuth();
|
||||
const { user } = storeToRefs(authStore);
|
||||
|
||||
// Get all localStorage refs
|
||||
const localStorageRefs = Object.fromEntries(
|
||||
Object.entries(UI_SETTINGS_KEYS).map(([name, config]) => [
|
||||
name,
|
||||
useLocalStorage(config.key, config.default),
|
||||
]),
|
||||
) as UISettingsRefs;
|
||||
|
||||
// Initialize settings from backend user data
|
||||
function initialize() {
|
||||
const userWithSettings = user.value as UserSchema | null;
|
||||
if (!userWithSettings?.ui_settings) return;
|
||||
|
||||
isSyncing.value = true;
|
||||
const backendSettings = userWithSettings.ui_settings;
|
||||
|
||||
// Sync backend settings to localStorage
|
||||
Object.entries(localStorageRefs).forEach(([key, ref]) => {
|
||||
if (key in backendSettings) {
|
||||
ref.value = backendSettings[key as UISettingsKey];
|
||||
}
|
||||
});
|
||||
|
||||
// Use nextTick-like delay to ensure all updates are done
|
||||
setTimeout(() => {
|
||||
isSyncing.value = false;
|
||||
}, 50);
|
||||
}
|
||||
|
||||
// Collect current settings from localStorage
|
||||
function collectSettings(): Record<string, unknown> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(localStorageRefs).map(([key, ref]) => [key, ref.value]),
|
||||
);
|
||||
}
|
||||
|
||||
const saveUISettings = async () => {
|
||||
if (!user.value || isSyncing.value) return;
|
||||
|
||||
const currentSettings = collectSettings();
|
||||
|
||||
try {
|
||||
const { data } = await userApi.updateUser({
|
||||
id: user.value.id,
|
||||
ui_settings: JSON.stringify(currentSettings),
|
||||
} as Partial<UserSchema> & { ui_settings: string });
|
||||
|
||||
// Update the user in the store with the new settings (without triggering sync)
|
||||
isSyncing.value = true;
|
||||
const dataWithSettings = data as UserSchema;
|
||||
if (dataWithSettings.ui_settings) {
|
||||
authStore.setCurrentUser(data);
|
||||
}
|
||||
setTimeout(() => {
|
||||
isSyncing.value = false;
|
||||
}, 50);
|
||||
} catch (error) {
|
||||
console.error("Failed to save UI settings to backend:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Watch all localStorage refs for changes (only set up once)
|
||||
Object.values(localStorageRefs).forEach((ref) => {
|
||||
watch(ref, () => {
|
||||
if (!isSyncing.value) {
|
||||
saveUISettings();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Watch user changes to initialize settings
|
||||
watch(
|
||||
user,
|
||||
(newUser) => {
|
||||
const userWithSettings = newUser as UserSchema | null;
|
||||
if (userWithSettings?.ui_settings) {
|
||||
initialize();
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
return {
|
||||
...localStorageRefs,
|
||||
initialize: initialize,
|
||||
saveUISettings: () => saveUISettings(),
|
||||
};
|
||||
}
|
||||
|
||||
export function useUISettings() {
|
||||
// Return singleton instance
|
||||
if (!uiSettingsInstance) {
|
||||
uiSettingsInstance = createUISettings();
|
||||
}
|
||||
return uiSettingsInstance;
|
||||
}
|
||||
@@ -39,6 +39,21 @@
|
||||
"show-ra": "Zobrazit Retroachievements",
|
||||
"show-unmatched": "Zobrazit neshody",
|
||||
"show-verified": "Zobrazit ověřené",
|
||||
"show-verified-only": "Zobrazit pouze ověřené ROM",
|
||||
"show-not-verified-only": "Zobrazit pouze neověřené ROM",
|
||||
"show-duplicates-only": "Zobrazit pouze duplicitní ROM",
|
||||
"show-not-duplicates-only": "Zobrazit pouze neduplicitní ROM",
|
||||
"show-favorites-only": "Zobrazit pouze oblíbené ROM",
|
||||
"show-not-favorites-only": "Zobrazit pouze neoblíbené ROM",
|
||||
"show-missing-only": "Zobrazit pouze chybějící ROM",
|
||||
"show-not-missing-only": "Zobrazit pouze nechybějící ROM",
|
||||
"missing-from-filesystem": "Platforma chybí v systému souborů",
|
||||
"show-playables-only": "Zobrazit pouze spustitelné ROM",
|
||||
"show-not-playables-only": "Zobrazit pouze nespustitelné ROM",
|
||||
"show-ra-only": "Zobrazit pouze ROM s RetroAchievements",
|
||||
"show-not-ra-only": "Zobrazit pouze ROM bez RetroAchievements",
|
||||
"match-any-logic": "Odpovídá KTERÉKOLI z vybraných hodnot (logika NEBO)",
|
||||
"match-all-logic": "Odpovídá VŠEM vybraným hodnotám (logika A)",
|
||||
"status": "Stav",
|
||||
"upload-roms": "Nahrát ROMy"
|
||||
}
|
||||
|
||||
@@ -39,6 +39,21 @@
|
||||
"show-ra": "Zeige Retroachievements",
|
||||
"show-missing": "Zeige fehlende",
|
||||
"show-verified": "Zeige verifiziert",
|
||||
"show-verified-only": "Zeige nur verifizierte ROMs",
|
||||
"show-not-verified-only": "Zeige nur nicht verifizierte ROMs",
|
||||
"show-duplicates-only": "Zeige nur doppelte ROMs",
|
||||
"show-not-duplicates-only": "Zeige nur nicht doppelte ROMs",
|
||||
"show-favorites-only": "Zeige nur Favoriten-ROMs",
|
||||
"show-not-favorites-only": "Zeige nur Nicht-Favoriten-ROMs",
|
||||
"show-missing-only": "Zeige nur fehlende ROMs",
|
||||
"show-not-missing-only": "Zeige nur nicht fehlende ROMs",
|
||||
"missing-from-filesystem": "Plattform fehlt im Dateisystem",
|
||||
"show-playables-only": "Zeige nur spielbare ROMs",
|
||||
"show-not-playables-only": "Zeige nur nicht spielbare ROMs",
|
||||
"show-ra-only": "Zeige nur ROMs mit RetroAchievements",
|
||||
"show-not-ra-only": "Zeige nur ROMs ohne RetroAchievements",
|
||||
"match-any-logic": "Entspricht EINEM der ausgewählten Werte (ODER-Logik)",
|
||||
"match-all-logic": "Entspricht ALLEN ausgewählten Werten (UND-Logik)",
|
||||
"status": "Status",
|
||||
"upload-roms": "Roms hochladen"
|
||||
}
|
||||
|
||||
@@ -57,5 +57,9 @@
|
||||
"username-length": "Username must be between 3 and 255 characters",
|
||||
"virtual-collection": "Autogenerated collection",
|
||||
"virtual-collections": "Autogenerated collections",
|
||||
"warning": "WARNING:"
|
||||
"warning": "WARNING:",
|
||||
"filter": {
|
||||
"any": "Any",
|
||||
"all": "All"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,21 @@
|
||||
"show-ra": "Show Retroachievements",
|
||||
"show-unmatched": "Show unmatched",
|
||||
"show-verified": "Show verified",
|
||||
"show-verified-only": "Show verified ROMs only",
|
||||
"show-not-verified-only": "Show non-verified ROMs only",
|
||||
"show-duplicates-only": "Show duplicate ROMs only",
|
||||
"show-not-duplicates-only": "Show non-duplicate ROMs only",
|
||||
"show-favorites-only": "Show favourite ROMs only",
|
||||
"show-not-favorites-only": "Show non-favourite ROMs only",
|
||||
"show-missing-only": "Show missing ROMs only",
|
||||
"show-not-missing-only": "Show non-missing ROMs only",
|
||||
"missing-from-filesystem": "Missing platform from filesystem",
|
||||
"show-playables-only": "Show playable ROMs only",
|
||||
"show-not-playables-only": "Show non-playable ROMs only",
|
||||
"show-ra-only": "Show ROMs with RetroAchievements only",
|
||||
"show-not-ra-only": "Show ROMs without RetroAchievements only",
|
||||
"match-any-logic": "Match ANY of the selected values (OR logic)",
|
||||
"match-all-logic": "Match ALL of the selected values (AND logic)",
|
||||
"status": "Status",
|
||||
"upload-roms": "Upload Roms"
|
||||
}
|
||||
|
||||
@@ -39,6 +39,21 @@
|
||||
"show-ra": "Show Retroachievements",
|
||||
"show-unmatched": "Show unmatched",
|
||||
"show-verified": "Show verified",
|
||||
"show-verified-only": "Show verified ROMs only",
|
||||
"show-not-verified-only": "Show non-verified ROMs only",
|
||||
"show-duplicates-only": "Show duplicate ROMs only",
|
||||
"show-not-duplicates-only": "Show non-duplicate ROMs only",
|
||||
"show-favorites-only": "Show favourite ROMs only",
|
||||
"show-not-favorites-only": "Show non-favourite ROMs only",
|
||||
"show-missing-only": "Show missing ROMs only",
|
||||
"show-not-missing-only": "Show non-missing ROMs only",
|
||||
"missing-from-filesystem": "Missing platform from filesystem",
|
||||
"show-playables-only": "Show playable ROMs only",
|
||||
"show-not-playables-only": "Show non-playable ROMs only",
|
||||
"show-ra-only": "Show ROMs with RetroAchievements only",
|
||||
"show-not-ra-only": "Show ROMs without RetroAchievements only",
|
||||
"match-any-logic": "Match ANY of the selected values (OR logic)",
|
||||
"match-all-logic": "Match ALL of the selected values (AND logic)",
|
||||
"status": "Status",
|
||||
"upload-roms": "Upload Roms"
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user