diff --git a/backend/endpoints/responses/rom.py b/backend/endpoints/responses/rom.py index 1daf8b840..143551787 100644 --- a/backend/endpoints/responses/rom.py +++ b/backend/endpoints/responses/rom.py @@ -444,3 +444,16 @@ class DetailedRomSchema(RomSchema): @field_validator("user_screenshots") def sort_user_screenshots(cls, v: list[ScreenshotSchema]) -> list[ScreenshotSchema]: return sorted(v, key=lambda x: x.created_at, reverse=True) + + +class RomFiltersDict(TypedDict): + genres: list[str] + franchises: list[str] + collections: list[str] + companies: list[str] + game_modes: list[str] + age_ratings: list[str] + player_counts: list[str] + regions: list[str] + languages: list[str] + platforms: list[int] diff --git a/backend/endpoints/rom.py b/backend/endpoints/rom.py index 62cecac97..12c11c301 100644 --- a/backend/endpoints/rom.py +++ b/backend/endpoints/rom.py @@ -43,6 +43,7 @@ from endpoints.responses import BulkOperationResponse from endpoints.responses.rom import ( DetailedRomSchema, RomFileSchema, + RomFiltersDict, RomUserSchema, SimpleRomSchema, UserNoteSchema, @@ -186,6 +187,7 @@ class CustomLimitOffsetParams(LimitOffsetParams): class CustomLimitOffsetPage[T: BaseModel](LimitOffsetPage[T]): char_index: dict[str, int] rom_id_index: list[int] + filter_values: RomFiltersDict __params_type__ = CustomLimitOffsetParams @@ -196,6 +198,9 @@ def get_roms( bool, Query(description="Whether to get the char index."), ] = True, + with_filter_values: Annotated[ + bool, Query(description="Whether to return filter values.") + ] = True, search_term: Annotated[ str | None, Query(description="Search term to filter roms."), @@ -413,7 +418,7 @@ def get_roms( ] = None, ) -> CustomLimitOffsetPage[SimpleRomSchema]: """Retrieve roms.""" - query, order_by_attr = db_rom_handler.get_roms_query( + unfiltered_query, order_by_attr = db_rom_handler.get_roms_query( user_id=request.user.id, order_by=order_by.lower(), order_dir=order_dir.lower(), @@ -421,7 +426,7 @@ def get_roms( # Filter down the query query = db_rom_handler.filter_roms( - query=query, + query=unfiltered_query, user_id=request.user.id, platform_ids=platform_ids, collection_id=collection_id, @@ -467,6 +472,33 @@ def get_roms( ) char_index_dict = {char: index for (char, index) in char_index} + filter_values = RomFiltersDict( + genres=[], + franchises=[], + collections=[], + companies=[], + game_modes=[], + age_ratings=[], + player_counts=[], + regions=[], + languages=[], + platforms=[], + ) + if with_filter_values: + # We use the unfiltered query so applied filters don't affect the list + filter_query = db_rom_handler.filter_roms( + query=unfiltered_query, + user_id=request.user.id, + platform_ids=platform_ids, + collection_id=collection_id, + virtual_collection_id=virtual_collection_id, + smart_collection_id=smart_collection_id, + search_term=search_term, + ) + query_filters = db_rom_handler.with_filter_values(query=filter_query) + # trunk-ignore(mypy/typeddict-item) + filter_values = RomFiltersDict(**query_filters) + # Get all ROM IDs in order for the additional data with sync_session.begin() as session: rom_id_index = session.scalars(query.with_only_columns(Rom.id)).all() # type: ignore @@ -480,6 +512,7 @@ def get_roms( additional_data={ "char_index": char_index_dict, "rom_id_index": rom_id_index, + "filter_values": filter_values, }, ) @@ -678,6 +711,15 @@ def get_rom_by_hash( return DetailedRomSchema.from_orm_with_request(rom, request) +@protected_route(router.get, "/filters", [Scope.ROMS_READ]) +async def get_rom_filters(request: Request) -> RomFiltersDict: + from handler.database import db_rom_handler + + filters = db_rom_handler.get_rom_filters() + # trunk-ignore(mypy/typeddict-item) + return RomFiltersDict(**filters) + + @protected_route( router.get, "/{id}", @@ -1509,7 +1551,7 @@ async def update_rom_user( @protected_route( router.get, - "files/{id}", + "/files/{id}", [Scope.ROMS_READ], responses={status.HTTP_404_NOT_FOUND: {}}, ) diff --git a/backend/handler/database/roms_handler.py b/backend/handler/database/roms_handler.py index 5af0593e8..99dee6b06 100644 --- a/backend/handler/database/roms_handler.py +++ b/backend/handler/database/roms_handler.py @@ -23,6 +23,7 @@ from sqlalchemy import ( ) from sqlalchemy.orm import Query, Session, joinedload, noload, selectinload from sqlalchemy.sql.elements import KeyedColumnElement +from sqlalchemy.sql.selectable import Select from config import ROMM_DB_DRIVER from decorators.database import begin_session @@ -207,12 +208,12 @@ 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( + 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( + def _filter_by_collection_id( self, query: Query, session: Session, collection_id: int ): from . import db_collection_handler @@ -223,7 +224,7 @@ class DBRomsHandler(DBBaseHandler): return query.filter(Rom.id.in_(collection.rom_ids)) return query - def filter_by_virtual_collection_id( + def _filter_by_virtual_collection_id( self, query: Query, session: Session, virtual_collection_id: str ): from . import db_collection_handler @@ -236,7 +237,7 @@ class DBRomsHandler(DBBaseHandler): return query.filter(Rom.id.in_(v_collection.rom_ids)) return query - def filter_by_smart_collection_id( + def _filter_by_smart_collection_id( self, query: Query, session: Session, smart_collection_id: int, user_id: int ): from . import db_collection_handler @@ -251,7 +252,7 @@ class DBRomsHandler(DBBaseHandler): return query.filter(Rom.id.in_(smart_collection.rom_ids)) return query - def filter_by_search_term(self, query: Query, search_term: str): + def _filter_by_search_term(self, query: Query, search_term: str): return query.filter( or_( Rom.fs_name.ilike(f"%{search_term}%"), @@ -259,7 +260,7 @@ class DBRomsHandler(DBBaseHandler): ) ) - def filter_by_matched(self, query: Query, value: bool) -> Query: + def _filter_by_matched(self, query: Query, value: bool) -> Query: """Filter based on whether the rom is matched to a metadata provider. Args: @@ -279,7 +280,7 @@ class DBRomsHandler(DBBaseHandler): predicate = not_(predicate) return query.filter(predicate) - def filter_by_favorite( + def _filter_by_favorite( self, query: Query, session: Session, value: bool, user_id: int | None ) -> Query: """Filter based on whether the rom is in the user's favorites collection.""" @@ -301,21 +302,21 @@ class DBRomsHandler(DBBaseHandler): return query return query.filter(false()) - def filter_by_duplicate(self, query: Query, value: bool) -> Query: + def _filter_by_duplicate(self, query: Query, value: bool) -> Query: """Filter based on whether the rom has duplicates.""" predicate = Rom.sibling_roms.any() if not value: predicate = not_(predicate) return query.filter(predicate) - def filter_by_playable(self, query: Query, value: bool) -> Query: + def _filter_by_playable(self, query: Query, value: bool) -> Query: """Filter based on whether the rom is playable on supported platforms.""" predicate = Platform.slug.in_(EJS_SUPPORTED_PLATFORMS) if not value: predicate = not_(predicate) return query.join(Platform).filter(predicate) - def filter_by_last_played( + 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.""" @@ -329,19 +330,19 @@ class DBRomsHandler(DBBaseHandler): ) return query.filter(has_last_played) - def filter_by_has_ra(self, query: Query, value: bool) -> Query: + def _filter_by_has_ra(self, query: Query, value: bool) -> Query: predicate = Rom.ra_id.isnot(None) if not value: predicate = not_(predicate) return query.filter(predicate) - def filter_by_missing_from_fs(self, query: Query, value: bool) -> Query: + def _filter_by_missing_from_fs(self, query: Query, value: bool) -> Query: predicate = Rom.missing_from_fs.isnot(False) if not value: predicate = not_(predicate) return query.filter(predicate) - def filter_by_verified(self, query: Query): + def _filter_by_verified(self, query: Query): keys_to_check = [ "tosec_match", "mame_arcade_match", @@ -364,7 +365,7 @@ class DBRomsHandler(DBBaseHandler): or_(*(Rom.hasheous_metadata[key].as_boolean() for key in keys_to_check)) ) - def filter_by_genres( + def _filter_by_genres( self, query: Query, *, @@ -375,7 +376,7 @@ class DBRomsHandler(DBBaseHandler): 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_franchises( + def _filter_by_franchises( self, query: Query, *, @@ -386,7 +387,7 @@ class DBRomsHandler(DBBaseHandler): 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_collections( + def _filter_by_collections( self, query: Query, *, @@ -397,7 +398,7 @@ class DBRomsHandler(DBBaseHandler): 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_companies( + def _filter_by_companies( self, query: Query, *, @@ -408,7 +409,7 @@ class DBRomsHandler(DBBaseHandler): 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_ratings( + def _filter_by_age_ratings( self, query: Query, *, @@ -419,7 +420,7 @@ class DBRomsHandler(DBBaseHandler): 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, statuses: Sequence[str]): + def _filter_by_status(self, query: Query, statuses: Sequence[str]): """Filter by one or more user statuses using OR logic.""" if not statuses: return query @@ -441,7 +442,7 @@ class DBRomsHandler(DBBaseHandler): return query.filter(or_(*status_filters), RomUser.hidden.is_(False)) - def filter_by_regions( + def _filter_by_regions( self, query: Query, *, @@ -452,7 +453,7 @@ class DBRomsHandler(DBBaseHandler): 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( + def _filter_by_languages( self, query: Query, *, @@ -463,7 +464,7 @@ class DBRomsHandler(DBBaseHandler): op = json_array_contains_all if match_all else json_array_contains_any return query.filter(op(Rom.languages, values, session=session)) - def filter_by_player_counts( + def _filter_by_player_counts( self, query: Query, *, @@ -518,52 +519,52 @@ class DBRomsHandler(DBBaseHandler): # 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) + query = self._filter_by_platform_ids(query, platform_ids) if collection_id: - query = self.filter_by_collection_id(query, session, collection_id) + query = self._filter_by_collection_id(query, session, collection_id) if virtual_collection_id: - query = self.filter_by_virtual_collection_id( + query = self._filter_by_virtual_collection_id( query, session, virtual_collection_id ) if smart_collection_id and user_id: - query = self.filter_by_smart_collection_id( + query = self._filter_by_smart_collection_id( query, session, smart_collection_id, user_id ) if search_term: - query = self.filter_by_search_term(query, search_term) + query = self._filter_by_search_term(query, search_term) if matched is not None: - query = self.filter_by_matched(query, value=matched) + query = self._filter_by_matched(query, value=matched) if favorite is not None: - query = self.filter_by_favorite( + query = self._filter_by_favorite( query, session=session, value=favorite, user_id=user_id ) if duplicate is not None: - query = self.filter_by_duplicate(query, value=duplicate) + query = self._filter_by_duplicate(query, value=duplicate) if last_played is not None: - query = self.filter_by_last_played( + 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) + query = self._filter_by_playable(query, value=playable) if has_ra is not None: - query = self.filter_by_has_ra(query, value=has_ra) + query = self._filter_by_has_ra(query, value=has_ra) if missing is not None: - query = self.filter_by_missing_from_fs(query, value=missing) + query = self._filter_by_missing_from_fs(query, value=missing) # TODO: Correctly support true/false values. if verified: - query = self.filter_by_verified(query) + query = self._filter_by_verified(query) if updated_after: query = query.filter(Rom.updated_at > updated_after) @@ -681,14 +682,14 @@ class DBRomsHandler(DBBaseHandler): # 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), - (player_counts, player_counts_logic, self.filter_by_player_counts), + (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), + (player_counts, player_counts_logic, self._filter_by_player_counts), ] for values, logic, filter_func in filters_to_apply: @@ -699,7 +700,7 @@ class DBRomsHandler(DBBaseHandler): # The RomUser table is already joined if user_id is set if statuses and user_id: - query = self.filter_by_status(query, statuses) + query = self._filter_by_status(query, statuses) elif user_id: query = query.filter( or_(RomUser.hidden.is_(False), RomUser.hidden.is_(None)) @@ -1232,3 +1233,108 @@ class DBRomsHandler(DBBaseHandler): # Return the first ROM matching any of the provided hash values return session.scalar(query.outerjoin(Rom.files).filter(or_(*filters)).limit(1)) + + def _collect_filter_values( + self, + session: Session, + statement: Select, + ) -> dict: + genres = set() + franchises = set() + collections = set() + companies = set() + game_modes = set() + age_ratings = set() + player_counts = set() + regions = set() + languages = set() + platforms = set() + + for row in session.execute(statement): + g, f, cl, co, gm, ar, pc, rg, lg, pid = row + if g: + genres.update(g) + if f: + franchises.update(f) + if cl: + collections.update(cl) + if co: + companies.update(co) + if gm: + game_modes.update(gm) + if ar: + age_ratings.update(ar) + if pc: + player_counts.add(pc) + if rg: + regions.update(rg) + if lg: + languages.update(lg) + platforms.add(pid) + + return { + "genres": sorted(genres), + "franchises": sorted(franchises), + "collections": sorted(collections), + "companies": sorted(companies), + "game_modes": sorted(game_modes), + "age_ratings": sorted(age_ratings), + "player_counts": sorted(player_counts), + "regions": sorted(regions), + "languages": sorted(languages), + "platforms": sorted(platforms), + } + + @begin_session + def with_filter_values( + self, + query: Query, + session: Session = None, # type: ignore + ) -> dict: + """ + Returns the list of filters given the current subset of ROMs in the query + """ + ids_subq = query.with_only_columns(Rom.id).scalar_subquery() # type: ignore + + statement = ( + select( + RomMetadata.genres, + RomMetadata.franchises, + RomMetadata.collections, + RomMetadata.companies, + RomMetadata.game_modes, + RomMetadata.age_ratings, + RomMetadata.player_count, + Rom.regions, + Rom.languages, + Rom.platform_id, + ) + .select_from(Rom) + .join(RomMetadata, Rom.id == RomMetadata.rom_id) + .where(Rom.id.in_(ids_subq)) + ) + + return self._collect_filter_values(session, statement) + + @begin_session + def get_rom_filters( + self, + session: Session = None, # type: ignore + ) -> dict: + """ + Returns all filter values across all ROM metadata + """ + statement = select( + RomMetadata.genres, + RomMetadata.franchises, + RomMetadata.collections, + RomMetadata.companies, + RomMetadata.game_modes, + RomMetadata.age_ratings, + RomMetadata.player_count, + Rom.regions, + Rom.languages, + Rom.platform_id, + ) + + return self._collect_filter_values(session, statement) diff --git a/frontend/src/__generated__/index.ts b/frontend/src/__generated__/index.ts index 247fbd188..baabd29be 100644 --- a/frontend/src/__generated__/index.ts +++ b/frontend/src/__generated__/index.ts @@ -62,6 +62,7 @@ export type { RAUserGameProgression } from './models/RAUserGameProgression'; export type { Role } from './models/Role'; export type { RomFileCategory } from './models/RomFileCategory'; export type { RomFileSchema } from './models/RomFileSchema'; +export type { RomFiltersDict } from './models/RomFiltersDict'; export type { RomFlashpointMetadata } from './models/RomFlashpointMetadata'; export type { RomGamelistMetadata } from './models/RomGamelistMetadata'; export type { RomHasheousMetadata } from './models/RomHasheousMetadata'; diff --git a/frontend/src/__generated__/models/CustomLimitOffsetPage_SimpleRomSchema_.ts b/frontend/src/__generated__/models/CustomLimitOffsetPage_SimpleRomSchema_.ts index dcd5c0ae6..baea947bf 100644 --- a/frontend/src/__generated__/models/CustomLimitOffsetPage_SimpleRomSchema_.ts +++ b/frontend/src/__generated__/models/CustomLimitOffsetPage_SimpleRomSchema_.ts @@ -2,6 +2,7 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ +import type { RomFiltersDict } from './RomFiltersDict'; import type { SimpleRomSchema } from './SimpleRomSchema'; export type CustomLimitOffsetPage_SimpleRomSchema_ = { items: Array; @@ -10,5 +11,6 @@ export type CustomLimitOffsetPage_SimpleRomSchema_ = { offset: number; char_index: Record; rom_id_index: Array; + filter_values: RomFiltersDict; }; diff --git a/frontend/src/__generated__/models/RomFiltersDict.ts b/frontend/src/__generated__/models/RomFiltersDict.ts new file mode 100644 index 000000000..16ccfccea --- /dev/null +++ b/frontend/src/__generated__/models/RomFiltersDict.ts @@ -0,0 +1,17 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type RomFiltersDict = { + genres: Array; + franchises: Array; + collections: Array; + companies: Array; + game_modes: Array; + age_ratings: Array; + player_counts: Array; + regions: Array; + languages: Array; + platforms: Array; +}; + diff --git a/frontend/src/components/Gallery/AppBar/common/CharIndexBar.vue b/frontend/src/components/Gallery/AppBar/common/CharIndexBar.vue index d10694bf8..b474c6380 100644 --- a/frontend/src/components/Gallery/AppBar/common/CharIndexBar.vue +++ b/frontend/src/components/Gallery/AppBar/common/CharIndexBar.vue @@ -5,11 +5,13 @@ import { inject, watch, computed } from "vue"; import { useDisplay } from "vuetify"; import storeGalleryFilter from "@/stores/galleryFilter"; import storeGalleryView from "@/stores/galleryView"; +import storePlatforms from "@/stores/platforms"; import storeRoms from "@/stores/roms"; import type { Events } from "@/types/emitter"; const { smAndDown } = useDisplay(); const romsStore = storeRoms(); +const platformsStore = storePlatforms(); const galleryFilterStore = storeGalleryFilter(); const galleryViewStore = storeGalleryView(); const { selectedRoms } = storeToRefs(romsStore); @@ -29,7 +31,11 @@ async function fetchRoms() { }); romsStore - .fetchRoms({ galleryFilter: galleryFilterStore, concat: false }) + .fetchRoms({ + galleryFilter: galleryFilterStore, + platformsStore: platformsStore, + concat: false, + }) .then(() => { emitter?.emit("showLoadingDialog", { loading: false, diff --git a/frontend/src/components/Gallery/AppBar/common/FilterDrawer/Base.vue b/frontend/src/components/Gallery/AppBar/common/FilterDrawer/Base.vue index 0410437f0..bd2b7af25 100644 --- a/frontend/src/components/Gallery/AppBar/common/FilterDrawer/Base.vue +++ b/frontend/src/components/Gallery/AppBar/common/FilterDrawer/Base.vue @@ -2,7 +2,7 @@ import { debounce } from "lodash"; import type { Emitter } from "mitt"; import { storeToRefs } from "pinia"; -import { inject, nextTick, onMounted, ref, watch } from "vue"; +import { inject, nextTick, onMounted } from "vue"; import { useI18n } from "vue-i18n"; import { useRouter } from "vue-router"; import { useDisplay } from "vuetify"; @@ -15,10 +15,9 @@ import FilterPlatformBtn from "@/components/Gallery/AppBar/common/FilterDrawer/F import FilterPlayablesBtn from "@/components/Gallery/AppBar/common/FilterDrawer/FilterPlayablesBtn.vue"; import FilterRaBtn from "@/components/Gallery/AppBar/common/FilterDrawer/FilterRaBtn.vue"; import FilterVerifiedBtn from "@/components/Gallery/AppBar/common/FilterDrawer/FilterVerifiedBtn.vue"; -import cachedApiService from "@/services/cache/api"; import storeGalleryFilter from "@/stores/galleryFilter"; import storePlatforms from "@/stores/platforms"; -import storeRoms, { type SimpleRom } from "@/stores/roms"; +import storeRoms from "@/stores/roms"; import type { Events } from "@/types/emitter"; withDefaults( @@ -79,13 +78,16 @@ const { selectedPlayerCounts, playerCountsLogic, } = storeToRefs(galleryFilterStore); -const { allPlatforms } = storeToRefs(platformsStore); const emitter = inject>("emitter"); const onFilterChange = debounce( () => { romsStore.resetPagination(); - romsStore.fetchRoms({ galleryFilter: galleryFilterStore, concat: false }); + romsStore.fetchRoms({ + galleryFilter: galleryFilterStore, + platformsStore: platformsStore, + concat: false, + }); const url = new URL(window.location.href); // Update URL with filters @@ -174,16 +176,6 @@ 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 = [ @@ -264,111 +256,10 @@ const filters = [ function resetFilters() { galleryFilterStore.resetFilters(); nextTick(async () => { - await fetchSearchFilteredRoms(); - setFilters(); emitter?.emit("filterRoms", null); }); } -// Store search-filtered ROMs for populating filter options -let searchFilteredRoms = ref([]); - -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( - romsForFilters - .flatMap((rom) => platformsStore.get(rom.platform_id)) - .filter((platform) => !!platform) - .sort(), - ), - ]); - galleryFilterStore.setFilterGenres([ - ...new Set(romsForFilters.flatMap((rom) => rom.metadatum.genres).sort()), - ]); - galleryFilterStore.setFilterFranchises([ - ...new Set( - romsForFilters.flatMap((rom) => rom.metadatum.franchises).sort(), - ), - ]); - galleryFilterStore.setFilterCompanies([ - ...new Set(romsForFilters.flatMap((rom) => rom.metadatum.companies).sort()), - ]); - galleryFilterStore.setFilterCollections([ - ...new Set( - romsForFilters.flatMap((rom) => rom.metadatum.collections).sort(), - ), - ]); - galleryFilterStore.setFilterAgeRatings([ - ...new Set( - romsForFilters.flatMap((rom) => rom.metadatum.age_ratings).sort(), - ), - ]); - galleryFilterStore.setFilterRegions([ - ...new Set(romsForFilters.flatMap((rom) => rom.regions).sort()), - ]); - galleryFilterStore.setFilterLanguages([ - ...new Set(romsForFilters.flatMap((rom) => rom.languages).sort()), - ]); - galleryFilterStore.setFilterPlayerCounts([ - ...new Set( - romsForFilters - .map((rom) => rom.metadatum.player_count) - .filter((playerCount): playerCount is string => !!playerCount) - .sort(), - ), - ]); - // Note: filterStatuses is static and doesn't need to be set dynamically -} - onMounted(async () => { const { search: urlSearch, @@ -581,33 +472,10 @@ 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( - () => searchTerm.value, - async () => { - await onSearchChange(); - }, - { immediate: false }, - ); - - // Watch for platform changes to update filter options - watch( - () => allPlatforms.value, - async () => { - await fetchSearchFilteredRoms(); - setFilters(); - }, - { immediate: false }, - ); }); diff --git a/frontend/src/components/Settings/LibraryManagement/Config/MissingGames.vue b/frontend/src/components/Settings/LibraryManagement/Config/MissingGames.vue index c1da149b5..6dc35db29 100644 --- a/frontend/src/components/Settings/LibraryManagement/Config/MissingGames.vue +++ b/frontend/src/components/Settings/LibraryManagement/Config/MissingGames.vue @@ -13,7 +13,7 @@ import PlatformIcon from "@/components/common/Platform/PlatformIcon.vue"; import storeGalleryFilter from "@/stores/galleryFilter"; import storeGalleryView from "@/stores/galleryView"; import storePlatforms from "@/stores/platforms"; -import storeRoms, { MAX_FETCH_LIMIT } from "@/stores/roms"; +import storeRoms from "@/stores/roms"; import type { Events } from "@/types/emitter"; const { t } = useI18n(); @@ -45,6 +45,7 @@ const onFilterChange = debounce( galleryFilterStore.setFilterMissing(true); romsStore.fetchRoms({ galleryFilter: galleryFilterStore, + platformsStore: platformsStore, concat: false, }); @@ -74,7 +75,10 @@ async function fetchRoms() { galleryFilterStore.setFilterMissing(true); romsStore - .fetchRoms({ galleryFilter: galleryFilterStore }) + .fetchRoms({ + galleryFilter: galleryFilterStore, + platformsStore: platformsStore, + }) .catch((error) => { console.error("Error fetching missing games:", error); emitter?.emit("snackbarShow", { @@ -91,10 +95,13 @@ async function fetchRoms() { } function cleanupAll() { - romsStore.setLimit(MAX_FETCH_LIMIT); + romsStore.setLimit(10000); galleryFilterStore.setFilterMissing(true); romsStore - .fetchRoms({ galleryFilter: galleryFilterStore }) + .fetchRoms({ + galleryFilter: galleryFilterStore, + platformsStore: platformsStore, + }) .then(() => { emitter?.emit("showLoadingDialog", { loading: false, diff --git a/frontend/src/components/common/Game/VirtualTable.vue b/frontend/src/components/common/Game/VirtualTable.vue index 334e96f45..1a14903de 100644 --- a/frontend/src/components/common/Game/VirtualTable.vue +++ b/frontend/src/components/common/Game/VirtualTable.vue @@ -16,6 +16,7 @@ import storeAuth from "@/stores/auth"; import storeCollections from "@/stores/collections"; import storeDownload from "@/stores/download"; import storeGalleryFilter from "@/stores/galleryFilter"; +import storePlatforms from "@/stores/platforms"; import storeRoms, { type SimpleRom } from "@/stores/roms"; import type { Events } from "@/types/emitter"; import { @@ -44,6 +45,7 @@ const { filteredRoms, selectedRoms, fetchingRoms, fetchTotalRoms } = const auth = storeAuth(); const galleryFilterStore = storeGalleryFilter(); const collectionsStore = storeCollections(); +const platformsStore = storePlatforms(); const emitter = inject>("emitter"); const HEADERS = [ @@ -134,7 +136,10 @@ function updateOptions({ sortBy }: { sortBy: SortBy }) { romsStore.resetPagination(); romsStore.setOrderBy(key); romsStore.setOrderDir(order); - romsStore.fetchRoms({ galleryFilter: galleryFilterStore }); + romsStore.fetchRoms({ + galleryFilter: galleryFilterStore, + platformsStore: platformsStore, + }); } diff --git a/frontend/src/console/views/GamesList.vue b/frontend/src/console/views/GamesList.vue index ae30864d0..abccb18f5 100644 --- a/frontend/src/console/views/GamesList.vue +++ b/frontend/src/console/views/GamesList.vue @@ -320,6 +320,7 @@ async function fetchRoms() { const fetchedRoms = await romsStore.fetchRoms({ galleryFilter: galleryFilterStore, + platformsStore: platformsStore, concat: false, }); diff --git a/frontend/src/services/api/rom.ts b/frontend/src/services/api/rom.ts index 552675265..7fcde968e 100644 --- a/frontend/src/services/api/rom.ts +++ b/frontend/src/services/api/rom.ts @@ -4,6 +4,7 @@ import type { ManualMetadata, RomUserSchema, UserNoteSchema, + RomFiltersDict, } from "@/__generated__"; import { type CustomLimitOffsetPage_SimpleRomSchema_ as GetRomsResponse } from "@/__generated__/models/CustomLimitOffsetPage_SimpleRomSchema_"; import api from "@/services/api"; @@ -246,6 +247,7 @@ async function getRecentRoms(): Promise<{ data: GetRomsResponse }> { order_dir: "desc", limit: RECENT_ROMS_LIMIT, with_char_index: false, + with_filter_values: false, }, }); } @@ -257,6 +259,7 @@ async function getRecentPlayedRoms(): Promise<{ data: GetRomsResponse }> { order_dir: "desc", limit: RECENT_PLAYED_ROMS_LIMIT, with_char_index: false, + with_filter_values: false, last_played: true, }, }); @@ -582,6 +585,10 @@ async function getRomNotes({ }); } +async function getRomFilters(): Promise<{ data: RomFiltersDict }> { + return api.get("/roms/filters"); +} + export default { uploadRoms, getRoms, @@ -601,4 +608,5 @@ export default { updateRomNote, deleteRomNote, getRomNotes, + getRomFilters, }; diff --git a/frontend/src/services/cache/api.ts b/frontend/src/services/cache/api.ts index 40c51743d..f302bcffb 100644 --- a/frontend/src/services/cache/api.ts +++ b/frontend/src/services/cache/api.ts @@ -147,6 +147,7 @@ class CachedApiService { order_dir: "desc", limit: 15, with_char_index: false, + with_filter_values: false, }); return cacheService.request(config, onBackgroundUpdate); @@ -160,6 +161,7 @@ class CachedApiService { order_dir: "desc", limit: 15, with_char_index: false, + with_filter_values: false, last_played: true, }); @@ -177,6 +179,7 @@ class CachedApiService { order_dir: "desc", limit: 15, with_char_index: false, + with_filter_values: false, }); } @@ -186,6 +189,7 @@ class CachedApiService { order_dir: "desc", limit: 15, with_char_index: false, + with_filter_values: false, last_played: true, }); } diff --git a/frontend/src/stores/roms.ts b/frontend/src/stores/roms.ts index 7c077e8e5..d0e641c3f 100644 --- a/frontend/src/stores/roms.ts +++ b/frontend/src/stores/roms.ts @@ -14,15 +14,16 @@ import { type SmartCollection, } from "@/stores/collections"; import storeGalleryFilter from "@/stores/galleryFilter"; +import storePlatforms from "@/stores/platforms"; import { type Platform } from "@/stores/platforms"; import type { ExtractPiniaStoreType } from "@/types"; type GalleryFilterStore = ExtractPiniaStoreType; +type PlatformsStore = ExtractPiniaStoreType; export type SimpleRom = SimpleRomSchema; export type SearchRom = SearchRomSchema; export type DetailedRom = DetailedRomSchema; -export const MAX_FETCH_LIMIT = 10000; const orderByStorage = useLocalStorage("roms.orderBy", "name"); const orderDirStorage = useLocalStorage("roms.orderDir", "asc"); @@ -150,8 +151,14 @@ export default defineStore("roms", { }; return params; }, - _postFetchRoms(response: GetRomsResponse, concat: boolean) { - const { items, offset, total, char_index, rom_id_index } = response; + _postFetchRoms( + response: GetRomsResponse, + galleryFilter: GalleryFilterStore, + platformsStore: PlatformsStore, + concat: boolean, + ) { + const { items, offset, total, char_index, rom_id_index, filter_values } = + response; if (!concat || this.fetchOffset === 0) { this._allRoms = items; } else { @@ -165,12 +172,34 @@ export default defineStore("roms", { // Set the character index for the current platform this.characterIndex = char_index; this.romIdIndex = rom_id_index; + + // Only set the list of platforms on first fetch + if (galleryFilter.filterPlatforms.length === 0) { + galleryFilter.setFilterPlatforms( + platformsStore.allPlatforms.filter((p) => + filter_values.platforms.includes(p.id), + ), + ); + } + + if (filter_values) { + galleryFilter.setFilterCollections(filter_values.collections); + galleryFilter.setFilterGenres(filter_values.genres); + galleryFilter.setFilterFranchises(filter_values.franchises); + galleryFilter.setFilterCompanies(filter_values.companies); + galleryFilter.setFilterAgeRatings(filter_values.age_ratings); + galleryFilter.setFilterRegions(filter_values.regions); + galleryFilter.setFilterLanguages(filter_values.languages); + galleryFilter.setFilterPlayerCounts(filter_values.player_counts); + } }, async fetchRoms({ galleryFilter, + platformsStore, concat = true, }: { galleryFilter: GalleryFilterStore; + platformsStore: PlatformsStore; concat?: boolean; }): Promise { if (this.fetchingRoms) return Promise.resolve([]); @@ -190,10 +219,20 @@ export default defineStore("roms", { JSON.stringify(currentParams) !== JSON.stringify(currentRequestParams); if (paramsChanged) return; - this._postFetchRoms(response, concat); + this._postFetchRoms( + response, + galleryFilter, + platformsStore, + concat, + ); }) .then((response) => { - this._postFetchRoms(response.data, concat); + this._postFetchRoms( + response.data, + galleryFilter, + platformsStore, + concat, + ); resolve(response.data.items); }) .catch((error) => { diff --git a/frontend/src/views/Gallery/Collection/BaseCollection.vue b/frontend/src/views/Gallery/Collection/BaseCollection.vue index d535a0175..272a38407 100644 --- a/frontend/src/views/Gallery/Collection/BaseCollection.vue +++ b/frontend/src/views/Gallery/Collection/BaseCollection.vue @@ -15,6 +15,7 @@ import GameTable from "@/components/common/Game/VirtualTable.vue"; import { type CollectionType } from "@/stores/collections"; import storeGalleryFilter from "@/stores/galleryFilter"; import storeGalleryView from "@/stores/galleryView"; +import storePlatforms from "@/stores/platforms"; import storeRoms, { type SimpleRom } from "@/stores/roms"; import type { Events } from "@/types/emitter"; import { views } from "@/utils"; @@ -29,6 +30,7 @@ const galleryViewStore = storeGalleryView(); const galleryFilterStore = storeGalleryFilter(); const { scrolledToTop, currentView } = storeToRefs(galleryViewStore); const romsStore = storeRoms(); +const platformsStore = storePlatforms(); const { filteredRoms, selectedRoms, @@ -53,7 +55,10 @@ async function fetchRoms() { }); romsStore - .fetchRoms({ galleryFilter: galleryFilterStore }) + .fetchRoms({ + galleryFilter: galleryFilterStore, + platformsStore: platformsStore, + }) .then(() => { emitter?.emit("showLoadingDialog", { loading: false, diff --git a/frontend/src/views/Gallery/Platform.vue b/frontend/src/views/Gallery/Platform.vue index 9d8815842..75d1757ea 100644 --- a/frontend/src/views/Gallery/Platform.vue +++ b/frontend/src/views/Gallery/Platform.vue @@ -48,7 +48,10 @@ async function fetchRoms() { }); romsStore - .fetchRoms({ galleryFilter: galleryFilterStore }) + .fetchRoms({ + galleryFilter: galleryFilterStore, + platformsStore: platformsStore, + }) .then(() => { emitter?.emit("showLoadingDialog", { loading: false, diff --git a/frontend/src/views/Gallery/Search.vue b/frontend/src/views/Gallery/Search.vue index f2bb39a70..ccfa52487 100644 --- a/frontend/src/views/Gallery/Search.vue +++ b/frontend/src/views/Gallery/Search.vue @@ -13,6 +13,7 @@ import GameCard from "@/components/common/Game/Card/Base.vue"; import GameTable from "@/components/common/Game/VirtualTable.vue"; import storeGalleryFilter from "@/stores/galleryFilter"; import storeGalleryView from "@/stores/galleryView"; +import storePlatforms from "@/stores/platforms"; import storeRoms, { type SimpleRom } from "@/stores/roms"; import type { Events } from "@/types/emitter"; import { views } from "@/utils"; @@ -22,6 +23,7 @@ const { scrolledToTop, currentView } = storeToRefs(galleryViewStore); const galleryFilterStore = storeGalleryFilter(); const { searchTerm } = storeToRefs(galleryFilterStore); const romsStore = storeRoms(); +const platformsStore = storePlatforms(); const { filteredRoms, selectedRoms, @@ -100,7 +102,10 @@ function onGameTouchEnd() { function fetchRoms() { romsStore - .fetchRoms({ galleryFilter: galleryFilterStore }) + .fetchRoms({ + galleryFilter: galleryFilterStore, + platformsStore: platformsStore, + }) .catch((error) => { emitter?.emit("snackbarShow", { msg: `Couldn't fetch roms: ${error}`, @@ -108,9 +113,6 @@ function fetchRoms() { color: "red", timeout: 4000, }); - }) - .finally(() => { - galleryFilterStore.activeFilterDrawer = false; }); }