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