diff --git a/backend/alembic/versions/0063_roms_metadata_player_count.py b/backend/alembic/versions/0063_roms_metadata_player_count.py new file mode 100644 index 000000000..b5329a02a --- /dev/null +++ b/backend/alembic/versions/0063_roms_metadata_player_count.py @@ -0,0 +1,402 @@ +"""Add player_count to roms_metadata view + +Revision ID: 0063_roms_metadata_player_count +Revises: 0062_rom_file_category_enum +Create Date: 2026-01-02 14:45:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +from utils.database import is_postgresql + +# revision identifiers, used by Alembic. +revision = "0063_roms_metadata_player_count" +down_revision = "0062_rom_file_category_enum" +branch_labels = None +depends_on = None + + +def upgrade(): + connection = op.get_bind() + if is_postgresql(connection): + connection.execute( + sa.text( + """ + CREATE OR REPLACE VIEW roms_metadata AS + SELECT + r.id AS rom_id, + NOW() AS created_at, + NOW() AS updated_at, + COALESCE( + (r.manual_metadata -> 'genres'), + (r.igdb_metadata -> 'genres'), + (r.moby_metadata -> 'genres'), + (r.ss_metadata -> 'genres'), + (r.launchbox_metadata -> 'genres'), + (r.ra_metadata -> 'genres'), + (r.flashpoint_metadata -> 'genres'), + (r.gamelist_metadata -> 'genres'), + '[]'::jsonb + ) AS genres, + + COALESCE( + (r.manual_metadata -> 'franchises'), + (r.igdb_metadata -> 'franchises'), + (r.ss_metadata -> 'franchises'), + (r.flashpoint_metadata -> 'franchises'), + (r.gamelist_metadata -> 'franchises'), + '[]'::jsonb + ) AS franchises, + + COALESCE( + (r.igdb_metadata -> 'collections'), + '[]'::jsonb + ) AS collections, + + COALESCE( + (r.manual_metadata -> 'companies'), + (r.igdb_metadata -> 'companies'), + (r.ss_metadata -> 'companies'), + (r.ra_metadata -> 'companies'), + (r.launchbox_metadata -> 'companies'), + (r.flashpoint_metadata -> 'companies'), + (r.gamelist_metadata -> 'companies'), + '[]'::jsonb + ) AS companies, + + COALESCE( + (r.manual_metadata -> 'game_modes'), + (r.igdb_metadata -> 'game_modes'), + (r.ss_metadata -> 'game_modes'), + (r.flashpoint_metadata -> 'game_modes'), + '[]'::jsonb + ) AS game_modes, + + COALESCE( + (r.manual_metadata -> 'age_ratings'), + CASE + WHEN r.igdb_metadata IS NOT NULL + AND r.igdb_metadata ? 'age_ratings' + AND jsonb_array_length(r.igdb_metadata -> 'age_ratings') > 0 + THEN + jsonb_path_query_array(r.igdb_metadata, '$.age_ratings[*].rating') + ELSE + '[]'::jsonb + END, + CASE + WHEN r.launchbox_metadata IS NOT NULL + AND r.launchbox_metadata ? 'esrb' + AND r.launchbox_metadata ->> 'esrb' IS NOT NULL + AND r.launchbox_metadata ->> 'esrb' != '' + THEN + jsonb_build_array(r.launchbox_metadata ->> 'esrb') + ELSE + '[]'::jsonb + END, + '[]'::jsonb + ) AS age_ratings, + + COALESCE( + NULLIF(r.manual_metadata ->> 'player_count', '1'), + NULLIF(r.ss_metadata ->> 'player_count', '1'), + NULLIF(r.igdb_metadata ->> 'player_count', '1'), + NULLIF(r.gamelist_metadata ->> 'player_count', '1'), + '1' + ) AS player_count, + + CASE + WHEN r.manual_metadata IS NOT NULL AND r.manual_metadata ? 'first_release_date' AND + r.manual_metadata ->> 'first_release_date' NOT IN ('null', 'None', '0', '0.0') AND + r.manual_metadata ->> 'first_release_date' ~ '^[0-9]+$' + THEN (r.manual_metadata ->> 'first_release_date')::bigint + + WHEN r.igdb_metadata IS NOT NULL AND r.igdb_metadata ? 'first_release_date' AND + r.igdb_metadata ->> 'first_release_date' NOT IN ('null', 'None', '0', '0.0') AND + r.igdb_metadata ->> 'first_release_date' ~ '^[0-9]+$' + THEN (r.igdb_metadata ->> 'first_release_date')::bigint * 1000 + + WHEN r.ss_metadata IS NOT NULL AND r.ss_metadata ? 'first_release_date' AND + r.ss_metadata ->> 'first_release_date' NOT IN ('null', 'None', '0', '0.0') AND + r.ss_metadata ->> 'first_release_date' ~ '^[0-9]+$' + THEN (r.ss_metadata ->> 'first_release_date')::bigint * 1000 + + WHEN r.ra_metadata IS NOT NULL AND r.ra_metadata ? 'first_release_date' AND + r.ra_metadata ->> 'first_release_date' NOT IN ('null', 'None', '0', '0.0') AND + r.ra_metadata ->> 'first_release_date' ~ '^[0-9]+$' + THEN (r.ra_metadata ->> 'first_release_date')::bigint * 1000 + + WHEN r.launchbox_metadata IS NOT NULL AND r.launchbox_metadata ? 'first_release_date' AND + r.launchbox_metadata ->> 'first_release_date' NOT IN ('null', 'None', '0', '0.0') AND + r.launchbox_metadata ->> 'first_release_date' ~ '^[0-9]+$' + THEN (r.launchbox_metadata ->> 'first_release_date')::bigint * 1000 + + WHEN r.flashpoint_metadata IS NOT NULL AND r.flashpoint_metadata ? 'first_release_date' AND + r.flashpoint_metadata ->> 'first_release_date' NOT IN ('null', 'None', '0', '0.0') AND + r.flashpoint_metadata ->> 'first_release_date' ~ '^[0-9]+$' + THEN (r.flashpoint_metadata ->> 'first_release_date')::bigint * 1000 + + WHEN r.gamelist_metadata IS NOT NULL + AND r.gamelist_metadata ? 'first_release_date' + AND r.gamelist_metadata ->> 'first_release_date' NOT IN ('null', 'None', '0', '0.0') + AND r.gamelist_metadata ->> 'first_release_date' ~ '^[0-9]{8}T[0-9]{6}$' + THEN (extract(epoch FROM to_timestamp(r.gamelist_metadata ->> 'first_release_date', 'YYYYMMDD"T"HH24MISS')) * 1000)::bigint + + ELSE NULL + END AS first_release_date, + + CASE + WHEN (igdb_rating IS NOT NULL OR moby_rating IS NOT NULL OR ss_rating IS NOT NULL OR launchbox_rating IS NOT NULL OR gamelist_rating IS NOT NULL) THEN + (COALESCE(igdb_rating, 0) + COALESCE(moby_rating, 0) + COALESCE(ss_rating, 0) + COALESCE(launchbox_rating, 0) + COALESCE(gamelist_rating, 0)) / + (CASE WHEN igdb_rating IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN moby_rating IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN ss_rating IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN launchbox_rating IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN gamelist_rating IS NOT NULL THEN 1 ELSE 0 END) + ELSE NULL + END AS average_rating + FROM ( + SELECT + r.id, + r.manual_metadata, + r.igdb_metadata, + r.moby_metadata, + r.ss_metadata, + r.ra_metadata, + r.launchbox_metadata, + r.flashpoint_metadata, + r.gamelist_metadata, + CASE + WHEN r.igdb_metadata IS NOT NULL AND r.igdb_metadata ? 'total_rating' AND + r.igdb_metadata ->> 'total_rating' NOT IN ('null', 'None', '0', '0.0') AND + r.igdb_metadata ->> 'total_rating' ~ '^[0-9]+(\\.[0-9]+)?$' + THEN (r.igdb_metadata ->> 'total_rating')::float + ELSE NULL + END AS igdb_rating, + CASE + WHEN r.moby_metadata IS NOT NULL AND r.moby_metadata ? 'moby_score' AND + r.moby_metadata ->> 'moby_score' NOT IN ('null', 'None', '0', '0.0') AND + r.moby_metadata ->> 'moby_score' ~ '^[0-9]+(\\.[0-9]+)?$' + THEN (r.moby_metadata ->> 'moby_score')::float * 10 + ELSE NULL + END AS moby_rating, + CASE + WHEN r.ss_metadata IS NOT NULL AND r.ss_metadata ? 'ss_score' AND + r.ss_metadata ->> 'ss_score' NOT IN ('null', 'None', '0', '0.0') AND + r.ss_metadata ->> 'ss_score' ~ '^[0-9]+(\\.[0-9]+)?$' + THEN (r.ss_metadata ->> 'ss_score')::float * 10 + ELSE NULL + END AS ss_rating, + CASE + WHEN r.launchbox_metadata IS NOT NULL AND r.launchbox_metadata ? 'community_rating' AND + r.launchbox_metadata ->> 'community_rating' NOT IN ('null', 'None', '0', '0.0') AND + r.launchbox_metadata ->> 'community_rating' ~ '^[0-9]+(\\.[0-9]+)?$' + THEN (r.launchbox_metadata ->> 'community_rating')::float * 20 + ELSE NULL + END AS launchbox_rating, + CASE + WHEN r.gamelist_metadata IS NOT NULL AND r.gamelist_metadata ? 'rating' AND + r.gamelist_metadata ->> 'rating' NOT IN ('null', 'None', '0', '0.0') AND + r.gamelist_metadata ->> 'rating' ~ '^[0-9]+(\\.[0-9]+)?$' + THEN (r.gamelist_metadata ->> 'rating')::float * 100 + ELSE NULL + END AS gamelist_rating + FROM roms r + ) AS r; + """ + ) + ) + else: + connection.execute( + sa.text( + """ + CREATE OR REPLACE VIEW roms_metadata AS + SELECT + r.id as rom_id, + NOW() AS created_at, + NOW() AS updated_at, + COALESCE( + JSON_EXTRACT(r.manual_metadata, '$.genres'), + JSON_EXTRACT(r.igdb_metadata, '$.genres'), + JSON_EXTRACT(r.moby_metadata, '$.genres'), + JSON_EXTRACT(r.ss_metadata, '$.genres'), + JSON_EXTRACT(r.launchbox_metadata, '$.genres'), + JSON_EXTRACT(r.ra_metadata, '$.genres'), + JSON_EXTRACT(r.flashpoint_metadata, '$.genres'), + JSON_EXTRACT(r.gamelist_metadata, '$.genres'), + JSON_ARRAY() + ) AS genres, + + COALESCE( + JSON_EXTRACT(r.manual_metadata, '$.franchises'), + JSON_EXTRACT(r.igdb_metadata, '$.franchises'), + JSON_EXTRACT(r.ss_metadata, '$.franchises'), + JSON_EXTRACT(r.flashpoint_metadata, '$.franchises'), + JSON_EXTRACT(r.gamelist_metadata, '$.franchises'), + JSON_ARRAY() + ) AS franchises, + + COALESCE( + JSON_EXTRACT(r.igdb_metadata, '$.collections'), + JSON_ARRAY() + ) AS collections, + + COALESCE( + JSON_EXTRACT(r.manual_metadata, '$.companies'), + JSON_EXTRACT(r.igdb_metadata, '$.companies'), + JSON_EXTRACT(r.ss_metadata, '$.companies'), + JSON_EXTRACT(r.ra_metadata, '$.companies'), + JSON_EXTRACT(r.launchbox_metadata, '$.companies'), + JSON_EXTRACT(r.flashpoint_metadata, '$.companies'), + JSON_EXTRACT(r.gamelist_metadata, '$.companies'), + JSON_ARRAY() + ) AS companies, + + COALESCE( + JSON_EXTRACT(r.manual_metadata, '$.game_modes'), + JSON_EXTRACT(r.igdb_metadata, '$.game_modes'), + JSON_EXTRACT(r.ss_metadata, '$.game_modes'), + JSON_EXTRACT(r.flashpoint_metadata, '$.game_modes'), + JSON_ARRAY() + ) AS game_modes, + + COALESCE( + JSON_EXTRACT(r.manual_metadata, '$.age_ratings'), + CASE + WHEN JSON_CONTAINS_PATH(r.igdb_metadata, 'one', '$.age_ratings') + AND JSON_LENGTH(JSON_EXTRACT(r.igdb_metadata, '$.age_ratings')) > 0 + THEN + JSON_EXTRACT(r.igdb_metadata, '$.age_ratings[*].rating') + ELSE + JSON_ARRAY() + END, + CASE + WHEN JSON_CONTAINS_PATH(r.launchbox_metadata, 'one', '$.esrb') + AND JSON_EXTRACT(r.launchbox_metadata, '$.esrb') IS NOT NULL + AND JSON_EXTRACT(r.launchbox_metadata, '$.esrb') != '' + THEN + JSON_ARRAY(JSON_EXTRACT(r.launchbox_metadata, '$.esrb')) + ELSE + JSON_ARRAY() + END, + JSON_ARRAY() + ) AS age_ratings, + + COALESCE( + NULLIF(JSON_UNQUOTE(JSON_EXTRACT(r.manual_metadata, '$.player_count')), '1'), + NULLIF(JSON_UNQUOTE(JSON_EXTRACT(r.ss_metadata, '$.player_count')), '1'), + NULLIF(JSON_UNQUOTE(JSON_EXTRACT(r.igdb_metadata, '$.player_count')), '1'), + NULLIF(JSON_UNQUOTE(JSON_EXTRACT(r.gamelist_metadata, '$.player_count')), '1'), + '1' + ) AS player_count, + + CASE + WHEN JSON_CONTAINS_PATH(r.manual_metadata, 'one', '$.first_release_date') AND + JSON_UNQUOTE(JSON_EXTRACT(r.manual_metadata, '$.first_release_date')) NOT IN ('null', 'None', '0', '0.0') AND + JSON_UNQUOTE(JSON_EXTRACT(r.manual_metadata, '$.first_release_date')) REGEXP '^[0-9]+$' + THEN CAST(JSON_EXTRACT(r.manual_metadata, '$.first_release_date') AS SIGNED) + + WHEN JSON_CONTAINS_PATH(r.igdb_metadata, 'one', '$.first_release_date') AND + JSON_UNQUOTE(JSON_EXTRACT(r.igdb_metadata, '$.first_release_date')) NOT IN ('null', 'None', '0', '0.0') AND + JSON_UNQUOTE(JSON_EXTRACT(r.igdb_metadata, '$.first_release_date')) REGEXP '^[0-9]+$' + THEN CAST(JSON_EXTRACT(r.igdb_metadata, '$.first_release_date') AS SIGNED) * 1000 + + WHEN JSON_CONTAINS_PATH(r.ss_metadata, 'one', '$.first_release_date') AND + JSON_UNQUOTE(JSON_EXTRACT(r.ss_metadata, '$.first_release_date')) NOT IN ('null', 'None', '0', '0.0') AND + JSON_UNQUOTE(JSON_EXTRACT(r.ss_metadata, '$.first_release_date')) REGEXP '^[0-9]+$' + THEN CAST(JSON_EXTRACT(r.ss_metadata, '$.first_release_date') AS SIGNED) * 1000 + + WHEN JSON_CONTAINS_PATH(r.ra_metadata, 'one', '$.first_release_date') AND + JSON_UNQUOTE(JSON_EXTRACT(r.ra_metadata, '$.first_release_date')) NOT IN ('null', 'None', '0', '0.0') AND + JSON_UNQUOTE(JSON_EXTRACT(r.ra_metadata, '$.first_release_date')) REGEXP '^[0-9]+$' + THEN CAST(JSON_EXTRACT(r.ra_metadata, '$.first_release_date') AS SIGNED) * 1000 + + WHEN JSON_CONTAINS_PATH(r.launchbox_metadata, 'one', '$.first_release_date') AND + JSON_UNQUOTE(JSON_EXTRACT(r.launchbox_metadata, '$.first_release_date')) NOT IN ('null', 'None', '0', '0.0') AND + JSON_UNQUOTE(JSON_EXTRACT(r.launchbox_metadata, '$.first_release_date')) REGEXP '^[0-9]+$' + THEN CAST(JSON_EXTRACT(r.launchbox_metadata, '$.first_release_date') AS SIGNED) * 1000 + + WHEN JSON_CONTAINS_PATH(r.flashpoint_metadata, 'one', '$.first_release_date') AND + JSON_UNQUOTE(JSON_EXTRACT(r.flashpoint_metadata, '$.first_release_date')) NOT IN ('null', 'None', '0', '0.0') AND + JSON_UNQUOTE(JSON_EXTRACT(r.flashpoint_metadata, '$.first_release_date')) REGEXP '^[0-9]+$' + THEN CAST(JSON_EXTRACT(r.flashpoint_metadata, '$.first_release_date') AS SIGNED) * 1000 + + WHEN JSON_CONTAINS_PATH(r.gamelist_metadata, 'one', '$.first_release_date') + AND JSON_UNQUOTE(JSON_EXTRACT(r.gamelist_metadata, '$.first_release_date')) NOT IN ('null', 'None', '0', '0.0') + AND JSON_UNQUOTE(JSON_EXTRACT(r.gamelist_metadata, '$.first_release_date')) REGEXP '^[0-9]{8}T[0-9]{6}$' + THEN UNIX_TIMESTAMP( + STR_TO_DATE( + JSON_UNQUOTE(JSON_EXTRACT(r.gamelist_metadata, '$.first_release_date')), + '%Y%m%dT%H%i%S' + ) + ) * 1000 + + ELSE NULL + END AS first_release_date, + + CASE + WHEN (igdb_rating IS NOT NULL OR moby_rating IS NOT NULL OR ss_rating IS NOT NULL OR launchbox_rating IS NOT NULL OR gamelist_rating IS NOT NULL) THEN + (COALESCE(igdb_rating, 0) + COALESCE(moby_rating, 0) + COALESCE(ss_rating, 0) + COALESCE(launchbox_rating, 0) + COALESCE(gamelist_rating, 0)) / + (CASE WHEN igdb_rating IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN moby_rating IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN ss_rating IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN launchbox_rating IS NOT NULL THEN 1 ELSE 0 END + + CASE WHEN gamelist_rating IS NOT NULL THEN 1 ELSE 0 END) + ELSE NULL + END AS average_rating + FROM ( + SELECT + id, + manual_metadata, + igdb_metadata, + moby_metadata, + ss_metadata, + ra_metadata, + launchbox_metadata, + flashpoint_metadata, + gamelist_metadata, + CASE + WHEN JSON_CONTAINS_PATH(igdb_metadata, 'one', '$.total_rating') AND + JSON_UNQUOTE(JSON_EXTRACT(igdb_metadata, '$.total_rating')) NOT IN ('null', 'None', '0', '0.0') AND + JSON_UNQUOTE(JSON_EXTRACT(igdb_metadata, '$.total_rating')) REGEXP '^[0-9]+(\\.[0-9]+)?$' + THEN CAST(JSON_EXTRACT(igdb_metadata, '$.total_rating') AS DECIMAL(10,2)) + ELSE NULL + END AS igdb_rating, + CASE + WHEN JSON_CONTAINS_PATH(moby_metadata, 'one', '$.moby_score') AND + JSON_UNQUOTE(JSON_EXTRACT(moby_metadata, '$.moby_score')) NOT IN ('null', 'None', '0', '0.0') AND + JSON_UNQUOTE(JSON_EXTRACT(moby_metadata, '$.moby_score')) REGEXP '^[0-9]+(\\.[0-9]+)?$' + THEN CAST(JSON_EXTRACT(moby_metadata, '$.moby_score') AS DECIMAL(10,2)) * 10 + ELSE NULL + END AS moby_rating, + CASE + WHEN JSON_CONTAINS_PATH(ss_metadata, 'one', '$.ss_score') AND + JSON_UNQUOTE(JSON_EXTRACT(ss_metadata, '$.ss_score')) NOT IN ('null', 'None', '0', '0.0') AND + JSON_UNQUOTE(JSON_EXTRACT(ss_metadata, '$.ss_score')) REGEXP '^[0-9]+(\\.[0-9]+)?$' + THEN CAST(JSON_EXTRACT(ss_metadata, '$.ss_score') AS DECIMAL(10,2)) * 10 + ELSE NULL + END AS ss_rating, + CASE + WHEN JSON_CONTAINS_PATH(launchbox_metadata, 'one', '$.community_rating') AND + JSON_UNQUOTE(JSON_EXTRACT(launchbox_metadata, '$.community_rating')) NOT IN ('null', 'None', '0', '0.0') AND + JSON_UNQUOTE(JSON_EXTRACT(launchbox_metadata, '$.community_rating')) REGEXP '^[0-9]+(\\.[0-9]+)?$' + THEN CAST(JSON_EXTRACT(launchbox_metadata, '$.community_rating') AS DECIMAL(10,2)) * 20 + ELSE NULL + END AS launchbox_rating, + CASE + WHEN JSON_CONTAINS_PATH(gamelist_metadata, 'one', '$.rating') AND + JSON_UNQUOTE(JSON_EXTRACT(gamelist_metadata, '$.rating')) NOT IN ('null', 'None', '0', '0.0') AND + JSON_UNQUOTE(JSON_EXTRACT(gamelist_metadata, '$.rating')) REGEXP '^[0-9]+(\\.[0-9]+)?$' + THEN CAST(JSON_EXTRACT(gamelist_metadata, '$.rating') AS DECIMAL(10,2)) * 100 + ELSE NULL + END AS gamelist_rating + FROM roms + ) AS r; + """ + ) + ) + + +def downgrade(): + op.execute("DROP VIEW IF EXISTS roms_metadata") diff --git a/backend/endpoints/responses/rom.py b/backend/endpoints/responses/rom.py index ea932a118..1daf8b840 100644 --- a/backend/endpoints/responses/rom.py +++ b/backend/endpoints/responses/rom.py @@ -178,6 +178,7 @@ class RomMetadataSchema(BaseModel): companies: list[str] game_modes: list[str] age_ratings: list[str] + player_count: str first_release_date: int | None average_rating: float | None diff --git a/backend/endpoints/rom.py b/backend/endpoints/rom.py index 708f96ba9..4f5e963bd 100644 --- a/backend/endpoints/rom.py +++ b/backend/endpoints/rom.py @@ -333,6 +333,15 @@ def get_roms( ), ), ] = None, + player_counts: Annotated[ + list[str] | None, + Query( + description=( + "Associated player count. 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, @@ -382,6 +391,12 @@ def get_roms( description="Logic operator for statuses filter: 'any' (OR) or 'all' (AND).", ), ] = "any", + player_counts_logic: Annotated[ + str, + Query( + description="Logic operator for player counts filter: 'any' (OR) or 'all' (AND).", + ), + ] = "any", order_by: Annotated[ str, Query(description="Field to order results by."), @@ -423,6 +438,7 @@ def get_roms( selected_statuses=selected_statuses, regions=regions, languages=languages, + player_counts=player_counts, # Logic operators genres_logic=genres_logic, franchises_logic=franchises_logic, @@ -432,6 +448,7 @@ def get_roms( regions_logic=regions_logic, languages_logic=languages_logic, statuses_logic=statuses_logic, + player_counts_logic=player_counts_logic, group_by_meta_id=group_by_meta_id, ) diff --git a/backend/handler/database/roms_handler.py b/backend/handler/database/roms_handler.py index 700064a70..9078f7fce 100644 --- a/backend/handler/database/roms_handler.py +++ b/backend/handler/database/roms_handler.py @@ -462,6 +462,16 @@ def filter_by_languages( 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( + self, + query: Query, + *, + session: Session, + values: Sequence[str], + match_all: bool = False, + ) -> Query: + return query.filter(RomMetadata.player_count.in_(values)) + @begin_session def filter_roms( self, @@ -488,6 +498,7 @@ def filter_roms( selected_statuses: Sequence[str] | None = None, regions: Sequence[str] | None = None, languages: Sequence[str] | None = None, + player_counts: Sequence[str] | None = None, # Logic operators for multi-value filters genres_logic: str = "any", franchises_logic: str = "any", @@ -497,6 +508,7 @@ def filter_roms( regions_logic: str = "any", languages_logic: str = "any", statuses_logic: str = "any", + player_counts_logic: str = "any", user_id: int | None = None, session: Session = None, # type: ignore ) -> Query[Rom]: @@ -656,7 +668,7 @@ def filter_roms( # Optimize JOINs - only join tables when needed needs_metadata_join = any( - [genres, franchises, collections, companies, age_ratings] + [genres, franchises, collections, companies, age_ratings, player_counts] ) if needs_metadata_join: @@ -671,6 +683,7 @@ def filter_roms( (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: @@ -766,6 +779,7 @@ def get_roms_scalar( selected_statuses=kwargs.get("selected_statuses", None), regions=kwargs.get("regions", None), languages=kwargs.get("languages", None), + player_counts=kwargs.get("player_counts", None), # Logic operators for multi-value filters genres_logic=kwargs.get("genres_logic", "any"), franchises_logic=kwargs.get("franchises_logic", "any"), @@ -775,6 +789,7 @@ def get_roms_scalar( regions_logic=kwargs.get("regions_logic", "any"), languages_logic=kwargs.get("languages_logic", "any"), statuses_logic=kwargs.get("statuses_logic", "any"), + player_counts_logic=kwargs.get("player_counts_logic", "any"), user_id=kwargs.get("user_id", None), ) return session.scalars(roms).all() diff --git a/backend/handler/metadata/igdb_handler.py b/backend/handler/metadata/igdb_handler.py index 9a24a6628..ff5665ecc 100644 --- a/backend/handler/metadata/igdb_handler.py +++ b/backend/handler/metadata/igdb_handler.py @@ -70,6 +70,21 @@ class IGDBRelatedGame(TypedDict): cover_url: str +class IGDBMetadataMultiplayerMode(TypedDict): + campaigncoop: bool + dropin: bool + lancoop: bool + offlinecoop: bool + offlinecoopmax: int + offlinemax: int + onlinecoop: int + onlinecoopmax: int + onlinemax: int + splitscreen: bool + splitscreenonline: bool + platform: IGDBMetadataPlatform + + class IGDBMetadata(TypedDict): total_rating: str aggregated_rating: str @@ -83,6 +98,8 @@ class IGDBMetadata(TypedDict): game_modes: list[str] age_ratings: list[IGDBAgeRating] platforms: list[IGDBMetadataPlatform] + multiplayer_modes: list[IGDBMetadataMultiplayerMode] + player_count: str expansions: list[IGDBRelatedGame] dlcs: list[IGDBRelatedGame] remasters: list[IGDBRelatedGame] @@ -114,7 +131,9 @@ def build_related_game( ) -def extract_metadata_from_igdb_rom(self: MetadataHandler, rom: Game) -> IGDBMetadata: +def extract_metadata_from_igdb_rom( + self: MetadataHandler, rom: Game, platform_igdb_id: int | None +) -> IGDBMetadata: age_ratings = rom.get("age_ratings", []) alternative_names = rom.get("alternative_names", []) collections = rom.get("collections", []) @@ -127,13 +146,13 @@ def extract_metadata_from_igdb_rom(self: MetadataHandler, rom: Game) -> IGDBMeta genres = rom.get("genres", []) involved_companies = rom.get("involved_companies", []) platforms = rom.get("platforms", []) + multiplayer_modes = rom.get("multiplayer_modes", []) ports = rom.get("ports", []) remakes = rom.get("remakes", []) remasters = rom.get("remasters", []) similar_games = rom.get("similar_games", []) videos = rom.get("videos", []) - # Narrow types for expandable fields we requested IGDB to be expanded. assert mark_expanded(franchise) assert mark_list_expanded(age_ratings) assert mark_list_expanded(alternative_names) @@ -146,12 +165,44 @@ def extract_metadata_from_igdb_rom(self: MetadataHandler, rom: Game) -> IGDBMeta assert mark_list_expanded(genres) assert mark_list_expanded(involved_companies) assert mark_list_expanded(platforms) + assert mark_list_expanded(multiplayer_modes) assert mark_list_expanded(ports) assert mark_list_expanded(remakes) assert mark_list_expanded(remasters) assert mark_list_expanded(similar_games) assert mark_list_expanded(videos) + multiplayer_modes_metadata = [] + + for mm in multiplayer_modes: + platform_data = mm.get("platform") + + igdb_id = -1 + name = "" + if isinstance(platform_data, dict): + igdb_id = platform_data.get("id", -1) + name = platform_data.get("name", "") + + multiplayer_modes_metadata.append( + IGDBMetadataMultiplayerMode( + campaigncoop=mm.get("campaigncoop", False), + dropin=mm.get("dropin", False), + lancoop=mm.get("lancoop", False), + offlinecoop=mm.get("offlinecoop", False), + offlinecoopmax=mm.get("offlinecoopmax", 0), + offlinemax=mm.get("offlinemax", 0), + onlinecoop=mm.get("onlinecoop", False), + onlinecoopmax=mm.get("onlinecoopmax", 0), + onlinemax=mm.get("onlinemax", 0), + splitscreen=mm.get("splitscreen", False), + splitscreenonline=mm.get("splitscreenonline", False), + platform=IGDBMetadataPlatform( + igdb_id=igdb_id, + name=name, + ), + ) + ) + return IGDBMetadata( { "youtube_video_id": videos[0].get("video_id") if videos else None, @@ -175,6 +226,10 @@ def extract_metadata_from_igdb_rom(self: MetadataHandler, rom: Game) -> IGDBMeta IGDBMetadataPlatform(igdb_id=p["id"], name=p.get("name", "")) for p in platforms ], + "multiplayer_modes": multiplayer_modes_metadata, + "player_count": derive_player_count( + multiplayer_modes_metadata, platform_igdb_id + ), "age_ratings": [ IGDB_AGE_RATINGS[rating_category] for r in age_ratings @@ -210,6 +265,53 @@ def extract_metadata_from_igdb_rom(self: MetadataHandler, rom: Game) -> IGDBMeta ) +def derive_player_count( + multiplayer_modes: list[IGDBMetadataMultiplayerMode], + platform_igdb_id: int | None = None, +) -> str: + if not multiplayer_modes: + return "1" + + relevant_modes = [ + mm + for mm in multiplayer_modes + if not platform_igdb_id + or (mm.get("platform") and mm["platform"].get("igdb_id") == platform_igdb_id) + ] + + if not relevant_modes: + return "1" + + max_players = 1 + + for mm in relevant_modes: + if any( + mm.get(key, False) + for key in ( + "campaigncoop", + "lancoop", + "offlinecoop", + "onlinecoop", + "dropin", + ) + ): + max_players = max(max_players, 2) + + max_players = max( + max_players, + mm.get("offlinecoopmax", 0), + mm.get("onlinecoopmax", 0), + ) + + max_players = max( + max_players, + mm.get("offlinemax", 0), + mm.get("onlinemax", 0), + ) + + return f"1-{max_players}" if max_players > 1 else "1" + + # Mapping from scan.priority.region codes to IGDB game_localizations region identifiers # IGDB's game_localizations provides regional titles and cover art, but NOT localized descriptions REGION_TO_IGDB_LOCALE: dict[str, str | None] = { @@ -287,7 +389,10 @@ def extract_localized_data(rom: Game, preferred_locale: str | None) -> tuple[str def build_igdb_rom( - handler: "IGDBHandler", rom: Game, preferred_locale: str | None + handler: "IGDBHandler", + rom: Game, + preferred_locale: str | None, + platform_igdb_id: int | None, ) -> "IGDBRom": """Build an IGDBRom from IGDB game data with localization support. @@ -295,6 +400,7 @@ def build_igdb_rom( handler: IGDBHandler instance for URL normalization rom: Game data from IGDB API preferred_locale: Locale code (e.g., "ja-JP") or None + platform_igdb_id: IGDB platform identifier Returns: IGDBRom with localized name/cover if available @@ -316,7 +422,7 @@ def build_igdb_rom( handler.normalize_cover_url(s.get("url", "")).replace("t_thumb", "t_720p") for s in rom_screenshots ], - igdb_metadata=extract_metadata_from_igdb_rom(handler, rom), + igdb_metadata=extract_metadata_from_igdb_rom(handler, rom, platform_igdb_id), ) @@ -575,7 +681,7 @@ async def get_rom(self, fs_name: str, platform_igdb_id: int) -> IGDBRom: if not rom: return fallback_rom - return build_igdb_rom(self, rom, get_igdb_preferred_locale()) + return build_igdb_rom(self, rom, get_igdb_preferred_locale(), platform_igdb_id) async def get_rom_by_id(self, igdb_id: int) -> IGDBRom: if not self.is_enabled(): @@ -589,7 +695,7 @@ async def get_rom_by_id(self, igdb_id: int) -> IGDBRom: if not roms: return IGDBRom(igdb_id=None) - return build_igdb_rom(self, roms[0], get_igdb_preferred_locale()) + return build_igdb_rom(self, roms[0], get_igdb_preferred_locale(), None) async def get_matched_rom_by_id(self, igdb_id: int) -> IGDBRom | None: if not self.is_enabled(): @@ -651,7 +757,10 @@ async def get_matched_roms_by_name( ] preferred_locale = get_igdb_preferred_locale() - return [build_igdb_rom(self, rom, preferred_locale) for rom in matched_roms] + return [ + build_igdb_rom(self, rom, preferred_locale, platform_igdb_id) + for rom in matched_roms + ] class TwitchAuth(MetadataHandler): @@ -785,6 +894,20 @@ async def get_oauth_token(self) -> str: "game_localizations.cover.url", "game_localizations.region.identifier", "game_localizations.region.category", + "multiplayer_modes.campaigncoop", + "multiplayer_modes.checksum", + "multiplayer_modes.dropin", + "multiplayer_modes.lancoop", + "multiplayer_modes.offlinecoop", + "multiplayer_modes.offlinecoopmax", + "multiplayer_modes.offlinemax", + "multiplayer_modes.onlinecoop", + "multiplayer_modes.onlinecoopmax", + "multiplayer_modes.onlinemax", + "multiplayer_modes.splitscreen", + "multiplayer_modes.splitscreenonline", + "multiplayer_modes.platform.id", + "multiplayer_modes.platform.name", ) diff --git a/backend/handler/metadata/ss_handler.py b/backend/handler/metadata/ss_handler.py index 3a370b4f2..4c7d6f7dc 100644 --- a/backend/handler/metadata/ss_handler.py +++ b/backend/handler/metadata/ss_handler.py @@ -128,6 +128,7 @@ class SSMetadata(SSMetadataMedia): franchises: list[str] game_modes: list[str] genres: list[str] + player_count: str class SSRom(BaseRom): @@ -352,6 +353,12 @@ def _get_game_modes(game: SSGame) -> list[str]: return modes return [] + def _get_player_count(game: SSGame) -> str: + player_count = game.get("joueurs", {}).get("text") + if not player_count or str(player_count).lower() in ("null", "none"): + return "1" + return str(player_count) + return SSMetadata( { "ss_score": _normalize_score(game.get("note", {}).get("text", "")), @@ -366,6 +373,7 @@ def _get_game_modes(game: SSGame) -> list[str]: "first_release_date": _get_lowest_date(game.get("dates", [])), "franchises": _get_franchises(game), "game_modes": _get_game_modes(game), + "player_count": _get_player_count(game), **extract_media_from_ss_game(rom, game), } ) diff --git a/backend/models/rom.py b/backend/models/rom.py index f0337ab6b..fafc00f0b 100644 --- a/backend/models/rom.py +++ b/backend/models/rom.py @@ -136,6 +136,7 @@ class RomMetadata(BaseModel): companies: Mapped[list[str] | None] = mapped_column(CustomJSON(), default=[]) game_modes: Mapped[list[str] | None] = mapped_column(CustomJSON(), default=[]) age_ratings: Mapped[list[str] | None] = mapped_column(CustomJSON(), default=[]) + player_count: Mapped[str | None] = mapped_column(String(length=100), default="1") first_release_date: Mapped[int | None] = mapped_column(BigInteger(), default=None) average_rating: Mapped[float | None] = mapped_column(default=None) diff --git a/backend/tests/endpoints/test_rom.py b/backend/tests/endpoints/test_rom.py index dda57c8d9..5d2de122b 100644 --- a/backend/tests/endpoints/test_rom.py +++ b/backend/tests/endpoints/test_rom.py @@ -88,6 +88,7 @@ def test_update_rom( "genres": '[{"id": 5, "name": "Shooter"}, {"id": 8, "name": "Platform"}, {"id": 31, "name": "Adventure"}]', "franchises": '[{"id": 756, "name": "Metroid"}]', "collections": '[{"id": 243, "name": "Metroid"}, {"id": 6240, "name": "Metroid Prime"}]', + "player_count": "1", "expansions": "[]", "dlcs": "[]", "companies": '[{"id": 203227, "company": {"id": 70, "name": "Nintendo"}}, {"id": 203307, "company": {"id": 766, "name": "Retro Studios"}}]', @@ -380,6 +381,7 @@ def test_update_raw_ss_metadata( raw_metadata = { "ss_score": "85", "alternative_names": ["Test SS Game"], + "player_count": "1-4", } response = client.put( @@ -396,6 +398,7 @@ def test_update_raw_ss_metadata( assert body["ss_metadata"] is not None assert body["ss_metadata"]["ss_score"] == "85" assert body["ss_metadata"]["alternative_names"] == ["Test SS Game"] + assert body["ss_metadata"]["player_count"] == "1-4" @patch.object( LaunchboxHandler, diff --git a/frontend/src/__generated__/models/RomMetadataSchema.ts b/frontend/src/__generated__/models/RomMetadataSchema.ts index 6a94e3c9d..cd9131ae5 100644 --- a/frontend/src/__generated__/models/RomMetadataSchema.ts +++ b/frontend/src/__generated__/models/RomMetadataSchema.ts @@ -10,6 +10,7 @@ export type RomMetadataSchema = { companies: Array; game_modes: Array; age_ratings: Array; + player_count: (string | null); first_release_date: (number | null); average_rating: (number | null); }; diff --git a/frontend/src/__generated__/models/RomSSMetadata.ts b/frontend/src/__generated__/models/RomSSMetadata.ts index 2bbb83469..e7e3827d9 100644 --- a/frontend/src/__generated__/models/RomSSMetadata.ts +++ b/frontend/src/__generated__/models/RomSSMetadata.ts @@ -36,5 +36,6 @@ export type RomSSMetadata = { franchises?: Array; game_modes?: Array; genres?: Array; + player_count?: (string | null); }; diff --git a/frontend/src/components/Details/Info/GameInfo.vue b/frontend/src/components/Details/Info/GameInfo.vue index ed5c2757f..ef5d4cdb9 100644 --- a/frontend/src/components/Details/Info/GameInfo.vue +++ b/frontend/src/components/Details/Info/GameInfo.vue @@ -33,6 +33,11 @@ const filters = [ name: t("rom.collections"), }, { key: "company", path: "metadatum.companies", name: t("rom.companies") }, + { + key: "playerCount", + path: "metadatum.player_count", + name: t("rom.player-count"), + }, ] as const; const dataSources = computed(() => { @@ -172,6 +177,14 @@ function onFilterClick(filter: FilterType, value: string) { query: { [filter]: value }, }); } + +function getFilterValues(path: string): string[] { + const value = get(props.rom, path); + if (Array.isArray(value)) { + return value.filter((v: unknown): v is string => !!v); + } + return value ? [String(value)] : []; +}