diff --git a/backend/endpoints/roms/__init__.py b/backend/endpoints/roms/__init__.py index 49336e286c..d8257874c7 100644 --- a/backend/endpoints/roms/__init__.py +++ b/backend/endpoints/roms/__init__.py @@ -54,6 +54,10 @@ ) from handler.database import db_rom_handler, db_save_handler from handler.database.base_handler import sync_session +from handler.database.roms_handler import ( + MAX_SIMILAR_ROMS_LIMIT, + SIMILAR_ROMS_LIMIT, +) from handler.filesystem import fs_resource_handler, fs_rom_handler from handler.filesystem.assets_handler import validate_image_upload from handler.metadata import ( @@ -1113,6 +1117,60 @@ def get_rom_simple( return SimpleRomSchema.from_orm_with_request(rom, request) +@protected_route( + router.get, + "/{id}/similar", + [Scope.ROMS_READ], + responses={status.HTTP_404_NOT_FOUND: {}}, +) +def get_similar_roms( + request: Request, + id: Annotated[int, PathVar(description="Rom internal id.", ge=1)], + limit: Annotated[ + int, + Query( + description="Max number of similar ROMs to return.", + ge=1, + le=MAX_SIMILAR_ROMS_LIMIT, + ), + ] = SIMILAR_ROMS_LIMIT, +) -> list[SimpleRomSchema]: + """Return library ROMs similar to the given ROM. + + Similarity is computed from the normalized `RomMetadata` signals + (franchises, collections, genres, companies, age ratings), so only ROMs + already in the library are returned. ROMs hidden from the caller are + excluded.""" + + rom = db_rom_handler.get_rom_simple(id) + + if not rom: + raise RomNotFoundInDatabaseException(id) + + assert_rom_visible(request, rom) + + # The ranking is user-independent and cached; apply this caller's + # visibility filter here (using the cached platform ids) and hydrate only + # the roms that survive, up to the requested limit. + candidates = db_rom_handler.get_similar_rom_candidates(rom) + if not candidates: + return [] + + perms = get_permissions(request) + visible_ids = [ + rom_id + for rom_id, platform_id in candidates + if platform_id not in perms.hidden_platform_ids + and rom_id not in perms.hidden_rom_ids + ][:limit] + if not visible_ids: + return [] + + roms_by_id = {r.id: r for r in db_rom_handler.get_roms_by_ids(visible_ids)} + ordered = [roms_by_id[i] for i in visible_ids if i in roms_by_id] + return [SimpleRomSchema.from_orm_with_request(r, request) for r in ordered] + + @protected_route( router.get, "/{id}", diff --git a/backend/handler/database/roms_handler.py b/backend/handler/database/roms_handler.py index 772b769fb8..771c7e55a5 100644 --- a/backend/handler/database/roms_handler.py +++ b/backend/handler/database/roms_handler.py @@ -66,6 +66,31 @@ from .base_handler import DBBaseHandler +# Default and hard cap for the "similar games in your library" list. +SIMILAR_ROMS_LIMIT = 6 +MAX_SIMILAR_ROMS_LIMIT = 24 +# Per-signal weights for the metadata-overlap similarity score. Series +# membership (franchise/collection) dominates, then genre, then the studio, +# with age rating as a light tiebreaker. +SIMILARITY_WEIGHTS = { + "franchises": 5.0, + "collections": 4.0, + "genres": 3.0, + "companies": 2.0, + "age_ratings": 1.0, +} +# Multiplier applied to a candidate's score when it shares the target's +# platform, so same-console matches float to the top without eclipsing a +# strong cross-platform series match. +SAME_PLATFORM_BOOST = 1.25 +# Upper bound on weak-signal (genre/company/age) candidates pulled for scoring; +# strong-signal (franchise/collection) candidates are always scored in full. +MAX_SIMILAR_CANDIDATES = 400 +# How many ranked candidates to cache per ROM. Larger than +# MAX_SIMILAR_ROMS_LIMIT so per-user visibility filtering has headroom before +# the endpoint slices to the requested limit. +SIMILAR_ROMS_CACHE_SIZE = 60 + EJS_SUPPORTED_PLATFORMS = [ UPS._3DO, UPS.AMIGA, @@ -153,6 +178,12 @@ def _filter_values_redis_key(cache_key: str, version: str) -> str: return f"filter_values:{ROM_FILTERS_CACHE_SCHEMA_VERSION}:{cache_key}:v{version}" +def _similar_roms_redis_key(rom_id: int, version: str) -> str: + # Shares the filter-values cache version so `invalidate_filter_values_cache` + # (called on every scan / ROM write) also drops stale similarity rankings. + return f"similar_roms:{ROM_FILTERS_CACHE_SCHEMA_VERSION}:{rom_id}:v{version}" + + def _store_versioned_cache(redis_key: str, version: str, result: Any) -> None: version_keys_set = _filter_values_cache_keys_key(version) with sync_cache.pipeline() as pipe: @@ -338,6 +369,136 @@ def get_roms_by_ids( return [] return session.scalars(query.filter(Rom.id.in_(ids))).all() + @begin_session + def get_similar_rom_candidates( + self, + rom: Rom, + *, + session: Session = None, # type: ignore + ) -> list[tuple[int, int]]: + """Ranked `(rom_id, platform_id)` library candidates similar to `rom`. + + Result is user-independent (no visibility filtering) so it is shared + across callers and cached: the expensive part is materializing the + `roms_metadata` view over the whole library, which is a database VIEW + derived from the per-provider JSON columns and cannot be indexed. The + endpoint applies per-user hidden-platform / hidden-rom filtering and + slices to the requested limit. Purely metadata-driven, so unidentified + ROMs (no `RomMetadata` row, or empty signals) yield an empty list. + """ + version = _filter_values_cache_version() + redis_key = _similar_roms_redis_key(rom.id, version) + cached = sync_cache.get(redis_key) + if cached is not None: + return [(pair[0], pair[1]) for pair in json.loads(cached)] + + candidates = self._compute_similar_rom_candidates(rom, session=session) + _store_versioned_cache(redis_key, version, candidates) + return candidates + + def _compute_similar_rom_candidates( + self, rom: Rom, *, session: Session + ) -> list[tuple[int, int]]: + target = session.get(RomMetadata, rom.id) + if target is None: + return [] + + signals = { + field: [v for v in (getattr(target, field) or []) if v] + for field in SIMILARITY_WEIGHTS + } + if not any(signals.values()): + return [] + + # Sibling ROMs are the same game (different dump/region); never + # surface them as "similar". Match both directions of the pairing + # rather than assuming the view stores symmetric rows. + sibling_ids = set( + session.scalars( + select(SiblingRom.sibling_rom_id).where(SiblingRom.rom_id == rom.id) + ).all() + ) | set( + session.scalars( + select(SiblingRom.rom_id).where(SiblingRom.sibling_rom_id == rom.id) + ).all() + ) + exclude_ids = {rom.id, *sibling_ids} + + # Strong signals (franchise / collection) are selective. Weak signals + # (genre / company / age rating) can match a large slice of the + # library. Both are OR'd into a single scan of the (view-backed, + # unindexable) metadata table, with strong matches floated to the top + # so the MAX_SIMILAR_CANDIDATES cap never drops them in favor of a + # weakly-related but higher-rated ROM. + strong_conditions = [ + json_array_contains_any( + getattr(RomMetadata, field), values, session=session + ) + for field in ("franchises", "collections") + if (values := signals[field]) + ] + weak_conditions = [ + json_array_contains_any( + getattr(RomMetadata, field), values, session=session + ) + for field in ("genres", "companies", "age_ratings") + if (values := signals[field]) + ] + all_conditions = strong_conditions + weak_conditions + if not all_conditions: + return [] + + order_by: list[Any] = [] + if strong_conditions: + order_by.append(or_(*strong_conditions).desc()) + order_by += [ + # Portable NULLs-last: rating-less ROMs sort after rated ones. + RomMetadata.average_rating.is_(None), + RomMetadata.average_rating.desc(), + ] + + query = ( + select( + RomMetadata.rom_id, + RomMetadata.franchises, + RomMetadata.collections, + RomMetadata.genres, + RomMetadata.companies, + RomMetadata.age_ratings, + RomMetadata.average_rating, + Rom.platform_id, + ) + .join(Rom, Rom.id == RomMetadata.rom_id) + .where(or_(*all_conditions)) + .where(RomMetadata.rom_id.not_in(exclude_ids)) + .where(Rom.missing_from_fs.is_(False)) + .order_by(*order_by) + .limit(MAX_SIMILAR_CANDIDATES) + ) + rows = session.execute(query).all() + + target_sets = {field: set(values) for field, values in signals.items()} + + def _score(row) -> float: + score = 0.0 + for field, weight in SIMILARITY_WEIGHTS.items(): + shared = target_sets[field] & set(getattr(row, field) or []) + score += weight * len(shared) + if score and row.platform_id == rom.platform_id: + score *= SAME_PLATFORM_BOOST + return score + + scored = [ + (score, row.average_rating or 0.0, row.rom_id, row.platform_id) + for row in rows + if (score := _score(row)) > 0 + ] + scored.sort(key=lambda t: (t[0], t[1], t[2]), reverse=True) + return [ + (rom_id, platform_id) + for _, _, rom_id, platform_id in scored[:SIMILAR_ROMS_CACHE_SIZE] + ] + def get_files_for_roms( self, rom_ids: list[int], diff --git a/backend/tests/endpoints/roms/test_rom.py b/backend/tests/endpoints/roms/test_rom.py index b830c5b53e..0ed2bea243 100644 --- a/backend/tests/endpoints/roms/test_rom.py +++ b/backend/tests/endpoints/roms/test_rom.py @@ -70,6 +70,74 @@ def test_get_rom_simple_missing_returns_404(client: TestClient, access_token: st assert response.status_code == status.HTTP_404_NOT_FOUND +def test_get_similar_roms( + client: TestClient, access_token: str, platform: Platform, admin_user: User +): + def _add(name: str, igdb_id: int, metadata: dict) -> Rom: + return db_rom_handler.add_rom( + Rom( + platform_id=platform.id, + name=name, + slug=name.lower().replace(" ", "-"), + fs_name=f"{name}.zip", + fs_path=f"{platform.slug}/roms", + igdb_id=igdb_id, + igdb_metadata=metadata, + ) + ) + + target = _add("Similar Target", 611111, {"franchises": ["Metroid"]}) + match = _add("Similar Match", 611112, {"franchises": ["Metroid"]}) + _add("Similar Unrelated", 611113, {"genres": ["Racing"]}) + + response = client.get( + f"/api/roms/{target.id}/similar", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == status.HTTP_200_OK + + body = response.json() + ids = [r["id"] for r in body] + assert match.id in ids + assert target.id not in ids + + +def test_get_similar_roms_respects_limit( + client: TestClient, access_token: str, platform: Platform, admin_user: User +): + def _add(name: str, igdb_id: int, metadata: dict) -> Rom: + return db_rom_handler.add_rom( + Rom( + platform_id=platform.id, + name=name, + slug=name.lower().replace(" ", "-"), + fs_name=f"{name}.zip", + fs_path=f"{platform.slug}/roms", + igdb_id=igdb_id, + igdb_metadata=metadata, + ) + ) + + target = _add("Limit Target", 612000, {"franchises": ["Metroid"]}) + for i in range(5): + _add(f"Limit Match {i}", 612100 + i, {"franchises": ["Metroid"]}) + + response = client.get( + f"/api/roms/{target.id}/similar?limit=2", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()) == 2 + + +def test_get_similar_roms_missing_returns_404(client: TestClient, access_token: str): + response = client.get( + "/api/roms/999999/similar", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_download_multi_file_rom_content( client: TestClient, access_token: str, multi_file_rom: Rom ): diff --git a/backend/tests/handler/database/test_similar_roms.py b/backend/tests/handler/database/test_similar_roms.py new file mode 100644 index 0000000000..d9b3b11c9d --- /dev/null +++ b/backend/tests/handler/database/test_similar_roms.py @@ -0,0 +1,170 @@ +"""Tests for the metadata-overlap "similar games in your library" ranking. + +`get_similar_rom_candidates` reads the `roms_metadata` DB view (derived from +the per-provider JSON columns), so candidates are seeded by writing +`igdb_metadata` rather than inserting into the view. Similarity is a weighted +overlap of franchises / collections / genres / companies / age ratings, with a +same-platform multiplier, excluding the ROM itself and its siblings. It returns +`(rom_id, platform_id)` pairs (user-independent and cached); per-user +visibility filtering and the result limit are applied by the endpoint. +""" + +import pytest + +from handler.database import db_platform_handler, db_rom_handler +from models.platform import Platform +from models.rom import Rom +from models.user import User + + +@pytest.fixture +def other_platform() -> Platform: + return db_platform_handler.add_platform( + Platform(name="other", slug="other_slug", fs_slug="other_slug") + ) + + +def _add_rom( + platform: Platform, + name: str, + *, + igdb_id: int | None = None, + igdb_metadata: dict | None = None, +) -> Rom: + rom = Rom( + platform_id=platform.id, + name=name, + slug=name.lower().replace(" ", "-"), + fs_name=f"{name}.zip", + fs_path=f"{platform.slug}/roms", + igdb_id=igdb_id, + igdb_metadata=igdb_metadata or {}, + ) + return db_rom_handler.add_rom(rom) + + +def _ids(candidates: list[tuple[int, int]]) -> list[int]: + return [rom_id for rom_id, _ in candidates] + + +class TestGetSimilarRomCandidates: + def test_ranks_by_weighted_overlap_and_platform_boost( + self, platform: Platform, other_platform: Platform, admin_user: User + ): + target = _add_rom( + platform, + "Zelda Target", + igdb_id=1000, + igdb_metadata={ + "franchises": ["Zelda"], + "genres": ["Adventure"], + "companies": ["Nintendo"], + }, + ) + + # Same franchise + same platform -> franchise (5) * boost (1.25). + same_platform_franchise = _add_rom( + platform, + "Zelda Same Platform", + igdb_id=1001, + igdb_metadata={"franchises": ["Zelda"]}, + ) + # Same franchise, different platform -> franchise (5), no boost. + cross_platform_franchise = _add_rom( + other_platform, + "Zelda Cross Platform", + igdb_id=1002, + igdb_metadata={"franchises": ["Zelda"]}, + ) + # Shared genre only -> genre (3). + genre_only = _add_rom( + other_platform, + "Some Adventure", + igdb_id=1003, + igdb_metadata={"genres": ["Adventure"]}, + ) + # No overlap -> excluded entirely. + _add_rom( + platform, + "Unrelated Racer", + igdb_id=1004, + igdb_metadata={"genres": ["Racing"]}, + ) + + result = db_rom_handler.get_similar_rom_candidates(target) + + assert _ids(result) == [ + same_platform_franchise.id, + cross_platform_franchise.id, + genre_only.id, + ] + + def test_excludes_siblings(self, platform: Platform, admin_user: User): + target = _add_rom( + platform, + "Zelda Target", + igdb_id=2000, + igdb_metadata={"franchises": ["Zelda"]}, + ) + # Same platform + same igdb_id -> sibling (same game, other dump). + sibling = _add_rom( + platform, + "Zelda Target (Rev 1)", + igdb_id=2000, + igdb_metadata={"franchises": ["Zelda"]}, + ) + real_match = _add_rom( + platform, + "Zelda Other", + igdb_id=2001, + igdb_metadata={"franchises": ["Zelda"]}, + ) + + # Exclusion must hold in both directions of the sibling pairing: + # neither dump should surface the other as "similar". + assert _ids(db_rom_handler.get_similar_rom_candidates(target)) == [ + real_match.id + ] + assert _ids(db_rom_handler.get_similar_rom_candidates(sibling)) == [ + real_match.id + ] + + def test_returns_empty_without_metadata(self, platform: Platform, admin_user: User): + target = _add_rom(platform, "No Metadata", igdb_id=3000) + _add_rom( + platform, + "Has Metadata", + igdb_id=3001, + igdb_metadata={"franchises": ["Zelda"]}, + ) + + assert db_rom_handler.get_similar_rom_candidates(target) == [] + + def test_returns_platform_ids_for_visibility_filtering( + self, platform: Platform, other_platform: Platform, admin_user: User + ): + target = _add_rom( + platform, + "Zelda Target", + igdb_id=5000, + igdb_metadata={"franchises": ["Zelda"]}, + ) + same_platform = _add_rom( + platform, + "Zelda Same Platform", + igdb_id=5001, + igdb_metadata={"franchises": ["Zelda"]}, + ) + other = _add_rom( + other_platform, + "Zelda Other Platform", + igdb_id=5002, + igdb_metadata={"franchises": ["Zelda"]}, + ) + + # Each candidate carries its platform id so the endpoint can drop + # hidden platforms without re-querying. + result = dict(db_rom_handler.get_similar_rom_candidates(target)) + + assert result[same_platform.id] == platform.id + assert result[other.id] == other_platform.id diff --git a/frontend/src/services/api/rom.ts b/frontend/src/services/api/rom.ts index a73c456ddd..7dc4c55ec9 100644 --- a/frontend/src/services/api/rom.ts +++ b/frontend/src/services/api/rom.ts @@ -420,6 +420,23 @@ async function getRomByMetadataProvider({ }); } +async function getSimilarRoms({ + romId, + limit, + signal, +}: { + romId: number; + limit?: number; + signal?: AbortSignal; +}) { + // Library ROMs ranked by shared metadata (franchises, collections, genres, + // companies, age ratings). Only returns games already in the library. + return api.get(`/roms/${romId}/similar`, { + params: limit != null ? { limit } : undefined, + signal, + }); +} + async function searchRom({ romId, searchTerm, @@ -918,6 +935,7 @@ export default { getRom, getRomSimple, getRomByMetadataProvider, + getSimilarRoms, downloadRom, bulkDownloadRoms, searchRom, diff --git a/frontend/src/v2/components/GameDetails/IgdbSimilarGamesGrid.vue b/frontend/src/v2/components/GameDetails/IgdbSimilarGamesGrid.vue new file mode 100644 index 0000000000..b619709873 --- /dev/null +++ b/frontend/src/v2/components/GameDetails/IgdbSimilarGamesGrid.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/frontend/src/v2/components/GameDetails/OverviewTab.vue b/frontend/src/v2/components/GameDetails/OverviewTab.vue index c0bbfc761d..de06141f9d 100644 --- a/frontend/src/v2/components/GameDetails/OverviewTab.vue +++ b/frontend/src/v2/components/GameDetails/OverviewTab.vue @@ -27,15 +27,17 @@ import type { UserCollectionSchema, } from "@/__generated__"; import storeCollections from "@/stores/collections"; -import type { DetailedRom } from "@/stores/roms"; +import type { DetailedRom, SimpleRom } from "@/stores/roms"; import CollectionTile from "@/v2/components/Collections/CollectionTile.vue"; import AgeRatingBadges from "@/v2/components/GameDetails/AgeRatingBadges.vue"; import HLTBStrip from "@/v2/components/GameDetails/HLTBStrip.vue"; +import IgdbSimilarGamesGrid from "@/v2/components/GameDetails/IgdbSimilarGamesGrid.vue"; import type { InfoGridSection } from "@/v2/components/GameDetails/InfoGrid.vue"; import InfoGrid from "@/v2/components/GameDetails/InfoGrid.vue"; import PlayerCountBadge from "@/v2/components/GameDetails/PlayerCountBadge.vue"; import RelatedGamesGrid from "@/v2/components/GameDetails/RelatedGamesGrid.vue"; import ScreenshotsTab from "@/v2/components/GameDetails/ScreenshotsTab.vue"; +import SimilarGamesGrid from "@/v2/components/GameDetails/SimilarGamesGrid.vue"; import { PROVIDERS, providerId } from "@/v2/components/GameDetails/providers"; import { useWebpSupport } from "@/v2/composables/useWebpSupport"; import { collectionCoverList } from "@/v2/utils/collectionCovers"; @@ -57,7 +59,12 @@ const props = defineProps<{ dlcs: IGDBRelatedGame[]; remakes: IGDBRelatedGame[]; remasters: IGDBRelatedGame[]; - similarGames: IGDBRelatedGame[]; + // Library games ranked by shared metadata (not IGDB related games) — every + // entry is a real owned ROM, so these link straight to their detail page. + similarGames: SimpleRom[]; + // IGDB's own similar games, shown below as an external discovery row. The + // grid drops any that are already owned (covered by `similarGames`). + igdbSimilarGames: IGDBRelatedGame[]; }>(); const hasAgeRatings = computed( @@ -121,7 +128,8 @@ const hasRelated = computed( props.dlcs.length + props.remakes.length + props.remasters.length + - props.similarGames.length > + props.similarGames.length + + props.igdbSimilarGames.length > 0, ); @@ -268,7 +276,19 @@ const coverSource = computed(() => { - +
+

+ + Similar games +

+ +
+ +