Add endpoint to fetch rom filters independent of query

This commit is contained in:
Georges-Antoine Assi
2026-01-15 11:18:51 -05:00
parent c01254679d
commit 96240a86e9
9 changed files with 162 additions and 155 deletions

View File

@@ -444,3 +444,14 @@ 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]
companies: list[str]
game_modes: list[str]
age_ratings: list[str]
player_counts: list[str]
regions: list[str]
languages: list[str]

View File

@@ -43,6 +43,7 @@ from endpoints.responses import BulkOperationResponse
from endpoints.responses.rom import (
DetailedRomSchema,
RomFileSchema,
RomFiltersDict,
RomUserSchema,
SimpleRomSchema,
UserNoteSchema,
@@ -671,6 +672,24 @@ 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()
return RomFiltersDict(
genres=filters["genres"],
franchises=filters["franchises"],
companies=filters["companies"],
game_modes=filters["game_modes"],
age_ratings=filters["age_ratings"],
player_counts=filters["player_counts"],
regions=filters["regions"],
languages=filters["languages"],
)
@protected_route(
router.get,
"/{id}",
@@ -1502,7 +1521,7 @@ async def update_rom_user(
@protected_route(
router.get,
"files/{id}",
"/files/{id}",
[Scope.ROMS_READ],
responses={status.HTTP_404_NOT_FOUND: {}},
)

View File

@@ -206,12 +206,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
@@ -222,7 +222,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
@@ -235,7 +235,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
@@ -250,7 +250,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}%"),
@@ -258,7 +258,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:
@@ -278,7 +278,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."""
@@ -300,21 +300,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."""
@@ -328,19 +328,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",
@@ -363,7 +363,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,
*,
@@ -374,7 +374,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,
*,
@@ -385,7 +385,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,
*,
@@ -396,7 +396,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,
*,
@@ -407,7 +407,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,
*,
@@ -418,7 +418,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
@@ -440,7 +440,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,
*,
@@ -451,7 +451,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,
*,
@@ -462,7 +462,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,
*,
@@ -516,52 +516,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)
# BEWARE YE WHO ENTERS HERE 💀
if group_by_meta_id:
@@ -676,14 +676,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:
@@ -694,7 +694,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))
@@ -1227,3 +1227,49 @@ 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))
@begin_session
def get_rom_filters(
self,
session: Session = None, # type: ignore
) -> dict:
statement = select(
RomMetadata.genres,
RomMetadata.franchises,
RomMetadata.companies,
RomMetadata.game_modes,
RomMetadata.age_ratings,
RomMetadata.player_count,
Rom.regions,
Rom.languages,
)
genres = set()
franchises = set()
companies = set()
game_modes = set()
age_ratings = set()
player_counts = set()
regions = set()
languages = set()
for row in session.execute(statement):
g, f, c, gm, ar, pc, rg, lg = row
genres.update(g)
franchises.update(f)
companies.update(c)
game_modes.update(gm)
age_ratings.update(ar)
player_counts.update(pc)
regions.update(rg)
languages.update(lg)
return {
"genres": sorted(genres),
"franchises": sorted(franchises),
"companies": sorted(companies),
"game_modes": sorted(game_modes),
"age_ratings": sorted(age_ratings),
"player_counts": sorted(player_counts),
"regions": sorted(regions),
"languages": sorted(languages),
}

View File

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

View File

@@ -0,0 +1,15 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type RomFiltersDict = {
genres: Array<string>;
franchises: Array<string>;
companies: Array<string>;
game_modes: Array<string>;
age_ratings: Array<string>;
player_counts: Array<string>;
regions: Array<string>;
languages: Array<string>;
};

View File

@@ -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, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useRouter } from "vue-router";
import { useDisplay } from "vuetify";
@@ -15,10 +15,10 @@ 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 romApi from "@/services/api/rom";
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(
@@ -177,8 +177,7 @@ const onFilterChange = debounce(
// Separate debounced function for search term changes
const onSearchChange = debounce(
async () => {
await fetchSearchFilteredRoms();
setFilters();
await fetchSearchFilters();
},
500,
{ leading: false, trailing: true },
@@ -264,109 +263,22 @@ 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<SimpleRom[]>([]);
async function fetchSearchFilters() {
const { data } = await romApi.getRomFilters();
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
galleryFilterStore.setFilterPlatforms([]);
galleryFilterStore.setFilterGenres(data.genres);
galleryFilterStore.setFilterFranchises(data.franchises);
galleryFilterStore.setFilterCompanies(data.companies);
galleryFilterStore.setFilterCollections([]);
galleryFilterStore.setFilterAgeRatings(data.age_ratings);
galleryFilterStore.setFilterRegions(data.regions);
galleryFilterStore.setFilterLanguages(data.languages);
galleryFilterStore.setFilterPlayerCounts(data.player_counts);
}
onMounted(async () => {
@@ -582,8 +494,7 @@ onMounted(async () => {
}
// Initial fetch of search-filtered ROMs for filter options
await fetchSearchFilteredRoms();
setFilters();
await fetchSearchFilters();
// Fire off search if URL state prepopulated
if (freshSearch || galleryFilterStore.isFiltered()) {
@@ -603,8 +514,7 @@ onMounted(async () => {
watch(
() => allPlatforms.value,
async () => {
await fetchSearchFilteredRoms();
setFilters();
await fetchSearchFilters();
},
{ immediate: false },
);

View File

@@ -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();
@@ -91,7 +91,7 @@ async function fetchRoms() {
}
function cleanupAll() {
romsStore.setLimit(MAX_FETCH_LIMIT);
romsStore.setLimit(10000);
galleryFilterStore.setFilterMissing(true);
romsStore
.fetchRoms({ galleryFilter: galleryFilterStore })

View File

@@ -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";
@@ -582,6 +583,10 @@ async function getRomNotes({
});
}
async function getRomFilters(): Promise<{ data: RomFiltersDict }> {
return api.get("/roms/filters");
}
export default {
uploadRoms,
getRoms,
@@ -601,4 +606,5 @@ export default {
updateRomNote,
deleteRomNote,
getRomNotes,
getRomFilters,
};

View File

@@ -22,7 +22,6 @@ type GalleryFilterStore = ExtractPiniaStoreType<typeof storeGalleryFilter>;
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");