Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
402 changes: 402 additions & 0 deletions backend/alembic/versions/0063_roms_metadata_player_count.py

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions backend/endpoints/responses/rom.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
17 changes: 17 additions & 0 deletions backend/endpoints/rom.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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."),
Expand Down Expand Up @@ -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,
Expand All @@ -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,
)

Expand Down
17 changes: 16 additions & 1 deletion backend/handler/database/roms_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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",
Expand All @@ -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]:
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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"),
Expand All @@ -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()
Expand Down
137 changes: 130 additions & 7 deletions backend/handler/metadata/igdb_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]
Expand Down Expand Up @@ -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", [])
Expand All @@ -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)
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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] = {
Expand Down Expand Up @@ -287,14 +389,18 @@ 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.

Args:
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
Expand All @@ -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),
)


Expand Down Expand Up @@ -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():
Expand All @@ -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():
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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",
)


Expand Down
8 changes: 8 additions & 0 deletions backend/handler/metadata/ss_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ class SSMetadata(SSMetadataMedia):
franchises: list[str]
game_modes: list[str]
genres: list[str]
player_count: str


class SSRom(BaseRom):
Expand Down Expand Up @@ -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", "")),
Expand All @@ -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),
}
)
Expand Down
Loading
Loading