Merge remote-tracking branch 'origin/master' into romm-1371

This commit is contained in:
zurdi
2026-01-02 11:12:36 +00:00
124 changed files with 3186 additions and 1277 deletions

View File

@@ -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

View File

@@ -1,4 +1,4 @@
"""empty message
"""Refactor platforms and roms tables
Revision ID: 0009_models_refactor
Revises: 2.0.0

View File

@@ -1,4 +1,4 @@
"""empty message
"""Change igdb_id and sgdb_id to integer
Revision ID: 0010_igdb_id_integerr
Revises: 0009_models_refactor

View File

@@ -1,4 +1,4 @@
"""empty message
"""Remove has_cover column from roms table
Revision ID: 0011_drop_has_cover
Revises: 0010_igdb_id_integerr

View File

@@ -1,4 +1,4 @@
"""empty message
"""Add support for multiple regions and languages
Revision ID: 0012_add_regions_languages
Revises: 0011_drop_has_cover

View File

@@ -1,4 +1,4 @@
"""empty message
"""Increase length of file_extension column
Revision ID: 0013_upgrade_file_extension
Revises: 0012_add_regions_languages

View File

@@ -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

View File

@@ -1,4 +1,4 @@
"""empty message
"""Add MobyGames data
Revision ID: 0015_mobygames_data
Revises: 0014_asset_files

View File

@@ -1,4 +1,4 @@
"""empty message
"""Track user last login and active times
Revision ID: 0016_user_last_login_active
Revises: 0015_mobygames_data

View File

@@ -1,4 +1,4 @@
"""empty message
"""Create rom_notes table
Revision ID: 0017_rom_notes
Revises: 0016_user_last_login_active

View File

@@ -1,4 +1,4 @@
"""empty message
"""Create firmware table
Revision ID: 0018_firmware
Revises: 0017_rom_notes

View File

@@ -1,4 +1,4 @@
"""empty message
"""Refactor resource storage
Revision ID: 0019_resources_refactor
Revises: 0018_firmware

View File

@@ -1,4 +1,4 @@
"""empty message
"""Add created_at and updated_at columns
Revision ID: 0020_created_and_updated
Revises: 0019_resources_refactor

View File

@@ -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

View File

@@ -1,4 +1,4 @@
"""empty message
"""Migrate resources and create collections table
Revision ID: 0022_collections
Revises: 0021_rom_user

View File

@@ -1,4 +1,4 @@
"""empty message
"""Create sibling_roms view
Revision ID: 0024_sibling_roms_db_view
Revises: 0023_make_columns_non_nullable

View File

@@ -1,4 +1,4 @@
"""empty message
"""Add hashes to roms
Revision ID: 0025_roms_hashes
Revises: 0024_sibling_roms_db_view

View File

@@ -1,4 +1,4 @@
"""empty message
"""Add status fields to rom_user table
Revision ID: 0026_romuser_status_fields
Revises: 0025_roms_hashes

View File

@@ -1,4 +1,4 @@
"""empty message
"""Add email to users table
Revision ID: 0028_user_email
Revises: 0027_platforms_data

View File

@@ -1,4 +1,4 @@
"""empty message
"""Change DateTime columns to TIMESTAMP
Revision ID: 0031_datetime_to_timestamp
Revises: 0030_user_email_null

View File

@@ -1,4 +1,4 @@
"""empty message
"""Increase length of platform fields
Revision ID: 0032_longer_fs_fields
Revises: 0031_datetime_to_timestamp

View File

@@ -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

View File

@@ -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

View File

@@ -1,4 +1,4 @@
"""empty message
"""Add ScreenScraper data
Revision ID: 0035_screenscraper
Revises: 0034_virtual_collections_db_view

View File

@@ -1,4 +1,4 @@
"""empty message
"""Populate ScreenScraper platform IDs
Revision ID: 0036_screenscraper_platforms_id
Revises: 0035_screenscraper

View File

@@ -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

View File

@@ -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

View File

@@ -1,4 +1,4 @@
"""empty message
"""Use higher resolution images for assets
Revision ID: 0041_assets_t_thumb_cleanup
Revises: 0040_migrate_assets_paths

View File

@@ -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

View File

@@ -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

View File

@@ -1,4 +1,4 @@
"""empty message
"""Add Hasheous and TGDB data
Revision ID: 0044_hasheous_id
Revises: 0043_launchbox_id

View File

@@ -1,4 +1,4 @@
"""empty message
"""Create or update roms_metadata view
Revision ID: 0045_roms_metadata_update
Revises: 0044_hasheous_id

View File

@@ -1,4 +1,4 @@
"""empty message
"""Standardize platform slugs
Revision ID: 0046_migrate_platform_slugs
Revises: 0045_roms_metadata_update

View File

@@ -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

View File

@@ -1,4 +1,4 @@
"""empty message
"""Add Flashpoint metadata
Revision ID: 0051_flashpoint_metadata
Revises: 0050_firmware_add_is_verified

View File

@@ -1,4 +1,4 @@
"""empty message
"""Update roms_metadata view to include flashpoint metadata
Revision ID: 0052_roms_metadata_flashpoint
Revises: 0051_flashpoint_metadata

View File

@@ -1,4 +1,4 @@
"""empty message
"""Add metadata slugs to platform
Revision ID: 0054_add_platform_metadata_slugs
Revises: 0053_add_hltb_metadata

View File

@@ -1,4 +1,4 @@
"""empty message
"""Add is_favorite to collections
Revision ID: 0055_collection_is_favorite
Revises: 0054_add_platform_metadata_slugs

View File

@@ -1,4 +1,4 @@
"""empty message
"""Add launchbox to roms_metadata view
Revision ID: 0058_roms_metadata_launchbox
Revises: 0057_multi_notes

View 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")

View 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")

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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)

View File

@@ -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,
)

View File

@@ -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,
}
)

View File

@@ -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]

View File

@@ -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"),

View File

@@ -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()

View File

@@ -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(

View File

@@ -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"

View File

@@ -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

View File

@@ -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

View File

@@ -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",

View File

@@ -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

View File

@@ -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")

View File

@@ -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
}

View File

@@ -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

View File

@@ -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:

View File

@@ -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:

View File

@@ -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(

View File

@@ -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:

View File

@@ -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")

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 108 KiB

View File

@@ -81,5 +81,6 @@ export type SimpleRomSchema = {
rom_user: RomUserSchema;
merged_screenshots: Array<string>;
merged_ra_metadata: (RomRAMetadata | null);
has_notes?: boolean;
};

View File

@@ -10,5 +10,6 @@ export type UserForm = {
enabled?: (boolean | null);
ra_username?: (string | null);
avatar?: (Blob | null);
ui_settings?: (string | null);
};

View File

@@ -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;
};

View File

@@ -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"

View File

@@ -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

View File

@@ -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"

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;

View File

@@ -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;

View File

@@ -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<{

View File

@@ -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"

View File

@@ -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") }}

View File

@@ -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"

View File

@@ -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')
"

View File

@@ -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>

View File

@@ -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>

View File

@@ -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"

View 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;
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}
}

View File

@@ -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"
}

View File

@@ -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