From 5a1f7d8170a97be819fd9857f8bf9932f696b480 Mon Sep 17 00:00:00 2001 From: Michael Manganiello Date: Tue, 9 Sep 2025 00:19:03 -0300 Subject: [PATCH 01/73] feat: Initial support for multi-value ROM filters This change converts the existing single-value ROM filter parameters to support multiple values. Users can now filter ROMs by multiple genres, franchises, collections, companies, age ratings, regions, and languages by repeating the respective query parameters in API requests. At the moment, setting multiple values for a filter will return ROMs that match any of the provided values (logical OR). A future change can introduce parameters like `genres_all (boolean)` to allow filtering ROMs that match all specified values (logical AND). The frontend has been updated to send single-value filters as arrays to maintain compatibility with existing UI components. At the moment, the UI does not support selecting multiple values, but this change lays the groundwork for it. NOTE: Breaking API change, as the filter parameters have changed names. --- backend/endpoints/rom.py | 105 ++++++++--- .../handler/database/collections_handler.py | 45 ++++- backend/handler/database/roms_handler.py | 177 +++++++++++------- .../Dialog/CreateSmartCollection.vue | 16 +- frontend/src/services/api/rom.ts | 14 +- 5 files changed, 234 insertions(+), 123 deletions(-) diff --git a/backend/endpoints/rom.py b/backend/endpoints/rom.py index 0c6e31cf4..f51caf94a 100644 --- a/backend/endpoints/rom.py +++ b/backend/endpoints/rom.py @@ -226,37 +226,86 @@ 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." + ), + alias="genre", + ), ] = 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." + ), + alias="franchise", + ), ] = 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." + ), + alias="collection", + ), ] = 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." + ), + alias="company", + ), ] = 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." + ), + alias="age_rating", + ), ] = None, selected_status: Annotated[ str | None, Query(description="Game status, set by the current user."), ] = 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." + ), + alias="region", + ), ] = 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." + ), + alias="language", + ), ] = None, order_by: Annotated[ str, @@ -292,14 +341,14 @@ def get_roms( 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, + genres=genres, + franchises=franchises, + collections=collections, + companies=companies, + age_ratings=age_ratings, selected_status=selected_status, - selected_region=selected_region, - selected_language=selected_language, + regions=regions, + languages=languages, group_by_meta_id=group_by_meta_id, ) diff --git a/backend/handler/database/collections_handler.py b/backend/handler/database/collections_handler.py index 6c9264808..1c22ab4a6 100644 --- a/backend/handler/database/collections_handler.py +++ b/backend/handler/database/collections_handler.py @@ -204,6 +204,37 @@ class DBCollectionsHandler(DBBaseHandler): # Extract filter criteria criteria = smart_collection.filter_criteria + # Backwards compatibility with old criteria names + genres = criteria.get("genres") or ( + [criteria["selected_genre"]] if criteria.get("selected_genre") else None + ) + franchises = criteria.get("franchises") or ( + [criteria["selected_franchise"]] + if criteria.get("selected_franchise") + else None + ) + collections = criteria.get("collections") or ( + [criteria["selected_collection"]] + if criteria.get("selected_collection") + else None + ) + companies = criteria.get("companies") or ( + [criteria["selected_company"]] if criteria.get("selected_company") else None + ) + age_ratings = criteria.get("age_ratings") or ( + [criteria["selected_age_rating"]] + if criteria.get("selected_age_rating") + else None + ) + regions = criteria.get("regions") or ( + [criteria["selected_region"]] if criteria.get("selected_region") else None + ) + languages = criteria.get("languages") or ( + [criteria["selected_language"]] + if criteria.get("selected_language") + else None + ) + # Use the existing filter_roms method with the stored criteria return db_rom_handler.get_roms_scalar( platform_id=criteria.get("platform_id"), @@ -217,14 +248,14 @@ 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"), + genres=genres, + franchises=franchises, + collections=collections, + companies=companies, + age_ratings=age_ratings, selected_status=criteria.get("selected_status"), - selected_region=criteria.get("selected_region"), - selected_language=criteria.get("selected_language"), + regions=regions, + languages=languages, user_id=user_id, order_by=criteria.get("order_by", "name"), order_dir=criteria.get("order_dir", "asc"), diff --git a/backend/handler/database/roms_handler.py b/backend/handler/database/roms_handler.py index ee85dd738..21c1ed807 100644 --- a/backend/handler/database/roms_handler.py +++ b/backend/handler/database/roms_handler.py @@ -29,7 +29,10 @@ from handler.metadata.base_hander import UniversalPlatformSlug as UPS from models.assets import Save, Screenshot, State from models.platform import Platform from models.rom import Rom, RomFile, RomMetadata, RomUser -from utils.database import json_array_contains_value +from utils.database import ( + json_array_contains_all, + json_array_contains_any, +) from .base_handler import DBBaseHandler @@ -306,30 +309,60 @@ 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 @@ -345,15 +378,27 @@ class DBRomsHandler(DBBaseHandler): return query.filter(status_filter, RomUser.hidden.is_(False)) - def filter_by_region(self, query: Query, session: Session, value: str) -> Query: - return query.filter( - json_array_contains_value(Rom.regions, 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_language(self, query: Query, session: Session, value: str) -> Query: - return query.filter( - json_array_contains_value(Rom.languages, value, 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( @@ -372,14 +417,14 @@ class DBRomsHandler(DBBaseHandler): 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, + 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_status: str | None = None, - selected_region: str | None = None, - selected_language: str | None = None, + regions: Sequence[str] | None = None, + languages: Sequence[str] | None = None, user_id: int | None = None, session: Session = None, ) -> Query[Rom]: @@ -519,39 +564,27 @@ class DBRomsHandler(DBBaseHandler): ) ) - if ( - selected_genre - or selected_franchise - or selected_collection - or selected_company - or selected_age_rating - ): + if genres or franchises or collections or companies or age_ratings: 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 genres: + query = self.filter_by_genres(query, session=session, values=genres) + if franchises: + query = self.filter_by_franchises(query, session=session, values=franchises) + if collections: + query = self.filter_by_collections( + query, session=session, values=collections ) - 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 + if companies: + query = self.filter_by_companies(query, session=session, values=companies) + if age_ratings: + query = self.filter_by_age_ratings( + query, session=session, values=age_ratings ) + if regions: + query = self.filter_by_regions(query, session=session, values=regions) + if languages: + query = self.filter_by_languages(query, session=session, values=languages) # The RomUser table is already joined if user_id is set if selected_status and user_id: @@ -633,14 +666,14 @@ class DBRomsHandler(DBBaseHandler): 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), + 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_status=kwargs.get("selected_status", None), - selected_region=kwargs.get("selected_region", None), - selected_language=kwargs.get("selected_language", None), + regions=kwargs.get("regions", None), + languages=kwargs.get("languages", None), user_id=kwargs.get("user_id", None), ) return session.scalars(roms).all() diff --git a/frontend/src/components/common/Collection/Dialog/CreateSmartCollection.vue b/frontend/src/components/common/Collection/Dialog/CreateSmartCollection.vue index 64003c512..167ada3a2 100644 --- a/frontend/src/components/common/Collection/Dialog/CreateSmartCollection.vue +++ b/frontend/src/components/common/Collection/Dialog/CreateSmartCollection.vue @@ -123,24 +123,22 @@ async function createSmartCollection() { 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 (selectedGenre.value) filterCriteria.genres = [selectedGenre.value]; if (selectedFranchise.value) - filterCriteria.selected_franchise = selectedFranchise.value; + filterCriteria.franchises = [selectedFranchise.value]; if (selectedCollection.value) - filterCriteria.selected_collection = selectedCollection.value; + filterCriteria.collections = [selectedCollection.value]; if (selectedCompany.value) - filterCriteria.selected_company = selectedCompany.value; + filterCriteria.companies = [selectedCompany.value]; if (selectedAgeRating.value) - filterCriteria.selected_age_rating = selectedAgeRating.value; + filterCriteria.age_ratings = [selectedAgeRating.value]; if (selectedStatus.value) filterCriteria.selected_status = getStatusKeyForText( selectedStatus.value, ); - if (selectedRegion.value) - filterCriteria.selected_region = selectedRegion.value; + if (selectedRegion.value) filterCriteria.regions = [selectedRegion.value]; if (selectedLanguage.value) - filterCriteria.selected_language = selectedLanguage.value; + filterCriteria.languages = [selectedLanguage.value]; const { data } = await collectionApi.createSmartCollection({ smartCollection: { diff --git a/frontend/src/services/api/rom.ts b/frontend/src/services/api/rom.ts index a86649eb8..14e62efa3 100644 --- a/frontend/src/services/api/rom.ts +++ b/frontend/src/services/api/rom.ts @@ -130,14 +130,14 @@ async function getRoms({ order_by: orderBy, order_dir: orderDir, group_by_meta_id: groupByMetaId, - selected_genre: selectedGenre, - selected_franchise: selectedFranchise, - selected_collection: selectedCollection, - selected_company: selectedCompany, - selected_age_rating: selectedAgeRating, + genre: selectedGenre, + franchise: selectedFranchise, + collection: selectedCollection, + company: selectedCompany, + age_rating: selectedAgeRating, selected_status: getStatusKeyForText(selectedStatus), - selected_region: selectedRegion, - selected_language: selectedLanguage, + region: selectedRegion, + language: selectedLanguage, ...(filterUnmatched ? { matched: false } : {}), ...(filterMatched ? { matched: true } : {}), ...(filterFavourites ? { favourite: true } : {}), From 91e3dfb1bbe735e0694b94b27aa4c67bfc7f3637 Mon Sep 17 00:00:00 2001 From: zurdi Date: Fri, 21 Nov 2025 14:20:10 +0000 Subject: [PATCH 02/73] feat: add RomNote import to roms_handler for enhanced functionality --- backend/handler/database/roms_handler.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/handler/database/roms_handler.py b/backend/handler/database/roms_handler.py index dfd3c2017..2e7b892c8 100644 --- a/backend/handler/database/roms_handler.py +++ b/backend/handler/database/roms_handler.py @@ -28,11 +28,11 @@ from decorators.database import begin_session 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, RomUser +from models.rom import Rom, RomFile, RomMetadata, RomNote, RomUser from utils.database import ( json_array_contains_all, json_array_contains_any, - json_array_contains_value + json_array_contains_value, ) from .base_handler import DBBaseHandler From c6717ee6356ae78de191874d2c059271339da2ed Mon Sep 17 00:00:00 2001 From: zurdi Date: Thu, 27 Nov 2025 12:56:01 +0000 Subject: [PATCH 03/73] Refactor gallery filter components to use toggle buttons for filter states, allowing null values for all filters. Update filter logic in the store and API services to accommodate new states. Enhance UI for better visibility and interaction with filter options in the gallery app. --- backend/endpoints/rom.py | 59 +++++- .../handler/database/collections_handler.py | 47 ++--- backend/handler/database/roms_handler.py | 10 +- backend/utils/database.py | 10 +- .../components/Details/MultiNoteManager.vue | 28 ++- .../AppBar/common/FilterDrawer/Base.vue | 11 +- .../FilterDrawer/FilterDuplicatesBtn.vue | 91 +++++++-- .../FilterDrawer/FilterFavoritesBtn.vue | 74 +++++-- .../FilterDrawer/FilterMatchStateBtn.vue | 86 ++++++++ .../common/FilterDrawer/FilterMatchedBtn.vue | 35 ---- .../common/FilterDrawer/FilterMissingBtn.vue | 75 +++++-- .../FilterDrawer/FilterPlayablesBtn.vue | 88 +++++++-- .../common/FilterDrawer/FilterRaBtn.vue | 89 +++++++-- .../FilterDrawer/FilterUnmatchedBtn.vue | 36 ---- .../common/FilterDrawer/FilterVerifiedBtn.vue | 75 +++++-- .../Dialog/CreateSmartCollection.vue | 23 ++- frontend/src/services/api/rom.ts | 24 +-- frontend/src/services/cache/api.ts | 22 ++- frontend/src/stores/galleryFilter.ts | 184 ++++++++++++++---- 19 files changed, 777 insertions(+), 290 deletions(-) create mode 100644 frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterMatchStateBtn.vue delete mode 100644 frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterMatchedBtn.vue delete mode 100644 frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterUnmatchedBtn.vue diff --git a/backend/endpoints/rom.py b/backend/endpoints/rom.py index 1a9218d8b..0ff26dfee 100644 --- a/backend/endpoints/rom.py +++ b/backend/endpoints/rom.py @@ -1,5 +1,6 @@ import binascii import json +import logging from base64 import b64encode from datetime import datetime, timezone from io import BytesIO @@ -258,9 +259,10 @@ def get_roms( description=( "Associated genre. Multiple values are allowed by repeating the" " parameter, and results that match any of the values will be" - " returned." + " returned. Maximum 50 values allowed." ), alias="genre", + max_length=50, ), ] = None, franchises: Annotated[ @@ -269,9 +271,10 @@ def get_roms( description=( "Associated franchise. Multiple values are allowed by repeating" " the parameter, and results that match any of the values will" - " be returned." + " be returned. Maximum 50 values allowed." ), alias="franchise", + max_length=50, ), ] = None, collections: Annotated[ @@ -280,9 +283,10 @@ def get_roms( description=( "Associated collection. Multiple values are allowed by" " repeating the parameter, and results that match any of the" - " values will be returned." + " values will be returned. Maximum 50 values allowed." ), alias="collection", + max_length=50, ), ] = None, companies: Annotated[ @@ -291,9 +295,10 @@ def get_roms( description=( "Associated company. Multiple values are allowed by repeating" " the parameter, and results that match any of the values will" - " be returned." + " be returned. Maximum 50 values allowed." ), alias="company", + max_length=50, ), ] = None, age_ratings: Annotated[ @@ -302,9 +307,10 @@ def get_roms( description=( "Associated age rating. Multiple values are allowed by" " repeating the parameter, and results that match any of the" - " values will be returned." + " values will be returned. Maximum 20 values allowed." ), alias="age_rating", + max_length=20, ), ] = None, selected_status: Annotated[ @@ -317,9 +323,10 @@ def get_roms( description=( "Associated region tag. Multiple values are allowed by" " repeating the parameter, and results that match any of the" - " values will be returned." + " values will be returned. Maximum 30 values allowed." ), alias="region", + max_length=30, ), ] = None, languages: Annotated[ @@ -328,9 +335,10 @@ def get_roms( description=( "Associated language tag. Multiple values are allowed by" " repeating the parameter, and results that match any of the" - " values will be returned." + " values will be returned. Maximum 30 values allowed." ), alias="language", + max_length=30, ), ] = None, order_by: Annotated[ @@ -344,6 +352,43 @@ def get_roms( ) -> CustomLimitOffsetPage[SimpleRomSchema]: """Retrieve roms.""" + # Sanitize and validate filter arrays + def sanitize_filter_array( + values: list[str] | None, max_length: int = 50, filter_name: str = "" + ) -> list[str] | None: + """Sanitize filter array by removing empty strings and limiting size.""" + if not values: + return None + + try: + # Remove empty/whitespace-only values and strip whitespace + sanitized = [ + v.strip() for v in values if v and isinstance(v, str) and v.strip() + ] + if not sanitized: + return None + + # Limit array size to prevent abuse + if len(sanitized) > max_length: + logging.warning( + f"Filter array '{filter_name}' truncated from {len(sanitized)} to {max_length} items" + ) + sanitized = sanitized[:max_length] + + return sanitized + except Exception as e: + logging.error(f"Error sanitizing filter array '{filter_name}': {e}") + return None + + # Apply sanitization with specific limits and names + genres = sanitize_filter_array(genres, 50, "genres") + franchises = sanitize_filter_array(franchises, 50, "franchises") + collections = sanitize_filter_array(collections, 50, "collections") + companies = sanitize_filter_array(companies, 50, "companies") + age_ratings = sanitize_filter_array(age_ratings, 20, "age_ratings") + regions = sanitize_filter_array(regions, 30, "regions") + languages = sanitize_filter_array(languages, 30, "languages") + # Get the base roms query query, order_by_attr = db_rom_handler.get_roms_query( user_id=request.user.id, diff --git a/backend/handler/database/collections_handler.py b/backend/handler/database/collections_handler.py index c5fefe822..974e87279 100644 --- a/backend/handler/database/collections_handler.py +++ b/backend/handler/database/collections_handler.py @@ -213,36 +213,23 @@ class DBCollectionsHandler(DBBaseHandler): # Extract filter criteria criteria = smart_collection.filter_criteria - # Backwards compatibility with old criteria names - genres = criteria.get("genres") or ( - [criteria["selected_genre"]] if criteria.get("selected_genre") else None - ) - franchises = criteria.get("franchises") or ( - [criteria["selected_franchise"]] - if criteria.get("selected_franchise") - else None - ) - collections = criteria.get("collections") or ( - [criteria["selected_collection"]] - if criteria.get("selected_collection") - else None - ) - companies = criteria.get("companies") or ( - [criteria["selected_company"]] if criteria.get("selected_company") else None - ) - age_ratings = criteria.get("age_ratings") or ( - [criteria["selected_age_rating"]] - if criteria.get("selected_age_rating") - else None - ) - regions = criteria.get("regions") or ( - [criteria["selected_region"]] if criteria.get("selected_region") else None - ) - languages = criteria.get("languages") or ( - [criteria["selected_language"]] - if criteria.get("selected_language") - else None - ) + # 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 return db_rom_handler.get_roms_scalar( diff --git a/backend/handler/database/roms_handler.py b/backend/handler/database/roms_handler.py index f971f147e..cd57f6ec7 100644 --- a/backend/handler/database/roms_handler.py +++ b/backend/handler/database/roms_handler.py @@ -587,9 +587,15 @@ class DBRomsHandler(DBBaseHandler): ) ) - if genres or franchises or collections or companies or age_ratings: + # 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) + # Apply metadata filters efficiently if genres: query = self.filter_by_genres(query, session=session, values=genres) if franchises: @@ -604,6 +610,8 @@ class DBRomsHandler(DBBaseHandler): query = self.filter_by_age_ratings( query, session=session, values=age_ratings ) + + # Apply rom-level filters if regions: query = self.filter_by_regions(query, session=session, values=regions) if languages: diff --git a/backend/utils/database.py b/backend/utils/database.py index d923f8ba8..e1dcfa948 100644 --- a/backend/utils/database.py +++ b/backend/utils/database.py @@ -44,7 +44,7 @@ def is_mariadb(conn: sa.Connection, min_version: tuple[int, ...] | None = None) def json_array_contains_value( - column: sa.Column, value: str | int, *, session: Session + column: sa.Column | Any, value: str | int, *, session: Session ) -> ColumnElement: """Check if a JSON array column contains the given value.""" conn = session.get_bind() @@ -66,12 +66,16 @@ def json_array_contains_value( def json_array_contains_any( - column: sa.Column, values: Sequence[str] | Sequence[int], *, session: Session + column: sa.Column | Any, values: Sequence[str] | Sequence[int], *, session: Session ) -> ColumnElement: """Check if a JSON array column contains any of the given values.""" if not values: return sa.false() + # Optimize for single value case + if len(values) == 1: + return json_array_contains_value(column, values[0], session=session) + conn = session.get_bind() if is_postgresql(conn): # In PostgreSQL, string arrays can be checked for overlap using the `?|` operator. @@ -98,7 +102,7 @@ def json_array_contains_any( def json_array_contains_all( - column: sa.Column, values: Sequence[Any], *, session: Session + column: sa.Column | Any, values: Sequence[Any], *, session: Session ) -> ColumnElement: """Check if a JSON array column contains all of the given values.""" if not values: diff --git a/frontend/src/components/Details/MultiNoteManager.vue b/frontend/src/components/Details/MultiNoteManager.vue index a716a7fb1..5263d8fb2 100644 --- a/frontend/src/components/Details/MultiNoteManager.vue +++ b/frontend/src/components/Details/MultiNoteManager.vue @@ -499,15 +499,25 @@ watch( diff --git a/frontend/src/components/Gallery/AppBar/common/FilterDrawer/Base.vue b/frontend/src/components/Gallery/AppBar/common/FilterDrawer/Base.vue index f727b7d0c..cd33af854 100644 --- a/frontend/src/components/Gallery/AppBar/common/FilterDrawer/Base.vue +++ b/frontend/src/components/Gallery/AppBar/common/FilterDrawer/Base.vue @@ -9,11 +9,10 @@ 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 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"; @@ -317,7 +316,7 @@ onMounted(async () => { v-model="activeFilterDrawer" mobile floating - width="400" + width="500" :class="{ 'ml-2': activeFilterDrawer, 'drawer-mobile': smAndDown && activeFilterDrawer, @@ -331,11 +330,7 @@ onMounted(async () => { - - + 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,81 @@ const galleryFilterStore = storeGalleryFilter(); const { fetchTotalRoms } = storeToRefs(romsStore); const { filterDuplicates } = storeToRefs(galleryFilterStore); const emitter = inject>("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); } diff --git a/frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterFavoritesBtn.vue b/frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterFavoritesBtn.vue index 0e505bbd7..1c8f8afc1 100644 --- a/frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterFavoritesBtn.vue +++ b/frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterFavoritesBtn.vue @@ -1,6 +1,7 @@ diff --git a/frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterMatchStateBtn.vue b/frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterMatchStateBtn.vue new file mode 100644 index 000000000..2ad73284c --- /dev/null +++ b/frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterMatchStateBtn.vue @@ -0,0 +1,86 @@ + + + diff --git a/frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterMatchedBtn.vue b/frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterMatchedBtn.vue deleted file mode 100644 index 660a0aecf..000000000 --- a/frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterMatchedBtn.vue +++ /dev/null @@ -1,35 +0,0 @@ - - - diff --git a/frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterMissingBtn.vue b/frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterMissingBtn.vue index bf5ce35eb..8224b1414 100644 --- a/frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterMissingBtn.vue +++ b/frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterMissingBtn.vue @@ -1,6 +1,7 @@ diff --git a/frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterPlayablesBtn.vue b/frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterPlayablesBtn.vue index bcf82f1cf..a1239af3f 100644 --- a/frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterPlayablesBtn.vue +++ b/frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterPlayablesBtn.vue @@ -1,6 +1,7 @@ diff --git a/frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterRaBtn.vue b/frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterRaBtn.vue index a97996f12..c79bee843 100644 --- a/frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterRaBtn.vue +++ b/frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterRaBtn.vue @@ -1,6 +1,7 @@ diff --git a/frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterUnmatchedBtn.vue b/frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterUnmatchedBtn.vue deleted file mode 100644 index 82158d970..000000000 --- a/frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterUnmatchedBtn.vue +++ /dev/null @@ -1,36 +0,0 @@ - - - diff --git a/frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterVerifiedBtn.vue b/frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterVerifiedBtn.vue index e4ef84288..47b52a40d 100644 --- a/frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterVerifiedBtn.vue +++ b/frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterVerifiedBtn.vue @@ -1,6 +1,7 @@ diff --git a/frontend/src/components/common/Collection/Dialog/CreateSmartCollection.vue b/frontend/src/components/common/Collection/Dialog/CreateSmartCollection.vue index 9244a5987..3c2b507f0 100644 --- a/frontend/src/components/common/Collection/Dialog/CreateSmartCollection.vue +++ b/frontend/src/components/common/Collection/Dialog/CreateSmartCollection.vue @@ -90,6 +90,10 @@ const filterSummary = computed(() => { return filters || ["No filters applied"]; }); +function toggleCollectionVisibility() { + isPublic.value = !isPublic.value; +} + function closeDialog() { show.value = false; name.value = ""; @@ -212,12 +216,16 @@ async function createSmartCollection() { - + + + {{ isPublic ? "mdi-lock-open-variant" : "mdi-lock" }} + + {{ isPublic ? t("rom.public") : t("rom.private") }} + @@ -242,8 +250,7 @@ async function createSmartCollection() { -