Added player count metadata from SS. Displays on game detail screen and added a filter under search for player counts.

This commit is contained in:
DevOldSchool
2026-01-05 11:57:30 +10:00
parent 9fe49ba0bf
commit 86a6804447
17 changed files with 539 additions and 13 deletions

View File

@@ -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")

View File

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

View File

@@ -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,
)

View File

@@ -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()

View File

@@ -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),
}
)

View File

@@ -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)

View File

@@ -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,

View File

@@ -10,6 +10,7 @@ export type RomMetadataSchema = {
companies: Array<string>;
game_modes: Array<string>;
age_ratings: Array<string>;
player_count: (string | null);
first_release_date: (number | null);
average_rating: (number | null);
};

View File

@@ -36,5 +36,6 @@ export type RomSSMetadata = {
franchises?: Array<string>;
game_modes?: Array<string>;
genres?: Array<string>;
player_count?: (string | null);
};

View File

@@ -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) {
<span>{{ filter.name }}</span>
</v-col>
<v-col>
<v-chip
v-for="value in get(rom, filter.path)"
:key="value"
size="small"
variant="outlined"
class="my-1 mr-2"
label
@click="onFilterClick(filter.key, value)"
>
{{ value }}
</v-chip>
<template v-if="Array.isArray(get(rom, filter.path))">
<v-chip
v-for="value in get(rom, filter.path).filter((v: string) => !!v)"
:key="value"
size="small"
variant="outlined"
class="my-1 mr-2"
label
@click="onFilterClick(filter.key, value)"
>
{{ value }}
</v-chip>
</template>
<template v-else-if="get(rom, filter.path)">
<v-chip
size="small"
variant="outlined"
class="my-1 mr-2"
label
@click="onFilterClick(filter.key, get(rom, filter.path))"
>
{{ get(rom, filter.path) }}
</v-chip>
</template>
</v-col>
</v-row>
</template>

View File

@@ -75,6 +75,9 @@ const {
filterLanguages,
selectedLanguages,
languagesLogic,
filterPlayerCounts,
selectedPlayerCounts,
playerCountsLogic,
} = storeToRefs(galleryFilterStore);
const { allPlatforms } = storeToRefs(platformsStore);
const emitter = inject<Emitter<Events>>("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) {

View File

@@ -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?",

View File

@@ -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",

View File

@@ -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 } : {}),

View File

@@ -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 }
: {}),

View File

@@ -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";
},
},
});

View File

@@ -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;
},