From 8f627d17fa450e10c5f2de7a4c72d3d7e4df4837 Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Sun, 19 Jul 2026 09:16:17 -0400 Subject: [PATCH 1/5] feat: library-based similar games from shared metadata Replace the IGDB `similar_games` list on the v2 game detail page with "Similar games" computed from the local library. Similarity is a weighted overlap of the normalized `RomMetadata` signals (franchises, collections, genres, companies, age ratings) with a same-platform boost, so every entry is a ROM the user owns and can open directly. Backend: - New `GET /roms/{id}/similar` endpoint returning `SimpleRomSchema[]`, excluding the ROM itself, its siblings, and anything hidden from the caller. - `DBRomsHandler.get_similar_rom_ids()` scores candidates over the `roms_metadata` view; strong signals (franchise/collection) are scored in full, broad signals (genre/company/age) are capped to bound the query. Frontend (v2 only): - `GameDetails.vue` fetches similar games per-ROM (abort-on-navigate). - New `SimilarGamesGrid.vue` renders real `GameCard`s linking to `/rom/{id}`. Expansions/DLC/Remakes/Remasters still come from IGDB. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/endpoints/roms/__init__.py | 51 ++++++ backend/handler/database/roms_handler.py | 140 ++++++++++++++ backend/tests/endpoints/roms/test_rom.py | 40 ++++ .../handler/database/test_similar_roms.py | 172 ++++++++++++++++++ frontend/src/services/api/rom.ts | 18 ++ .../v2/components/GameDetails/OverviewTab.vue | 9 +- .../GameDetails/SimilarGamesGrid.vue | 37 ++++ frontend/src/v2/views/GameDetails.vue | 34 +++- 8 files changed, 491 insertions(+), 10 deletions(-) create mode 100644 backend/tests/handler/database/test_similar_roms.py create mode 100644 frontend/src/v2/components/GameDetails/SimilarGamesGrid.vue diff --git a/backend/endpoints/roms/__init__.py b/backend/endpoints/roms/__init__.py index 49336e286c..d986b0dc6d 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,53 @@ def get_rom_simple( return SimpleRomSchema.from_orm_with_request(rom, request) +@protected_route( + router.get, + "/{id}/similar", + [] if DISABLE_DOWNLOAD_ENDPOINT_AUTH else [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) + + perms = get_permissions(request) + similar_ids = db_rom_handler.get_similar_rom_ids( + rom, + limit=limit, + hidden_platform_ids=list(perms.hidden_platform_ids), + hidden_rom_ids=list(perms.hidden_rom_ids), + ) + if not similar_ids: + return [] + + roms_by_id = {r.id: r for r in db_rom_handler.get_roms_by_ids(similar_ids)} + ordered = [roms_by_id[i] for i in similar_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..0e82b3c50d 100644 --- a/backend/handler/database/roms_handler.py +++ b/backend/handler/database/roms_handler.py @@ -66,6 +66,27 @@ 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 + EJS_SUPPORTED_PLATFORMS = [ UPS._3DO, UPS.AMIGA, @@ -338,6 +359,125 @@ def get_roms_by_ids( return [] return session.scalars(query.filter(Rom.id.in_(ids))).all() + @begin_session + def get_similar_rom_ids( + self, + rom: Rom, + *, + limit: int = SIMILAR_ROMS_LIMIT, + hidden_platform_ids: Sequence[int] | None = None, + hidden_rom_ids: Sequence[int] | None = None, + session: Session = None, # type: ignore + ) -> list[int]: + """Rank other library ROMs by shared normalized metadata. + + Similarity is a weighted overlap of the target ROM's `RomMetadata` + signals (franchises, collections, genres, companies, age ratings) + against every other identified ROM, with a multiplicative boost for + same-platform matches. Returns rom ids ordered by descending score, + capped at `limit`. Purely metadata-driven, so unidentified ROMs (no + `RomMetadata` row, or empty signals) yield an empty list. + """ + 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". + sibling_ids = set( + session.scalars( + select(SiblingRom.sibling_rom_id).where(SiblingRom.rom_id == rom.id) + ).all() + ) + exclude_ids = {rom.id, *sibling_ids} + if hidden_rom_ids: + exclude_ids.update(hidden_rom_ids) + + columns = ( + RomMetadata.rom_id, + RomMetadata.franchises, + RomMetadata.collections, + RomMetadata.genres, + RomMetadata.companies, + RomMetadata.age_ratings, + RomMetadata.average_rating, + Rom.platform_id, + ) + + def _candidate_query(where): + query = ( + select(*columns) + .join(Rom, Rom.id == RomMetadata.rom_id) + .where(where) + .where(RomMetadata.rom_id.not_in(exclude_ids)) + .where(Rom.missing_from_fs.is_(False)) + ) + if hidden_platform_ids: + query = query.where(Rom.platform_id.not_in(hidden_platform_ids)) + return query + + # Strong signals (franchise / collection) are selective, so every + # match is scored. Weak signals (genre / company / age rating) can + # match a large slice of the library, so they are capped and ordered + # by rating to keep the candidate set bounded. + 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]) + ] + + rows: dict[int, Any] = {} + if strong_conditions: + for row in session.execute(_candidate_query(or_(*strong_conditions))).all(): + rows[row.rom_id] = row + if weak_conditions: + weak_query = ( + _candidate_query(or_(*weak_conditions)) + .order_by( + # Portable NULLs-last: rating-less ROMs sort after rated ones. + RomMetadata.average_rating.is_(None), + RomMetadata.average_rating.desc(), + ) + .limit(MAX_SIMILAR_CANDIDATES) + ) + for row in session.execute(weak_query).all(): + rows.setdefault(row.rom_id, row) + + 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) + for row in rows.values() + if (score := _score(row)) > 0 + ] + scored.sort(key=lambda t: (t[0], t[1], t[2]), reverse=True) + return [rom_id for _, _, rom_id in scored[:limit]] + 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..592ea337e5 100644 --- a/backend/tests/endpoints/roms/test_rom.py +++ b/backend/tests/endpoints/roms/test_rom.py @@ -70,6 +70,46 @@ 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_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..8fb70a04c5 --- /dev/null +++ b/backend/tests/handler/database/test_similar_roms.py @@ -0,0 +1,172 @@ +"""Tests for the metadata-overlap "similar games in your library" ranking. + +`get_similar_rom_ids` 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. +""" + +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) + + +class TestGetSimilarRomIds: + 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_ids(target) + + assert 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). + _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"]}, + ) + + result = db_rom_handler.get_similar_rom_ids(target) + + assert result == [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_ids(target) == [] + + def test_respects_limit(self, platform: Platform, admin_user: User): + target = _add_rom( + platform, + "Zelda Target", + igdb_id=4000, + igdb_metadata={"franchises": ["Zelda"]}, + ) + for i in range(5): + _add_rom( + platform, + f"Zelda Match {i}", + igdb_id=4100 + i, + igdb_metadata={"franchises": ["Zelda"]}, + ) + + result = db_rom_handler.get_similar_rom_ids(target, limit=2) + + assert len(result) == 2 + + def test_excludes_hidden_platforms( + self, platform: Platform, other_platform: Platform, admin_user: User + ): + target = _add_rom( + platform, + "Zelda Target", + igdb_id=5000, + igdb_metadata={"franchises": ["Zelda"]}, + ) + hidden = _add_rom( + other_platform, + "Zelda Hidden Platform", + igdb_id=5001, + igdb_metadata={"franchises": ["Zelda"]}, + ) + + result = db_rom_handler.get_similar_rom_ids( + target, hidden_platform_ids=[other_platform.id] + ) + + assert hidden.id not in result + assert result == [] 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/OverviewTab.vue b/frontend/src/v2/components/GameDetails/OverviewTab.vue index c0bbfc761d..78f7f79c30 100644 --- a/frontend/src/v2/components/GameDetails/OverviewTab.vue +++ b/frontend/src/v2/components/GameDetails/OverviewTab.vue @@ -27,7 +27,7 @@ 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"; @@ -36,6 +36,7 @@ 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 +58,9 @@ 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[]; }>(); const hasAgeRatings = computed( @@ -308,7 +311,7 @@ const coverSource = computed(() => { Similar games - + diff --git a/frontend/src/v2/components/GameDetails/SimilarGamesGrid.vue b/frontend/src/v2/components/GameDetails/SimilarGamesGrid.vue new file mode 100644 index 0000000000..8789ae0da3 --- /dev/null +++ b/frontend/src/v2/components/GameDetails/SimilarGamesGrid.vue @@ -0,0 +1,37 @@ + + + + + diff --git a/frontend/src/v2/views/GameDetails.vue b/frontend/src/v2/views/GameDetails.vue index 19923274c8..cce208b719 100644 --- a/frontend/src/v2/views/GameDetails.vue +++ b/frontend/src/v2/views/GameDetails.vue @@ -6,6 +6,7 @@ // orchestrator — data + tab state live here, every visual piece is a // sub-component under components/GameDetails/. import { RTabNav, type RTabNavItem } from "@v2/lib"; +import axios from "axios"; import { storeToRefs } from "pinia"; import { computed, ref, watch } from "vue"; import { useI18n } from "vue-i18n"; @@ -13,7 +14,7 @@ import { onBeforeRouteUpdate, useRoute, useRouter } from "vue-router"; import type { IGDBRelatedGame } from "@/__generated__"; import romApi from "@/services/api/rom"; import storeAuth from "@/stores/auth"; -import storeRoms from "@/stores/roms"; +import storeRoms, { type SimpleRom } from "@/stores/roms"; import { toBrowserLocale } from "@/utils"; import AchievementsTab from "@/v2/components/GameDetails/AchievementsTab.vue"; import CoverColumn from "@/v2/components/GameDetails/CoverColumn.vue"; @@ -198,13 +199,32 @@ const earnedAchievementIds = computed>(() => { const achievementsEarned = computed(() => earnedAchievementIds.value.size); const igdb = computed(() => currentRom.value?.igdb_metadata ?? null); -// IGDB ships up to ~10 similar games per title; rendering all of them -// would dominate the overview and push HLTB/Achievements below the -// fold. Cap to keep the section to ~2 rows of cards at typical widths. -const SIMILAR_GAMES_MAX = 6; -const similarGames = computed(() => - (igdb.value?.similar_games ?? []).slice(0, SIMILAR_GAMES_MAX), +// "Similar games" is computed from the local library (shared metadata), +// not IGDB's similar_games list — so every entry is a ROM the user owns +// and can open directly. Fetched per-ROM and refreshed on navigation. +const similarGames = ref([]); +let similarAbort: AbortController | null = null; +watch( + () => currentRom.value?.id, + async (romId) => { + similarAbort?.abort(); + similarGames.value = []; + if (!romId) return; + similarAbort = new AbortController(); + try { + const { data } = await romApi.getSimilarRoms({ + romId, + signal: similarAbort.signal, + }); + // Guard against a stale response landing after a fast navigation. + if (currentRom.value?.id === romId) similarGames.value = data; + } catch (error) { + if (!axios.isCancel(error)) console.error(error); + } + }, + { immediate: true }, ); + const remakes = computed(() => igdb.value?.remakes ?? []); const remasters = computed( () => igdb.value?.remasters ?? [], From 95c83753a0f5795fa78a25c65a4f62957f05d954 Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi <3247106+gantoine@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:24:11 -0400 Subject: [PATCH 2/5] Update backend/endpoints/roms/__init__.py Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- backend/endpoints/roms/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/endpoints/roms/__init__.py b/backend/endpoints/roms/__init__.py index d986b0dc6d..10bca189bd 100644 --- a/backend/endpoints/roms/__init__.py +++ b/backend/endpoints/roms/__init__.py @@ -1120,7 +1120,7 @@ def get_rom_simple( @protected_route( router.get, "/{id}/similar", - [] if DISABLE_DOWNLOAD_ENDPOINT_AUTH else [Scope.ROMS_READ], + [Scope.ROMS_READ], responses={status.HTTP_404_NOT_FOUND: {}}, ) def get_similar_roms( From 9c065fa0b4b376fcf914692e7bc617a664671f11 Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Sun, 19 Jul 2026 12:25:52 -0400 Subject: [PATCH 3/5] fix: exclude siblings in both directions for similar games The sibling-exclusion query only read rows where the target is `SiblingRom.rom_id`, so a reverse-direction pairing could leave the other dump/region eligible as a similar game. Query both sides of the pairing instead of assuming the view stores symmetric rows. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/handler/database/roms_handler.py | 7 ++++++- backend/tests/handler/database/test_similar_roms.py | 9 +++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/backend/handler/database/roms_handler.py b/backend/handler/database/roms_handler.py index 0e82b3c50d..ad486416b2 100644 --- a/backend/handler/database/roms_handler.py +++ b/backend/handler/database/roms_handler.py @@ -390,11 +390,16 @@ def get_similar_rom_ids( return [] # Sibling ROMs are the same game (different dump/region); never - # surface them as "similar". + # 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} if hidden_rom_ids: diff --git a/backend/tests/handler/database/test_similar_roms.py b/backend/tests/handler/database/test_similar_roms.py index 8fb70a04c5..0f80fc4f98 100644 --- a/backend/tests/handler/database/test_similar_roms.py +++ b/backend/tests/handler/database/test_similar_roms.py @@ -101,7 +101,7 @@ def test_excludes_siblings(self, platform: Platform, admin_user: User): igdb_metadata={"franchises": ["Zelda"]}, ) # Same platform + same igdb_id -> sibling (same game, other dump). - _add_rom( + sibling = _add_rom( platform, "Zelda Target (Rev 1)", igdb_id=2000, @@ -114,9 +114,10 @@ def test_excludes_siblings(self, platform: Platform, admin_user: User): igdb_metadata={"franchises": ["Zelda"]}, ) - result = db_rom_handler.get_similar_rom_ids(target) - - assert result == [real_match.id] + # Exclusion must hold in both directions of the sibling pairing: + # neither dump should surface the other as "similar". + assert db_rom_handler.get_similar_rom_ids(target) == [real_match.id] + assert db_rom_handler.get_similar_rom_ids(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) From da7a209618c38b1022643bea38634ed27d6396e3 Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Sun, 19 Jul 2026 12:30:48 -0400 Subject: [PATCH 4/5] feat: add IGDB similar games as an unowned discovery row Keep the library-based "Similar games" list on top, and show IGDB's own similar_games below it as an external discovery row. The row drops any game already in the library (those surface in the metadata-based list above), so it only ever links out to games the user doesn't own. - New `IgdbSimilarGamesGrid.vue` resolves ownership via the IGDB -> RomM cross-reference and renders only unowned suggestions. - `RelatedGameCard` gains a `forceExternal` prop so the grid's cards skip the redundant per-card lookup. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../GameDetails/IgdbSimilarGamesGrid.vue | 102 ++++++++++++++++++ .../v2/components/GameDetails/OverviewTab.vue | 13 ++- .../GameDetails/RelatedGameCard.vue | 8 +- frontend/src/v2/views/GameDetails.vue | 8 ++ 4 files changed, 128 insertions(+), 3 deletions(-) create mode 100644 frontend/src/v2/components/GameDetails/IgdbSimilarGamesGrid.vue diff --git a/frontend/src/v2/components/GameDetails/IgdbSimilarGamesGrid.vue b/frontend/src/v2/components/GameDetails/IgdbSimilarGamesGrid.vue new file mode 100644 index 0000000000..0b00a6fbdd --- /dev/null +++ b/frontend/src/v2/components/GameDetails/IgdbSimilarGamesGrid.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/frontend/src/v2/components/GameDetails/OverviewTab.vue b/frontend/src/v2/components/GameDetails/OverviewTab.vue index 78f7f79c30..036a526227 100644 --- a/frontend/src/v2/components/GameDetails/OverviewTab.vue +++ b/frontend/src/v2/components/GameDetails/OverviewTab.vue @@ -31,6 +31,7 @@ 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"; @@ -61,6 +62,9 @@ const props = defineProps<{ // 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( @@ -124,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, ); @@ -306,12 +311,16 @@ const coverSource = computed(() => { -
+

Similar games

+
diff --git a/frontend/src/v2/components/GameDetails/RelatedGameCard.vue b/frontend/src/v2/components/GameDetails/RelatedGameCard.vue index 6a98289ccf..f85c3dcb63 100644 --- a/frontend/src/v2/components/GameDetails/RelatedGameCard.vue +++ b/frontend/src/v2/components/GameDetails/RelatedGameCard.vue @@ -29,7 +29,12 @@ import GameCard from "@/v2/components/GameCard/GameCard.vue"; defineOptions({ inheritAttrs: false }); -const props = defineProps<{ game: IGDBRelatedGame }>(); +const props = defineProps<{ + game: IGDBRelatedGame; + // Skip the IGDB -> RomM lookup and always link out. Used when the parent + // has already resolved ownership (e.g. the unowned-only discovery row). + forceExternal?: boolean; +}>(); const { t } = useI18n(); const router = useRouter(); @@ -37,6 +42,7 @@ const romId = ref(null); const inLibrary = computed(() => romId.value !== null); onMounted(async () => { + if (props.forceExternal) return; try { const res = await romApi.getRomByMetadataProvider({ field: "igdb_id", diff --git a/frontend/src/v2/views/GameDetails.vue b/frontend/src/v2/views/GameDetails.vue index cce208b719..9bde5ccd67 100644 --- a/frontend/src/v2/views/GameDetails.vue +++ b/frontend/src/v2/views/GameDetails.vue @@ -225,6 +225,13 @@ watch( { immediate: true }, ); +// IGDB's own "similar games" are kept as a discovery row below the +// library-based list. The grid filters out any that are already owned +// (those surface in the library section above), so this row is purely +// games to look up externally. +const igdbSimilarGames = computed( + () => igdb.value?.similar_games ?? [], +); const remakes = computed(() => igdb.value?.remakes ?? []); const remasters = computed( () => igdb.value?.remasters ?? [], @@ -299,6 +306,7 @@ const tabs = computed(() => [ :remakes="remakes" :remasters="remasters" :similar-games="similarGames" + :igdb-similar-games="igdbSimilarGames" /> From 1d144ac55803446dd1b61e22dc691d46d84cfb2e Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Sun, 19 Jul 2026 14:17:11 -0400 Subject: [PATCH 5/5] perf: cache similar-roms ranking and fetch it in parallel The /roms/{id}/similar endpoint re-materialized the roms_metadata view (a DB VIEW derived from per-provider JSON columns, unindexable) twice per call, taking 5s+ on large libraries. The ranking is user-independent, so cache it in Redis under the existing filter-values cache version (already bumped on every scan / ROM write, so invalidation is free) and share it across callers. Collapse the two candidate scans into one, floating strong-signal matches to the top. Per-user visibility filtering and the result limit move to the endpoint, which now hydrates only the final slice. Frontend: fire the similar-games request in parallel with getRom on the v2 detail page instead of chaining it behind currentRom, and differentiate the owned ("Similar games") and IGDB ("Similar on IGDB") discovery rows. AI assistance: written with Claude Code. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/endpoints/roms/__init__.py | 25 ++-- backend/handler/database/roms_handler.py | 134 ++++++++++-------- backend/tests/endpoints/roms/test_rom.py | 28 ++++ .../handler/database/test_similar_roms.py | 75 +++++----- .../GameDetails/IgdbSimilarGamesGrid.vue | 1 - .../v2/components/GameDetails/OverviewTab.vue | 19 ++- frontend/src/v2/views/GameDetails.vue | 69 +++++---- 7 files changed, 208 insertions(+), 143 deletions(-) diff --git a/backend/endpoints/roms/__init__.py b/backend/endpoints/roms/__init__.py index 10bca189bd..d8257874c7 100644 --- a/backend/endpoints/roms/__init__.py +++ b/backend/endpoints/roms/__init__.py @@ -1149,18 +1149,25 @@ def get_similar_roms( 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) - similar_ids = db_rom_handler.get_similar_rom_ids( - rom, - limit=limit, - hidden_platform_ids=list(perms.hidden_platform_ids), - hidden_rom_ids=list(perms.hidden_rom_ids), - ) - if not similar_ids: + 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(similar_ids)} - ordered = [roms_by_id[i] for i in similar_ids if i in roms_by_id] + 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] diff --git a/backend/handler/database/roms_handler.py b/backend/handler/database/roms_handler.py index ad486416b2..771c7e55a5 100644 --- a/backend/handler/database/roms_handler.py +++ b/backend/handler/database/roms_handler.py @@ -86,6 +86,10 @@ # 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, @@ -174,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: @@ -360,24 +370,35 @@ def get_roms_by_ids( return session.scalars(query.filter(Rom.id.in_(ids))).all() @begin_session - def get_similar_rom_ids( + def get_similar_rom_candidates( self, rom: Rom, *, - limit: int = SIMILAR_ROMS_LIMIT, - hidden_platform_ids: Sequence[int] | None = None, - hidden_rom_ids: Sequence[int] | None = None, session: Session = None, # type: ignore - ) -> list[int]: - """Rank other library ROMs by shared normalized metadata. - - Similarity is a weighted overlap of the target ROM's `RomMetadata` - signals (franchises, collections, genres, companies, age ratings) - against every other identified ROM, with a multiplicative boost for - same-platform matches. Returns rom ids ordered by descending score, - capped at `limit`. Purely metadata-driven, so unidentified ROMs (no - `RomMetadata` row, or empty signals) yield an empty list. + ) -> 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 [] @@ -402,36 +423,13 @@ def get_similar_rom_ids( ).all() ) exclude_ids = {rom.id, *sibling_ids} - if hidden_rom_ids: - exclude_ids.update(hidden_rom_ids) - columns = ( - RomMetadata.rom_id, - RomMetadata.franchises, - RomMetadata.collections, - RomMetadata.genres, - RomMetadata.companies, - RomMetadata.age_ratings, - RomMetadata.average_rating, - Rom.platform_id, - ) - - def _candidate_query(where): - query = ( - select(*columns) - .join(Rom, Rom.id == RomMetadata.rom_id) - .where(where) - .where(RomMetadata.rom_id.not_in(exclude_ids)) - .where(Rom.missing_from_fs.is_(False)) - ) - if hidden_platform_ids: - query = query.where(Rom.platform_id.not_in(hidden_platform_ids)) - return query - - # Strong signals (franchise / collection) are selective, so every - # match is scored. Weak signals (genre / company / age rating) can - # match a large slice of the library, so they are capped and ordered - # by rating to keep the candidate set bounded. + # 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 @@ -446,23 +444,38 @@ def _candidate_query(where): for field in ("genres", "companies", "age_ratings") if (values := signals[field]) ] + all_conditions = strong_conditions + weak_conditions + if not all_conditions: + return [] - rows: dict[int, Any] = {} + order_by: list[Any] = [] if strong_conditions: - for row in session.execute(_candidate_query(or_(*strong_conditions))).all(): - rows[row.rom_id] = row - if weak_conditions: - weak_query = ( - _candidate_query(or_(*weak_conditions)) - .order_by( - # Portable NULLs-last: rating-less ROMs sort after rated ones. - RomMetadata.average_rating.is_(None), - RomMetadata.average_rating.desc(), - ) - .limit(MAX_SIMILAR_CANDIDATES) + 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, ) - for row in session.execute(weak_query).all(): - rows.setdefault(row.rom_id, row) + .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()} @@ -476,12 +489,15 @@ def _score(row) -> float: return score scored = [ - (score, row.average_rating or 0.0, row.rom_id) - for row in rows.values() + (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 for _, _, rom_id in scored[:limit]] + return [ + (rom_id, platform_id) + for _, _, rom_id, platform_id in scored[:SIMILAR_ROMS_CACHE_SIZE] + ] def get_files_for_roms( self, diff --git a/backend/tests/endpoints/roms/test_rom.py b/backend/tests/endpoints/roms/test_rom.py index 592ea337e5..0ed2bea243 100644 --- a/backend/tests/endpoints/roms/test_rom.py +++ b/backend/tests/endpoints/roms/test_rom.py @@ -102,6 +102,34 @@ def _add(name: str, igdb_id: int, metadata: dict) -> Rom: 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", diff --git a/backend/tests/handler/database/test_similar_roms.py b/backend/tests/handler/database/test_similar_roms.py index 0f80fc4f98..d9b3b11c9d 100644 --- a/backend/tests/handler/database/test_similar_roms.py +++ b/backend/tests/handler/database/test_similar_roms.py @@ -1,10 +1,12 @@ """Tests for the metadata-overlap "similar games in your library" ranking. -`get_similar_rom_ids` 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. +`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 @@ -41,7 +43,11 @@ def _add_rom( return db_rom_handler.add_rom(rom) -class TestGetSimilarRomIds: +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 ): @@ -85,9 +91,9 @@ def test_ranks_by_weighted_overlap_and_platform_boost( igdb_metadata={"genres": ["Racing"]}, ) - result = db_rom_handler.get_similar_rom_ids(target) + result = db_rom_handler.get_similar_rom_candidates(target) - assert result == [ + assert _ids(result) == [ same_platform_franchise.id, cross_platform_franchise.id, genre_only.id, @@ -116,8 +122,12 @@ def test_excludes_siblings(self, platform: Platform, admin_user: User): # Exclusion must hold in both directions of the sibling pairing: # neither dump should surface the other as "similar". - assert db_rom_handler.get_similar_rom_ids(target) == [real_match.id] - assert db_rom_handler.get_similar_rom_ids(sibling) == [real_match.id] + 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) @@ -128,28 +138,9 @@ def test_returns_empty_without_metadata(self, platform: Platform, admin_user: Us igdb_metadata={"franchises": ["Zelda"]}, ) - assert db_rom_handler.get_similar_rom_ids(target) == [] - - def test_respects_limit(self, platform: Platform, admin_user: User): - target = _add_rom( - platform, - "Zelda Target", - igdb_id=4000, - igdb_metadata={"franchises": ["Zelda"]}, - ) - for i in range(5): - _add_rom( - platform, - f"Zelda Match {i}", - igdb_id=4100 + i, - igdb_metadata={"franchises": ["Zelda"]}, - ) + assert db_rom_handler.get_similar_rom_candidates(target) == [] - result = db_rom_handler.get_similar_rom_ids(target, limit=2) - - assert len(result) == 2 - - def test_excludes_hidden_platforms( + def test_returns_platform_ids_for_visibility_filtering( self, platform: Platform, other_platform: Platform, admin_user: User ): target = _add_rom( @@ -158,16 +149,22 @@ def test_excludes_hidden_platforms( igdb_id=5000, igdb_metadata={"franchises": ["Zelda"]}, ) - hidden = _add_rom( - other_platform, - "Zelda Hidden Platform", + same_platform = _add_rom( + platform, + "Zelda Same Platform", igdb_id=5001, igdb_metadata={"franchises": ["Zelda"]}, ) - - result = db_rom_handler.get_similar_rom_ids( - target, hidden_platform_ids=[other_platform.id] + other = _add_rom( + other_platform, + "Zelda Other Platform", + igdb_id=5002, + igdb_metadata={"franchises": ["Zelda"]}, ) - assert hidden.id not in result - assert result == [] + # 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/v2/components/GameDetails/IgdbSimilarGamesGrid.vue b/frontend/src/v2/components/GameDetails/IgdbSimilarGamesGrid.vue index 0b00a6fbdd..b619709873 100644 --- a/frontend/src/v2/components/GameDetails/IgdbSimilarGamesGrid.vue +++ b/frontend/src/v2/components/GameDetails/IgdbSimilarGamesGrid.vue @@ -60,7 +60,6 @@ const shown = computed(() => unowned.value.slice(0, props.max));