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..404db3086 --- /dev/null +++ b/backend/alembic/versions/0063_roms_metadata_player_count.py @@ -0,0 +1,390 @@ +"""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(r.ss_metadata ->> 'player_count', '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(JSON_UNQUOTE(JSON_EXTRACT(r.ss_metadata, '$.player_count')), '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..6e9b9bee1 100644 --- a/backend/handler/database/roms_handler.py +++ b/backend/handler/database/roms_handler.py @@ -462,6 +462,16 @@ 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( + 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 @@ class DBRomsHandler(DBBaseHandler): 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 @@ class DBRomsHandler(DBBaseHandler): 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 @@ class DBRomsHandler(DBBaseHandler): # 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 @@ class DBRomsHandler(DBBaseHandler): (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 @@ class DBRomsHandler(DBBaseHandler): 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 @@ class DBRomsHandler(DBBaseHandler): 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/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 extract_metadata_from_ss_rom(rom: Rom, game: SSGame) -> SSMetadata: 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 extract_metadata_from_ss_rom(rom: Rom, game: SSGame) -> SSMetadata: "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..fa5d3e3e7 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..60cfc6761 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 @@ class TestUpdateRawMetadata: raw_metadata = { "ss_score": "85", "alternative_names": ["Test SS Game"], + "player_count": "1-4", } response = client.put( @@ -396,6 +398,7 @@ class TestUpdateRawMetadata: 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..dbb4e4863 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: "players", + path: "metadatum.player_count", + name: t("rom.player-count"), + }, ] as const; const dataSources = computed(() => { @@ -217,17 +222,30 @@ function onFilterClick(filter: FilterType, value: string) { {{ filter.name }} - - {{ value }} - + + diff --git a/frontend/src/components/Gallery/AppBar/common/FilterDrawer/Base.vue b/frontend/src/components/Gallery/AppBar/common/FilterDrawer/Base.vue index 3ac17a956..ec76bb90b 100644 --- a/frontend/src/components/Gallery/AppBar/common/FilterDrawer/Base.vue +++ b/frontend/src/components/Gallery/AppBar/common/FilterDrawer/Base.vue @@ -75,6 +75,9 @@ const { filterLanguages, selectedLanguages, languagesLogic, + filterPlayerCounts, + selectedPlayerCounts, + playerCountsLogic, } = storeToRefs(galleryFilterStore); const { allPlatforms } = storeToRefs(platformsStore); const emitter = inject>("emitter"); @@ -150,6 +153,12 @@ const onFilterChange = debounce( : null, statusesLogic: selectedStatuses.value.length > 1 ? statusesLogic.value : null, + playerCounts: + selectedPlayerCounts.value.length > 0 + ? selectedPlayerCounts.value.join(",") + : 1, + playerCountsLogic: + selectedPlayerCounts.value.length > 0 ? playerCountsLogic.value : 1, }).forEach(([key, value]) => { if (value) { url.searchParams.set(key, value); @@ -234,6 +243,14 @@ const filters = [ setLogic: (logic: "any" | "all") => galleryFilterStore.setLanguagesLogic(logic), }, + { + label: t("platform.player-count"), + selected: selectedPlayerCounts, + items: filterPlayerCounts, + logic: playerCountsLogic, + setLogic: (logic: "any" | "all") => + galleryFilterStore.setPlayerCountsLogic(logic), + }, { label: t("platform.status"), selected: selectedStatuses, @@ -341,6 +358,10 @@ function setFilters() { galleryFilterStore.setFilterLanguages([ ...new Set(romsForFilters.flatMap((rom) => rom.languages).sort()), ]); + galleryFilterStore.setFilterPlayerCounts([ + ...new Set(romsForFilters.flatMap((rom) => rom.metadatum.player_count).sort(), + ), +]); // Note: filterStatuses is static and doesn't need to be set dynamically } @@ -373,6 +394,8 @@ onMounted(async () => { languagesLogic: urlLanguagesLogic, statuses: urlStatuses, statusesLogic: urlStatusesLogic, + playerCounts: urlPlayerCounts, + playerCountsLogic: urlPlayerCountsLogic, } = router.currentRoute.value.query; // Check for query params to set filters @@ -535,6 +558,14 @@ onMounted(async () => { } } + if (urlPlayerCounts !== undefined) { + const playerCounts = (urlPlayerCounts as string).split(",").filter((pc) => pc.trim()); + galleryFilterStore.setSelectedFilterPlayerCounts(playerCounts); + if (urlPlayerCountsLogic !== undefined) { + galleryFilterStore.setPlayerCountsLogic(urlPlayerCountsLogic as "any" | "all"); + } + } + // Check if search term is set in the URL (empty string is ok) const freshSearch = urlSearch !== undefined && urlSearch !== searchTerm.value; if (freshSearch) { diff --git a/frontend/src/locales/en_US/platform.json b/frontend/src/locales/en_US/platform.json index 1a20a177f..fdc35f8fa 100644 --- a/frontend/src/locales/en_US/platform.json +++ b/frontend/src/locales/en_US/platform.json @@ -24,6 +24,7 @@ "no-firmware-found": "No firmware found", "old-horizontal-cases": "Old horizontal cases", "old-squared-cases": "Old squared cases", + "player-count": "Player count", "region": "Region", "removing-platform-1": "Removing platform", "removing-platform-2": "] from RomM. Do you confirm?", diff --git a/frontend/src/locales/en_US/rom.json b/frontend/src/locales/en_US/rom.json index c3ef570a4..58b1e1d7d 100644 --- a/frontend/src/locales/en_US/rom.json +++ b/frontend/src/locales/en_US/rom.json @@ -53,6 +53,7 @@ "no-states-found": "No states found", "now-playing": "Now playing", "personal": "Personal", + "player-count": "Players", "public-notes": "Public notes", "rating": "Rating", "refresh-metadata": "Refresh metadata", diff --git a/frontend/src/services/api/rom.ts b/frontend/src/services/api/rom.ts index 5045fc9a2..bdfd898d1 100644 --- a/frontend/src/services/api/rom.ts +++ b/frontend/src/services/api/rom.ts @@ -87,6 +87,7 @@ export interface GetRomsParams { selectedAgeRatings?: string[] | null; selectedRegions?: string[] | null; selectedLanguages?: string[] | null; + selectedPlayerCounts?: string[] | null; selectedStatuses?: string[] | null; // Logic operators for multi-value filters genresLogic?: string | null; @@ -97,6 +98,7 @@ export interface GetRomsParams { regionsLogic?: string | null; languagesLogic?: string | null; statusesLogic?: string | null; + playerCountsLogic?: string | null; } async function getRoms({ @@ -124,6 +126,7 @@ async function getRoms({ selectedAgeRatings = null, selectedRegions = null, selectedLanguages = null, + selectedPlayerCounts = null, selectedStatuses = null, // Logic operators genresLogic = null, @@ -134,6 +137,7 @@ async function getRoms({ regionsLogic = null, languagesLogic = null, statusesLogic = null, + playerCountsLogic = null, }: GetRomsParams): Promise<{ data: GetRomsResponse }> { const params = { platform_ids: @@ -177,6 +181,10 @@ async function getRoms({ selectedLanguages && selectedLanguages.length > 0 ? selectedLanguages : undefined, + player_counts: + selectedPlayerCounts && selectedPlayerCounts.length > 0 + ? selectedPlayerCounts + : undefined, // Logic operators genres_logic: selectedGenres && selectedGenres.length > 1 @@ -210,6 +218,10 @@ async function getRoms({ selectedStatuses && selectedStatuses.length > 1 ? statusesLogic || "any" : undefined, + player_counts_logic: + selectedPlayerCounts && selectedPlayerCounts.length > 1 + ? playerCountsLogic || "any" + : undefined, ...(filterMatched !== null ? { matched: filterMatched } : {}), ...(filterFavorites !== null ? { favorite: filterFavorites } : {}), ...(filterDuplicates !== null ? { duplicate: filterDuplicates } : {}), diff --git a/frontend/src/services/cache/api.ts b/frontend/src/services/cache/api.ts index 8138b17ed..3004b8de9 100644 --- a/frontend/src/services/cache/api.ts +++ b/frontend/src/services/cache/api.ts @@ -74,6 +74,10 @@ class CachedApiService { params.selectedLanguages && params.selectedLanguages.length > 0 ? params.selectedLanguages : undefined, + player_counts: + params.selectedPlayerCounts && params.selectedPlayerCounts.length > 0 + ? params.selectedPlayerCounts + : undefined, // Logic operators genres_logic: params.selectedGenres && params.selectedGenres.length > 1 @@ -107,6 +111,10 @@ class CachedApiService { params.selectedStatuses && params.selectedStatuses.length > 1 ? params.statusesLogic || "any" : undefined, + player_counts_logic: + params.selectedPlayerCounts && params.selectedPlayerCounts.length > 1 + ? params.playerCountsLogic || "any" + : undefined, ...(params.filterMatched !== null ? { matched: params.filterMatched } : {}), diff --git a/frontend/src/stores/galleryFilter.ts b/frontend/src/stores/galleryFilter.ts index af86b21a4..f00c02f77 100644 --- a/frontend/src/stores/galleryFilter.ts +++ b/frontend/src/stores/galleryFilter.ts @@ -12,7 +12,8 @@ export type FilterType = | "ageRating" | "status" | "region" - | "language"; + | "language" + | "playerCount"; const defaultFilterState = { activeFilterDrawer: false, @@ -25,6 +26,7 @@ const defaultFilterState = { filterAgeRatings: [] as string[], filterRegions: [] as string[], filterLanguages: [] as string[], + filterPlayerCounts: [] as string[], filterStatuses: Object.values(romStatusMap).map((status) => status.text), filterMatched: null as boolean | null, // null = all, true = matched, false = unmatched filterFavorites: null as boolean | null, // null = all, true = favorites, false = not favorites @@ -42,6 +44,7 @@ const defaultFilterState = { selectedAgeRatings: [] as string[], selectedRegions: [] as string[], selectedLanguages: [] as string[], + selectedPlayerCounts: [] as string[], selectedStatuses: [] as string[], // Logic operators for multi-select filters genresLogic: "any" as "any" | "all", @@ -52,6 +55,7 @@ const defaultFilterState = { regionsLogic: "any" as "any" | "all", languagesLogic: "any" as "any" | "all", statusesLogic: "any" as "any" | "all", + playerCountsLogic: "any" as "any" | "all", }; export default defineStore("galleryFilter", { @@ -85,6 +89,9 @@ export default defineStore("galleryFilter", { setFilterLanguages(languages: string[]) { this.filterLanguages = languages; }, + setFilterPlayerCounts(playerCounts: string[]) { + this.filterPlayerCounts = playerCounts; + }, setSelectedFilterPlatform(platform: Platform) { this.selectedPlatform = platform ? this.filterPlatforms.find((p) => p.id === platform.id) || null @@ -137,6 +144,12 @@ export default defineStore("galleryFilter", { setLanguagesLogic(logic: "any" | "all") { this.languagesLogic = logic; }, + setSelectedFilterPlayerCounts(playerCounts: string[]) { + this.selectedPlayerCounts = playerCounts; + }, + setPlayerCountsLogic(logic: "any" | "all") { + this.playerCountsLogic = logic; + }, setSelectedFilterStatuses(statuses: string[]) { this.selectedStatuses = statuses; }, @@ -336,6 +349,7 @@ export default defineStore("galleryFilter", { this.selectedAgeRatings.length > 0 || this.selectedRegions.length > 0 || this.selectedLanguages.length > 0 || + this.selectedPlayerCounts.length > 0 || this.selectedStatuses.length > 0, ); }, @@ -352,6 +366,7 @@ export default defineStore("galleryFilter", { this.selectedAgeRatings = []; this.selectedRegions = []; this.selectedLanguages = []; + this.selectedPlayerCounts = []; this.selectedStatuses = []; this.filterMatched = null; this.filterFavorites = null; @@ -369,6 +384,7 @@ export default defineStore("galleryFilter", { this.regionsLogic = "any"; this.languagesLogic = "any"; this.statusesLogic = "any"; + this.playerCountsLogic = "any"; }, }, }); diff --git a/frontend/src/stores/roms.ts b/frontend/src/stores/roms.ts index ccc5c4091..7c077e8e5 100644 --- a/frontend/src/stores/roms.ts +++ b/frontend/src/stores/roms.ts @@ -135,6 +135,7 @@ export default defineStore("roms", { selectedAgeRatings: galleryFilter.selectedAgeRatings, selectedRegions: galleryFilter.selectedRegions, selectedLanguages: galleryFilter.selectedLanguages, + selectedPlayerCounts: galleryFilter.selectedPlayerCounts, selectedStatuses: galleryFilter.selectedStatuses, // Logic operators genresLogic: galleryFilter.genresLogic, @@ -145,6 +146,7 @@ export default defineStore("roms", { regionsLogic: galleryFilter.regionsLogic, languagesLogic: galleryFilter.languagesLogic, statusesLogic: galleryFilter.statusesLogic, + playerCountsLogic: galleryFilter.playerCountsLogic, }; return params; },