diff --git a/backend/endpoints/feeds.py b/backend/endpoints/feeds.py index 4925028ad..c5acf8241 100644 --- a/backend/endpoints/feeds.py +++ b/backend/endpoints/feeds.py @@ -95,7 +95,7 @@ def platforms_webrcade_feed(request: Request) -> WebrcadeFeedSchema: continue category_items = [] - roms = db_rom_handler.get_roms_scalar(platform_id=p.id) + roms = db_rom_handler.get_roms_scalar(platform_ids=[p.id]) for rom in roms: download_url = generate_rom_download_url(request, rom) category_item = WebrcadeFeedItemSchema( @@ -206,7 +206,7 @@ async def tinfoil_index_feed( return titledb - roms = db_rom_handler.get_roms_scalar(platform_id=switch.id) + roms = db_rom_handler.get_roms_scalar(platform_ids=[switch.id]) return TinfoilFeedSchema( files=[ @@ -294,7 +294,7 @@ def pkgi_ps3_feed( status_code=400, detail=f"Invalid content type: {content_type}" ) from e - roms = db_rom_handler.get_roms_scalar(platform_id=ps3_platform.id) + roms = db_rom_handler.get_roms_scalar(platform_ids=[ps3_platform.id]) txt_lines = [] for rom in roms: @@ -365,7 +365,7 @@ def pkgi_psvita_feed( status_code=400, detail=f"Invalid content type: {content_type}" ) from e - roms = db_rom_handler.get_roms_scalar(platform_id=psvita_platform.id) + roms = db_rom_handler.get_roms_scalar(platform_ids=[psvita_platform.id]) txt_lines = [] for rom in roms: @@ -436,7 +436,7 @@ def pkgi_psp_feed( status_code=400, detail=f"Invalid content type: {content_type}" ) from e - roms = db_rom_handler.get_roms_scalar(platform_id=psp_platform.id) + roms = db_rom_handler.get_roms_scalar(platform_ids=[psp_platform.id]) txt_lines = [] for rom in roms: @@ -505,7 +505,7 @@ def fpkgi_feed(request: Request, platform_slug: str) -> Response: status_code=404, detail=f"Platform {platform_slug} not found" ) - roms = db_rom_handler.get_roms_scalar(platform_id=platform.id) + roms = db_rom_handler.get_roms_scalar(platform_ids=[platform.id]) response_data = {} for rom in roms: @@ -551,7 +551,7 @@ def kekatsu_ds_feed(request: Request, platform_slug: str) -> Response: status_code=404, detail=f"Platform {platform_slug} not found" ) - roms = db_rom_handler.get_roms_scalar(platform_id=platform.id) + roms = db_rom_handler.get_roms_scalar(platform_ids=[platform.id]) txt_lines = [] txt_lines.append("1") # Database version diff --git a/backend/endpoints/rom.py b/backend/endpoints/rom.py index 6997d0b7b..0311ccc1b 100644 --- a/backend/endpoints/rom.py +++ b/backend/endpoints/rom.py @@ -200,9 +200,14 @@ def get_roms( str | None, Query(description="Search term to filter roms."), ] = None, - platform_id: Annotated[ - int | None, - Query(description="Platform internal id.", ge=1), + platform_ids: Annotated[ + list[int] | None, + Query( + description=( + "Platform internal ids. Multiple values are allowed by repeating the" + " parameter, and results that match any of the values will be returned." + ), + ), ] = None, collection_id: Annotated[ int | None, @@ -218,7 +223,7 @@ def get_roms( ] = None, matched: Annotated[ bool | None, - Query(description="Whether the rom matched a metadata source."), + Query(description="Whether the rom matched at least one metadata source."), ] = None, favorite: Annotated[ bool | None, @@ -228,6 +233,12 @@ def get_roms( bool | None, Query(description="Whether the rom is marked as duplicate."), ] = None, + last_played: Annotated[ + bool | None, + Query( + description="Whether the rom has a last played value for the current user." + ), + ] = None, playable: Annotated[ bool | None, Query(description="Whether the rom is playable from the browser."), @@ -242,9 +253,7 @@ def get_roms( ] = None, verified: Annotated[ bool | None, - Query( - description="Whether the rom is verified by Hasheous from the filesystem." - ), + Query(description="Whether the rom is verified by Hasheous."), ] = None, group_by_meta_id: Annotated[ bool, @@ -252,38 +261,127 @@ def get_roms( description="Whether to group roms by metadata ID (IGDB / Moby / ScreenScraper / RetroAchievements / LaunchBox)." ), ] = False, - selected_genre: Annotated[ - str | None, - Query(description="Associated genre."), + genres: Annotated[ + list[str] | None, + Query( + description=( + "Associated genre. Multiple values are allowed by repeating the" + " parameter, and results that match any of the values will be returned." + ), + ), ] = None, - selected_franchise: Annotated[ - str | None, - Query(description="Associated franchise."), + franchises: Annotated[ + list[str] | None, + Query( + description=( + "Associated franchise. Multiple values are allowed by repeating" + " the parameter, and results that match any of the values will be returned." + ), + ), ] = None, - selected_collection: Annotated[ - str | None, - Query(description="Associated collection."), + collections: Annotated[ + list[str] | None, + Query( + description=( + "Associated collection. Multiple values are allowed by repeating" + " the parameter, and results that match any of the values will be returned." + ), + ), ] = None, - selected_company: Annotated[ - str | None, - Query(description="Associated company."), + companies: Annotated[ + list[str] | None, + Query( + description=( + "Associated company. Multiple values are allowed by repeating" + " the parameter, and results that match any of the values will be returned." + ), + ), ] = None, - selected_age_rating: Annotated[ - str | None, - Query(description="Associated age rating."), + age_ratings: Annotated[ + list[str] | None, + Query( + description=( + "Associated age rating. Multiple values are allowed by repeating" + " the parameter, and results that match any of the values will be returned." + ), + ), ] = None, - selected_status: Annotated[ - str | None, - Query(description="Game status, set by the current user."), + selected_statuses: Annotated[ + list[str] | None, + Query( + description=( + "Game status, set by the current user. Multiple values are allowed by repeating" + " the parameter, and results that match any of the values will be returned." + ), + ), ] = None, - selected_region: Annotated[ - str | None, - Query(description="Associated region tag."), + regions: Annotated[ + list[str] | None, + Query( + description=( + "Associated region tag. Multiple values are allowed by repeating" + " the parameter, and results that match any of the values will be returned." + ), + ), ] = None, - selected_language: Annotated[ - str | None, - Query(description="Associated language tag."), + languages: Annotated[ + list[str] | None, + Query( + description=( + "Associated language tag. Multiple values are allowed by repeating" + " the parameter, and results that match any of the values will be returned." + ), + ), ] = None, + # Logic operators for multi-value filters + genres_logic: Annotated[ + str, + Query( + description="Logic operator for genres filter: 'any' (OR) or 'all' (AND).", + ), + ] = "any", + franchises_logic: Annotated[ + str, + Query( + description="Logic operator for franchises filter: 'any' (OR) or 'all' (AND).", + ), + ] = "any", + collections_logic: Annotated[ + str, + Query( + description="Logic operator for collections filter: 'any' (OR) or 'all' (AND).", + ), + ] = "any", + companies_logic: Annotated[ + str, + Query( + description="Logic operator for companies filter: 'any' (OR) or 'all' (AND).", + ), + ] = "any", + age_ratings_logic: Annotated[ + str, + Query( + description="Logic operator for age ratings filter: 'any' (OR) or 'all' (AND).", + ), + ] = "any", + regions_logic: Annotated[ + str, + Query( + description="Logic operator for regions filter: 'any' (OR) or 'all' (AND).", + ), + ] = "any", + languages_logic: Annotated[ + str, + Query( + description="Logic operator for languages filter: 'any' (OR) or 'all' (AND).", + ), + ] = "any", + statuses_logic: Annotated[ + str, + Query( + description="Logic operator for statuses filter: 'any' (OR) or 'all' (AND).", + ), + ] = "any", order_by: Annotated[ str, Query(description="Field to order results by."), @@ -294,8 +392,6 @@ def get_roms( ] = "asc", ) -> CustomLimitOffsetPage[SimpleRomSchema]: """Retrieve roms.""" - - # Get the base roms query query, order_by_attr = db_rom_handler.get_roms_query( user_id=request.user.id, order_by=order_by.lower(), @@ -306,7 +402,7 @@ def get_roms( query = db_rom_handler.filter_roms( query=query, user_id=request.user.id, - platform_id=platform_id, + platform_ids=platform_ids, collection_id=collection_id, virtual_collection_id=virtual_collection_id, smart_collection_id=smart_collection_id, @@ -314,18 +410,28 @@ def get_roms( matched=matched, favorite=favorite, duplicate=duplicate, + last_played=last_played, playable=playable, has_ra=has_ra, missing=missing, verified=verified, - selected_genre=selected_genre, - selected_franchise=selected_franchise, - selected_collection=selected_collection, - selected_company=selected_company, - selected_age_rating=selected_age_rating, - selected_status=selected_status, - selected_region=selected_region, - selected_language=selected_language, + genres=genres, + franchises=franchises, + collections=collections, + companies=companies, + age_ratings=age_ratings, + selected_statuses=selected_statuses, + regions=regions, + languages=languages, + # Logic operators + genres_logic=genres_logic, + franchises_logic=franchises_logic, + collections_logic=collections_logic, + companies_logic=companies_logic, + age_ratings_logic=age_ratings_logic, + regions_logic=regions_logic, + languages_logic=languages_logic, + statuses_logic=statuses_logic, group_by_meta_id=group_by_meta_id, ) diff --git a/backend/handler/database/collections_handler.py b/backend/handler/database/collections_handler.py index c104a10e3..82798dc47 100644 --- a/backend/handler/database/collections_handler.py +++ b/backend/handler/database/collections_handler.py @@ -250,9 +250,32 @@ class DBCollectionsHandler(DBBaseHandler): # Extract filter criteria criteria = smart_collection.filter_criteria + # Convert legacy single-value criteria to arrays for backward compatibility + def convert_legacy_filter(new_key: str, old_key: str) -> list[str] | None: + """Convert legacy single-value filter to array format.""" + if new_value := criteria.get(new_key): + return new_value if isinstance(new_value, list) else [new_value] + if old_value := criteria.get(old_key): + return [old_value] + return None + + # Apply conversions + genres = convert_legacy_filter("genres", "selected_genre") + franchises = convert_legacy_filter("franchises", "selected_franchise") + collections = convert_legacy_filter("collections", "selected_collection") + companies = convert_legacy_filter("companies", "selected_company") + age_ratings = convert_legacy_filter("age_ratings", "selected_age_rating") + regions = convert_legacy_filter("regions", "selected_region") + languages = convert_legacy_filter("languages", "selected_language") + # Use the existing filter_roms method with the stored criteria + platform_ids = criteria.get("platform_ids") + if platform_ids is None: + if platform_id := criteria.get("platform_id"): + platform_ids = [platform_id] + return db_rom_handler.get_roms_scalar( - platform_id=criteria.get("platform_id"), + platform_ids=platform_ids, collection_id=criteria.get("collection_id"), virtual_collection_id=criteria.get("virtual_collection_id"), search_term=criteria.get("search_term"), @@ -263,14 +286,23 @@ class DBCollectionsHandler(DBBaseHandler): has_ra=criteria.get("has_ra"), missing=criteria.get("missing"), verified=criteria.get("verified"), - selected_genre=criteria.get("selected_genre"), - selected_franchise=criteria.get("selected_franchise"), - selected_collection=criteria.get("selected_collection"), - selected_company=criteria.get("selected_company"), - selected_age_rating=criteria.get("selected_age_rating"), - selected_status=criteria.get("selected_status"), - selected_region=criteria.get("selected_region"), - selected_language=criteria.get("selected_language"), + genres=genres, + franchises=franchises, + collections=collections, + companies=companies, + age_ratings=age_ratings, + selected_statuses=criteria.get("selected_statuses"), + regions=regions, + languages=languages, + # Logic operators for multi-value filters + genres_logic=criteria.get("genres_logic", "any"), + franchises_logic=criteria.get("franchises_logic", "any"), + collections_logic=criteria.get("collections_logic", "any"), + companies_logic=criteria.get("companies_logic", "any"), + age_ratings_logic=criteria.get("age_ratings_logic", "any"), + regions_logic=criteria.get("regions_logic", "any"), + languages_logic=criteria.get("languages_logic", "any"), + statuses_logic=criteria.get("statuses_logic", "any"), user_id=user_id, order_by=criteria.get("order_by", "name"), order_dir=criteria.get("order_dir", "asc"), diff --git a/backend/handler/database/roms_handler.py b/backend/handler/database/roms_handler.py index 825d2273f..700064a70 100644 --- a/backend/handler/database/roms_handler.py +++ b/backend/handler/database/roms_handler.py @@ -29,7 +29,11 @@ from handler.metadata.base_handler import UniversalPlatformSlug as UPS from models.assets import Save, Screenshot, State from models.platform import Platform from models.rom import Rom, RomFile, RomMetadata, RomNote, RomUser -from utils.database import json_array_contains_value +from utils.database import ( + json_array_contains_all, + json_array_contains_any, + json_array_contains_value, +) from .base_handler import DBBaseHandler @@ -202,6 +206,11 @@ class DBRomsHandler(DBBaseHandler): def filter_by_platform_id(self, query: Query, platform_id: int): return query.filter(Rom.platform_id == platform_id) + def filter_by_platform_ids( + self, query: Query, platform_ids: Sequence[int] + ) -> Query: + return query.filter(Rom.platform_id.in_(platform_ids)) + def filter_by_collection_id( self, query: Query, session: Session, collection_id: int ): @@ -250,7 +259,11 @@ class DBRomsHandler(DBBaseHandler): ) def filter_by_matched(self, query: Query, value: bool) -> Query: - """Filter based on whether the rom is matched to a metadata provider.""" + """Filter based on whether the rom is matched to a metadata provider. + + Args: + value: True for matched ROMs, False for unmatched ROMs + """ predicate = or_( Rom.igdb_id.isnot(None), Rom.moby_id.isnot(None), @@ -301,6 +314,20 @@ class DBRomsHandler(DBBaseHandler): predicate = not_(predicate) return query.join(Platform).filter(predicate) + def filter_by_last_played( + self, query: Query, value: bool, user_id: int | None = None + ) -> Query: + """Filter based on whether the rom has a last played value for the user.""" + if not user_id: + return query + + has_last_played = ( + RomUser.last_played.is_(None) + if not value + else RomUser.last_played.isnot(None) + ) + return query.filter(has_last_played) + def filter_by_has_ra(self, query: Query, value: bool) -> Query: predicate = Rom.ra_id.isnot(None) if not value: @@ -336,60 +363,110 @@ class DBRomsHandler(DBBaseHandler): or_(*(Rom.hasheous_metadata[key].as_boolean() for key in keys_to_check)) ) - def filter_by_genre(self, query: Query, session: Session, value: str) -> Query: - return query.filter( - json_array_contains_value(RomMetadata.genres, value, session=session) - ) + def filter_by_genres( + self, + query: Query, + *, + session: Session, + values: Sequence[str], + match_all: bool = False, + ) -> Query: + op = json_array_contains_all if match_all else json_array_contains_any + return query.filter(op(RomMetadata.genres, values, session=session)) - def filter_by_franchise(self, query: Query, session: Session, value: str) -> Query: - return query.filter( - json_array_contains_value(RomMetadata.franchises, value, session=session) - ) + def filter_by_franchises( + self, + query: Query, + *, + session: Session, + values: Sequence[str], + match_all: bool = False, + ) -> Query: + op = json_array_contains_all if match_all else json_array_contains_any + return query.filter(op(RomMetadata.franchises, values, session=session)) - def filter_by_collection(self, query: Query, session: Session, value: str) -> Query: - return query.filter( - json_array_contains_value(RomMetadata.collections, value, session=session) - ) + def filter_by_collections( + self, + query: Query, + *, + session: Session, + values: Sequence[str], + match_all: bool = False, + ) -> Query: + op = json_array_contains_all if match_all else json_array_contains_any + return query.filter(op(RomMetadata.collections, values, session=session)) - def filter_by_company(self, query: Query, session: Session, value: str) -> Query: - return query.filter( - json_array_contains_value(RomMetadata.companies, value, session=session) - ) + def filter_by_companies( + self, + query: Query, + *, + session: Session, + values: Sequence[str], + match_all: bool = False, + ) -> Query: + op = json_array_contains_all if match_all else json_array_contains_any + return query.filter(op(RomMetadata.companies, values, session=session)) - def filter_by_age_rating(self, query: Query, session: Session, value: str) -> Query: - return query.filter( - json_array_contains_value(RomMetadata.age_ratings, value, session=session) - ) + def filter_by_age_ratings( + self, + query: Query, + *, + session: Session, + values: Sequence[str], + match_all: bool = False, + ) -> Query: + op = json_array_contains_all if match_all else json_array_contains_any + return query.filter(op(RomMetadata.age_ratings, values, session=session)) - def filter_by_status(self, query: Query, selected_status: str): - status_filter = RomUser.status == selected_status - if selected_status == "now_playing": - status_filter = RomUser.now_playing.is_(True) - elif selected_status == "backlogged": - status_filter = RomUser.backlogged.is_(True) - elif selected_status == "hidden": - status_filter = RomUser.hidden.is_(True) + def filter_by_status(self, query: Query, selected_statuses: Sequence[str]): + """Filter by one or more user statuses using OR logic.""" + if not selected_statuses: + return query - if selected_status == "hidden": - return query.filter(status_filter) + status_filters = [] + for selected_status in selected_statuses: + if selected_status == "now_playing": + status_filters.append(RomUser.now_playing.is_(True)) + elif selected_status == "backlogged": + status_filters.append(RomUser.backlogged.is_(True)) + elif selected_status == "hidden": + status_filters.append(RomUser.hidden.is_(True)) + else: + status_filters.append(RomUser.status == selected_status) - return query.filter(status_filter, RomUser.hidden.is_(False)) + # If hidden is in the list, don't apply the hidden filter at the end + if "hidden" in selected_statuses: + return query.filter(or_(*status_filters)) - def filter_by_region(self, query: Query, session: Session, value: str) -> Query: - return query.filter( - json_array_contains_value(Rom.regions, value, session=session) - ) + return query.filter(or_(*status_filters), RomUser.hidden.is_(False)) - def filter_by_language(self, query: Query, session: Session, value: str) -> Query: - return query.filter( - json_array_contains_value(Rom.languages, value, session=session) - ) + def filter_by_regions( + self, + query: Query, + *, + session: Session, + values: Sequence[str], + match_all: bool = False, + ) -> Query: + op = json_array_contains_all if match_all else json_array_contains_any + return query.filter(op(Rom.regions, values, session=session)) + + def filter_by_languages( + self, + query: Query, + *, + session: Session, + values: Sequence[str], + match_all: bool = False, + ) -> Query: + op = json_array_contains_all if match_all else json_array_contains_any + return query.filter(op(Rom.languages, values, session=session)) @begin_session def filter_roms( self, query: Query, - platform_id: int | None = None, + platform_ids: Sequence[int] | None = None, collection_id: int | None = None, virtual_collection_id: str | None = None, smart_collection_id: int | None = None, @@ -397,26 +474,37 @@ class DBRomsHandler(DBBaseHandler): matched: bool | None = None, favorite: bool | None = None, duplicate: bool | None = None, + last_played: bool | None = None, playable: bool | None = None, has_ra: bool | None = None, missing: bool | None = None, verified: bool | None = None, group_by_meta_id: bool = False, - selected_genre: str | None = None, - selected_franchise: str | None = None, - selected_collection: str | None = None, - selected_company: str | None = None, - selected_age_rating: str | None = None, - selected_status: str | None = None, - selected_region: str | None = None, - selected_language: str | None = None, + genres: Sequence[str] | None = None, + franchises: Sequence[str] | None = None, + collections: Sequence[str] | None = None, + companies: Sequence[str] | None = None, + age_ratings: Sequence[str] | None = None, + selected_statuses: Sequence[str] | None = None, + regions: Sequence[str] | None = None, + languages: Sequence[str] | None = None, + # Logic operators for multi-value filters + genres_logic: str = "any", + franchises_logic: str = "any", + collections_logic: str = "any", + companies_logic: str = "any", + age_ratings_logic: str = "any", + regions_logic: str = "any", + languages_logic: str = "any", + statuses_logic: str = "any", user_id: int | None = None, session: Session = None, # type: ignore ) -> Query[Rom]: from handler.scan_handler import MetadataSource - if platform_id: - query = self.filter_by_platform_id(query, platform_id) + # Handle platform filtering - platform filtering always uses OR logic since ROMs belong to only one platform + if platform_ids: + query = self.filter_by_platform_ids(query, platform_ids) if collection_id: query = self.filter_by_collection_id(query, session, collection_id) @@ -445,6 +533,11 @@ class DBRomsHandler(DBBaseHandler): if duplicate is not None: query = self.filter_by_duplicate(query, value=duplicate) + if last_played is not None: + query = self.filter_by_last_played( + query, value=last_played, user_id=user_id + ) + if playable is not None: query = self.filter_by_playable(query, value=playable) @@ -561,43 +654,34 @@ class DBRomsHandler(DBBaseHandler): ) ) - if ( - selected_genre - or selected_franchise - or selected_collection - or selected_company - or selected_age_rating - ): + # Optimize JOINs - only join tables when needed + needs_metadata_join = any( + [genres, franchises, collections, companies, age_ratings] + ) + + if needs_metadata_join: query = query.outerjoin(RomMetadata) - if selected_genre: - query = self.filter_by_genre(query, session=session, value=selected_genre) - if selected_franchise: - query = self.filter_by_franchise( - query, session=session, value=selected_franchise - ) - if selected_collection: - query = self.filter_by_collection( - query, session=session, value=selected_collection - ) - if selected_company: - query = self.filter_by_company( - query, session=session, value=selected_company - ) - if selected_age_rating: - query = self.filter_by_age_rating( - query, session=session, value=selected_age_rating - ) - if selected_region: - query = self.filter_by_region(query, session=session, value=selected_region) - if selected_language: - query = self.filter_by_language( - query, session=session, value=selected_language - ) + # Apply metadata and rom-level filters efficiently + filters_to_apply = [ + (genres, genres_logic, self.filter_by_genres), + (franchises, franchises_logic, self.filter_by_franchises), + (collections, collections_logic, self.filter_by_collections), + (companies, companies_logic, self.filter_by_companies), + (age_ratings, age_ratings_logic, self.filter_by_age_ratings), + (regions, regions_logic, self.filter_by_regions), + (languages, languages_logic, self.filter_by_languages), + ] + + for values, logic, filter_func in filters_to_apply: + if values: + query = filter_func( + query, session=session, values=values, match_all=(logic == "all") + ) # The RomUser table is already joined if user_id is set - if selected_status and user_id: - query = self.filter_by_status(query, selected_status) + if selected_statuses and user_id: + query = self.filter_by_status(query, selected_statuses) elif user_id: query = query.filter( or_(RomUser.hidden.is_(False), RomUser.hidden.is_(None)) @@ -662,25 +746,35 @@ class DBRomsHandler(DBBaseHandler): ) roms = self.filter_roms( query=query, - platform_id=kwargs.get("platform_id", None), + platform_ids=kwargs.get("platform_ids", None), collection_id=kwargs.get("collection_id", None), virtual_collection_id=kwargs.get("virtual_collection_id", None), search_term=kwargs.get("search_term", None), matched=kwargs.get("matched", None), favorite=kwargs.get("favorite", None), duplicate=kwargs.get("duplicate", None), + last_played=kwargs.get("last_played", None), playable=kwargs.get("playable", None), has_ra=kwargs.get("has_ra", None), missing=kwargs.get("missing", None), verified=kwargs.get("verified", None), - selected_genre=kwargs.get("selected_genre", None), - selected_franchise=kwargs.get("selected_franchise", None), - selected_collection=kwargs.get("selected_collection", None), - selected_company=kwargs.get("selected_company", None), - selected_age_rating=kwargs.get("selected_age_rating", None), - selected_status=kwargs.get("selected_status", None), - selected_region=kwargs.get("selected_region", None), - selected_language=kwargs.get("selected_language", None), + genres=kwargs.get("genres", None), + franchises=kwargs.get("franchises", None), + collections=kwargs.get("collections", None), + companies=kwargs.get("companies", None), + age_ratings=kwargs.get("age_ratings", None), + selected_statuses=kwargs.get("selected_statuses", None), + regions=kwargs.get("regions", None), + languages=kwargs.get("languages", None), + # Logic operators for multi-value filters + genres_logic=kwargs.get("genres_logic", "any"), + franchises_logic=kwargs.get("franchises_logic", "any"), + collections_logic=kwargs.get("collections_logic", "any"), + companies_logic=kwargs.get("companies_logic", "any"), + age_ratings_logic=kwargs.get("age_ratings_logic", "any"), + regions_logic=kwargs.get("regions_logic", "any"), + languages_logic=kwargs.get("languages_logic", "any"), + statuses_logic=kwargs.get("statuses_logic", "any"), user_id=kwargs.get("user_id", None), ) return session.scalars(roms).all() diff --git a/backend/tasks/manual/cleanup_orphaned_resources.py b/backend/tasks/manual/cleanup_orphaned_resources.py index 337715503..b065c852b 100644 --- a/backend/tasks/manual/cleanup_orphaned_resources.py +++ b/backend/tasks/manual/cleanup_orphaned_resources.py @@ -68,7 +68,7 @@ class CleanupOrphanedResourcesTask(Task): existing_roms_by_platform: dict[int, set[int]] = { platform_id: { rom.id - for rom in db_rom_handler.get_roms_scalar(platform_id=platform_id) + for rom in db_rom_handler.get_roms_scalar(platform_ids=[platform_id]) } for platform_id in existing_platforms } diff --git a/backend/tests/handler/cassettes/test_fastapi/test_scan_rom.yaml b/backend/tests/handler/cassettes/test_fastapi/test_scan_rom.yaml index f218541ee..bc3d67db9 100644 --- a/backend/tests/handler/cassettes/test_fastapi/test_scan_rom.yaml +++ b/backend/tests/handler/cassettes/test_fastapi/test_scan_rom.yaml @@ -16,8 +16,7 @@ interactions: uri: https://playmatch.retrorealm.dev/api/identify/ids?fileName=Paper+Mario+(USA).z64&fileSize=1024 response: body: - string: !!binary | - CxKAeyJnYW1lTWF0Y2hUeXBlIjoiTm9NYXRjaCIsImlkIjpudWxsfQM= + string: '{"gameMatchType":"NoMatch","id":null}' headers: Age: - "81" @@ -29,8 +28,6 @@ interactions: - HIT Connection: - keep-alive - Content-Encoding: - - br Content-Type: - application/json Date: @@ -43,8 +40,6 @@ interactions: - '{"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=mjZ4A7YtYFmLhvxBpW6QPl821crHqZZvQpN%2B8KgRa1zLBnsoeewii%2FWWpbQC7wKBCHw7a818u5WLZOYV60b7as%2Far6YDNSodkqsgt%2BNs4w9z%2F2bhZL22Sw%3D%3D"}]}' Server: - cloudflare - Transfer-Encoding: - - chunked Vary: - accept-encoding X-Ratelimit-Limit: @@ -101,7 +96,7 @@ interactions: message: OK - request: body: - search "paper mario"; fields id,name,slug,summary,total_rating,aggregated_rating,first_release_date,artworks.url,cover.url,screenshots.url,platforms.id,platforms.name,alternative_names.name,genres.name,franchise.name,franchises.name,collections.name,game_modes.name,involved_companies.company.name,expansions.id,expansions.slug,expansions.name,expansions.cover.url,expanded_games.id,expanded_games.slug,expanded_games.name,expanded_games.cover.url,dlcs.id,dlcs.name,dlcs.slug,dlcs.cover.url,remakes.id,remakes.slug,remakes.name,remakes.cover.url,remasters.id,remasters.slug,remasters.name,remasters.cover.url,ports.id,ports.slug,ports.name,ports.cover.url,similar_games.id,similar_games.slug,similar_games.name,similar_games.cover.url,age_ratings.rating_category,videos.video_id; + search "paper mario"; fields id,name,slug,summary,total_rating,aggregated_rating,first_release_date,artworks.url,cover.url,screenshots.url,platforms.id,platforms.name,alternative_names.name,genres.name,franchise.name,franchises.name,collections.name,game_modes.name,involved_companies.company.name,expansions.id,expansions.slug,expansions.name,expansions.cover.url,expanded_games.id,expanded_games.slug,expanded_games.name,expanded_games.cover.url,dlcs.id,dlcs.name,dlcs.slug,dlcs.cover.url,remakes.id,remakes.slug,remakes.name,remakes.cover.url,remasters.id,remasters.slug,remasters.name,remasters.cover.url,ports.id,ports.slug,ports.name,ports.cover.url,similar_games.id,similar_games.slug,similar_games.name,similar_games.cover.url,age_ratings.rating_category,videos.video_id,game_localizations.id,game_localizations.name,game_localizations.cover.url,game_localizations.region.identifier,game_localizations.region.category; where platforms=[4] & game_type=(10,0,11,8,9); limit 200; headers: accept: diff --git a/backend/tests/handler/test_db_handler.py b/backend/tests/handler/test_db_handler.py index 2715ada79..0f373d525 100644 --- a/backend/tests/handler/test_db_handler.py +++ b/backend/tests/handler/test_db_handler.py @@ -1,3 +1,5 @@ +from datetime import datetime, timezone + from sqlalchemy.exc import IntegrityError from handler.auth import auth_handler @@ -47,7 +49,7 @@ def test_roms(rom: Rom, platform: Platform): ) ) - roms = db_rom_handler.get_roms_scalar(platform_id=platform.id) + roms = db_rom_handler.get_roms_scalar(platform_ids=[platform.id]) assert len(roms) == 2 rom_1 = db_rom_handler.get_rom(roms[0].id) @@ -61,15 +63,48 @@ def test_roms(rom: Rom, platform: Platform): db_rom_handler.delete_rom(rom.id) - roms = db_rom_handler.get_roms_scalar(platform_id=platform.id) + roms = db_rom_handler.get_roms_scalar(platform_ids=[platform.id]) assert len(roms) == 1 db_rom_handler.mark_missing_roms(rom_2.platform_id, []) - roms = db_rom_handler.get_roms_scalar(platform_id=platform.id) + roms = db_rom_handler.get_roms_scalar(platform_ids=[platform.id]) assert len(roms) == 1 +def test_filter_last_played(rom: Rom, platform: Platform, admin_user: User): + second_rom = db_rom_handler.add_rom( + Rom( + platform_id=platform.id, + name="test_rom_unplayed", + slug="test_rom_unplayed_slug", + fs_name="test_rom_unplayed.zip", + fs_name_no_tags="test_rom_unplayed", + fs_name_no_ext="test_rom_unplayed", + fs_extension="zip", + fs_path=f"{platform.slug}/roms", + ) + ) + db_rom_handler.add_rom_user(rom_id=second_rom.id, user_id=admin_user.id) + + rom_user = db_rom_handler.get_rom_user(rom.id, admin_user.id) + assert rom_user is not None + + db_rom_handler.update_rom_user( + rom_user.id, {"last_played": datetime(2024, 1, 1, tzinfo=timezone.utc)} + ) + + played_roms = db_rom_handler.get_roms_scalar( + user_id=admin_user.id, last_played=True + ) + assert {r.id for r in played_roms} == {rom.id} + + unplayed_roms = db_rom_handler.get_roms_scalar( + user_id=admin_user.id, last_played=False + ) + assert {r.id for r in unplayed_roms} == {second_rom.id} + + def test_users(admin_user): db_user_handler.add_user( User( 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/backend/utils/gamelist_exporter.py b/backend/utils/gamelist_exporter.py index 020d8c6c4..f68ce7da1 100644 --- a/backend/utils/gamelist_exporter.py +++ b/backend/utils/gamelist_exporter.py @@ -168,7 +168,7 @@ class GamelistExporter: if not platform: raise ValueError(f"Platform with ID {platform_id} not found") - roms = db_rom_handler.get_roms_scalar(platform_id=platform_id) + roms = db_rom_handler.get_roms_scalar(platform_ids=[platform_id]) # Create root element root = Element("gameList") diff --git a/frontend/src/components/Details/MultiNoteManager.vue b/frontend/src/components/Details/MultiNoteManager.vue index cd0b5c9b7..cdb398f31 100644 --- a/frontend/src/components/Details/MultiNoteManager.vue +++ b/frontend/src/components/Details/MultiNoteManager.vue @@ -508,11 +508,11 @@ watch(