feat: library-aware recommendations engine - #4174
Conversation
Recommendations came straight from IGDB's `similar_games`, which mostly names titles the server does not hold and is absent entirely for anything IGDB never matched. `rom_similarity` holds a precomputed item-item graph blending four signals: IDF-weighted metadata overlap, the IGDB prior resolved against the library, collection co-membership and co-play. Scores are library-relative, so a shelf full of arcade games learns on its own that "Action" says nothing while "Metroidvania" says a great deal, with no tuning. Each edge stores the facets that produced it, so the UI can explain a recommendation rather than showing an unattributed row of covers. Candidate generation runs off an inverted index with a document-frequency cap, avoiding the all-pairs comparison. The graph is rebuilt wholesale by a scheduled task because the IDF weighting shifts as the library grows, and writes commit in batches so a task that dies leaves stale edges rather than an empty table. The personalised feed is ranked on demand from those edges plus live play history, since a nightly feed would ignore the game played an hour ago. Ratings below the scale midpoint act as negative seeds, results are diversified per franchise and platform, and owned-but-unplayed games are boosted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Recommendation quality is a judgement call that tests cannot make. This dumps the index in readable form -- each game's own facets, its top neighbours, their scores and the reasons behind them -- so the weights can be sanity-checked against a real library instead of a fixture. Also reports index health: coverage, score spread, and which facets are driving matches, which is what surfaces a facet dominating the rankings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Similar games" now reads the recommendations index instead of `igdb_metadata.similar_games`, so every entry is a game the server actually holds. That also drops the per-card `by-metadata-provider` lookup the IGDB stubs needed: the endpoint returns real ROMs, so the cards are ordinary interactive GameCards rather than synthetic ones. Adds a "Recommended for you" row to Home, each card captioned with why it was suggested, behind a `showRecommendations` UI setting. An unbuilt index or a library too small to relate anything is a normal state, so both surfaces stay hidden rather than surfacing an error. Structural IGDB relations (expansions, DLC, remakes, remasters) are untouched -- those are facts about a game, not recommendations. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Validating the index against a 12.7k-game library surfaced four problems a synthetic fixture could not, because each depends on the shape of real metadata. Stop penalising well-documented games. Vectors were L2-normalised, on the assumption that facet counts behave like document length in text retrieval. They do not: a long document repeating a word is not more relevant, but a game tagged with three genres and two franchises genuinely has more in common than one carrying a single tag. Normalising by a vector's own length made every top match a sparsely-tagged entry -- a Mario compilation matched Golf, F-1 Race and Pinball (6-8 tokens each) while the actual Mario platformers (12-16 tokens) were nowhere in the list. Scaling by the library average instead returns Yoshi's Island, Super Mario 64 and Donkey Kong. Two hypotheses were measured and rejected on the way: franchise coherence via genre entropy (Metroid scored higher than Mario, so no signal) and the franchise/genre weight ratio (five configurations, none of which moved the result). Suppress duplicates against each other, not just against the source. Two discs of one release share an igdb_id and platform, so both took a slot in the same list. The same game ported to another platform gets its own igdb_id, so it needs a title comparison rather than an id one. Diversify across series. Similarity ranking alone made the section a franchise filter -- five Metroid games for Super Metroid. Slots are reserved at build time so the stored graph always carries cross-series options, and the per- series cap is applied when serving so the policy can change without a rebuild. Capped-out entries backfill a short list in score order. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…spectives
IGDB carries three descriptors the scanner never requested: community
keywords ("metroidvania", "shmup"), curated themes (Horror, Survival, Open
world) and player perspectives (Side view, First person). They describe how a
game plays, which genre and franchise cannot: on a 12.7k library only these
three connect Castlevania to Symphony of the Night rather than to the
platformers, Ocarina of Time to Super Mario 64 via Open world, EarthBound to
Chrono Trigger via Fantasy, and GoldenEye to Perfect Dark via Stealth plus
First person.
Coverage beats the existing facets: 66% of matched games carry keywords
against 59% for franchises.
They reach the index the same way every other facet does -- a STORED
generated column, the roms_metadata view, then the trigger-maintained
roms_facets -- so the builder still never reads the wide roms table.
Weight a facet by the values it holds. A game's franchise weight is now split
across however many franchises it carries, so one counts for more than one of
six. Compilations and crossovers previously matched strongly on every
franchise they touched: the SNES Mario compilation pulled in Mario Tennis and
Mario Party ahead of the 2D platformers, and Project X Zone surfaced against
Resident Evil at 0.305 rather than 0.197. Splitting by sqrt rather than the
count keeps three genres worth more in total than one.
Rank keywords last when explaining a match. They are the rarest tokens and so
carry the highest IDF, which had them explaining a Castlevania pairing with
"frankenstein's monster" instead of the shared series.
Existing libraries carry none of this until it is fetched, so
tools/backfill_igdb_tags.py asks IGDB for only the three missing fields in
batches of 500 -- 12,118 games in 36 seconds, against the twelve thousand
requests a rescan would make.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…t feed `average_rating` averages whatever providers happened to rate a game, so a lone ScreenScraper 10/10 is indistinguishable from a broad consensus. The cold-start feed ordered by that rating alone. On a real 12.7k library exactly fourteen games scored a perfect 100, every one of them a single ScreenScraper score with IGDB unrated. The feed returns fifteen results, so it was reduced to listing those fourteen alphabetically: "The Adventures of Batman & Robin", "Adventures of Lolo", "American Bass Challenge", "Aussie Rules Footy". This is the first thing a new install shows anyone, since nobody has play history on day one. Storing IGDB's vote count lets the feed use a Bayesian average, shrinking a rating toward the library mean in proportion to how little evidence backs it. The same library now opens with A Link to the Past (1,710 votes), Uncharted 2 (2,216), Ocarina of Time (2,138) and GTA: San Andreas (4,012). Games with no vote count -- most of them, since only IGDB reports one -- land at the library mean rather than dropping out, which a test pins. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The per-series cap keyed on a game's first franchise. IGDB lists franchises in no stable order and a game often carries several, so one series split across several counters and each spelling got its own allowance. Sports libraries show it worst, since they hold every yearly release. Madden titles carry both "Madden" and "NFL": some resolved to one, some to the other, and a cap of two returned four Madden games. Tony Hawk's Pro Skater 2 returned four Tony Hawks, and Mario Kart 64 filled its remaining slots with Mario Golf and Mario Tennis via the broader "Mario" franchise. A candidate is now measured against every franchise and collection it lists, and is held back if any one of them is already at the cap. The same fix applies to the build-time reservation, which picked its slots the same way. The three now return two same-series entries followed by something else: NCAA Football and Tiger Woods for Madden, Skate It and Grind Session for Tony Hawk (matched on the skateboarding keyword), F-Zero X and Wave Race for Mario Kart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`companies` flattens every IGDB involvement role into one list, so the studio that made a game is indistinguishable from the label that shipped it or the regional distributor that boxed it. Those carry very different weight: a studio's games genuinely resemble each other, while a publisher spans everything it ever released. It is not a marginal distinction. On a 12.7k-game library the second most common "company" is Tec Toy, Sega's Brazilian distributor, on 768 games, with Playtronic (Nintendo's) on 350 -- dense enough that IDF does not suppress them, so matches were coming out as "same distributor". Super Metroid's cross-franchise suggestions were all justified by Playtronic. IGDB returns developer/publisher booleans on involved_companies and the scanner never requested them. Requesting them turns that noise into signal: Super Metroid now matches on Nintendo R&D1, the studio behind it, Super Mario Land and Wario Land. Gunstar Heroes returns Alien Soldier and Dynamite Headdy -- the rest of Treasure's catalogue. Developer keeps the 0.7 the merged facet already carried, so splitting the roles redistributes that weight rather than adding new influence, and publisher drops to 0.25. Sweeping developer from 1.0 to 0.4 barely moved anything: a tight studio's games also share genre and theme, so the developer token is not what carries them. The one place it shows is a wide-ranging studio -- Tony Hawk's Pro Skater now surfaces Neversoft's unrelated titles where it used to find other skateboarding games through a keyword. `companies` is untouched for display, and the scorer falls back to it when a game was matched by a provider that reports no roles, so emitting both never double-counts one studio. Coverage is 72% developers and 78% publishers, ahead of every facet already in use. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`load_igdb_ids` keyed IGDB id to a single rom id. Region and revision variants of one game share an id, so the dict collapsed them and all but one variant was skipped -- the same many-to-one mistake the similarity builder had, reintroduced in the tool. On a 12.7k library 2,668 roms share an id with another, and 1,266 of them were left with no keywords while their duplicate had them. Fixing it lifted every facet by around ten points: keywords 66% to 76%, themes 64% to 73%, perspectives 62% to 73%, developers 72% to 83%, publishers 78% to 89%. Found while investigating why a Tony Hawk entry would not match other skateboarding games. It still doesn't, its metadata is too thin, but Grind Session now at least places. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Splitting `companies` into developer and publisher left both facets unmapped, so every studio match rendered with the generic tag icon -- Gunstar Heroes explained four of its six matches as "Treasure" under a keyword glyph. `platform` was never mapped either. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The second pass deduped candidates against each other by igdb_id but compared titles only against the source, and IGDB gives every port its own id. A shelf holding Monopoly on both the GB and the NES spent two of six slots naming the same game. Found by sampling the index at random rather than by reading the code: it was the first entry of a 40-game sample. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reviewing this means judging recommendation quality, which needs a real library. --report turns a random sample into a self-contained page that can be handed to someone who has not got one, and records the flags that produced it so the same page can be regenerated against another shelf. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`involved_companies` carries one entry per involvement, so a studio credited twice in the same role landed in `developers` twice. The two role lists legitimately overlap where a studio both made and shipped a game; neither may repeat internally. The equivalent fix for the merged `companies` list and for `franchises` is upstream in rommapp#4175, since those exist on master. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
f9846a5 to
3fe0a21
Compare
Measured on a 14,952-game library, 400 random games at six results each: a cap of two fired on 52% of lists, and each time it swapped a 0.48 match for a 0.10 one, roughly five times less related. It bought 0.41 more distinct series than a cap of three. Three still keeps the tail of a deep franchise off the list, which is what the cap is for, without discarding a strong match so eagerly. Applied when serving, so this needs no rebuild. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
IGDB sends the main franchise both on its own (`franchise`) and inside `franchises`, and the two were concatenated with `pydash.compact`, which drops falsy values but not duplicates. The repeat is stored and rendered, so the details page shows "Happy Feet, Happy Feet". Measured on a 14,952-game library: 1,080 of 8,788 games carrying a franchise carried it twice (12.3%). `pydash.uniq` keeps first-seen order, so `franchises[0]` -- which the gamelist exporter writes out as <family> -- is unchanged. Existing libraries keep the repeat until the affected games are rescanned. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Drop a comment that narrated the per-series cap changing rather than describing what the cap does, merge two adjacent comments in the builder that both explained candidates colliding with each other, and refresh the library statistics quoted in the migrations and the backfill tool so they match the shelf the numbers were last taken from. The two measurements in `scoring.py` still cite the 12.7k library they were run on, since that is where they were run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The feed reimplemented franchise counting inline, keyed on a single representative series where the similar-games surface counts every name a game's series goes under. On a 14,952-game library 415 series keys are shared by games whose representative differs, so the two surfaces disagreed about what counts as the same series. `cap_by_series` gains an optional platform ceiling, which the feed needs and a single game's shelf does not, and the feed now calls it. The feed also picks up the backfill in the process, so a row the caps leave short fills rather than arriving with fewer entries than asked for. `primary_series` has no callers left and goes with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`ports` sat alongside similar_games, remakes, expansions and dlcs in the bucket feeding the IGDB prior, so a game was scored more highly against its own port for being a port of itself. Neither reading of the relation is a recommendation. Where a port is faithful the two entries are the same product, and where it was rebuilt for the target hardware it can earn a place on its own facets instead. On a 14,952-game library, 1,867 owned pairs are linked by `ports`. The title check already collapses the 1,573 whose names match; of the rest, 243 had a live edge and 79 came from the prior alone. The remainder score on shared metadata, which is the honest basis for them. Not an exclusion: 21% of those pairs diverge sharply on genre, perspective and mode -- an N64 game and its Game Boy Color counterpart are usually separate productions -- and no threshold told the two cases apart reliably enough to suppress on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`showRecommendations` gated the "Recommended for you" row on Home but not the "Similar games" section on a game's page, so the feature could only be half switched off. rommapp#3794 asked for it to be hideable, not hideable in one of the two places it appears. The setting's description said "at the home page" and now names both surfaces, retranslated across the 18 locales. Expansions, DLC, remakes and remasters share the related panel and are unaffected: the panel still renders for them with recommendations off. Closes rommapp#3794 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds a library-aware recommendations system that replaces IGDB-only similar games with a precomputed similarity index, and surfaces recommendations in v2 game details and the Home dashboard. This aligns with RomM’s goal of recommending playable titles that actually exist in the user’s library, and supports an opt-out UI setting plus “why” explanations per recommendation.
Changes:
- Backend: introduces a precomputed
rom_similaritygraph build, a personalised/api/recommendationsfeed, and a/api/roms/{id}/similarendpoint, plus scheduled task wiring and metadata extraction/storage needed for scoring. - Frontend (v2): adds a single UI toggle to show/hide recommendations, renders “Similar games” using library-resolved ROMs with reason chips, and adds a “Recommended for you” Home row.
- i18n + generated types: adds recommendation strings across locales and new OpenAPI-generated schemas for recommendation payloads.
Reviewed changes
Copilot reviewed 79 out of 84 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| frontend/src/v2/views/Settings/UserInterface.vue | Adds a UI toggle to enable/disable recommendations surfaces. |
| frontend/src/v2/views/Home.vue | Fetches and renders the personalised recommendations row with reasons. |
| frontend/src/v2/views/Home.test.ts | Adds tests for the Home recommendations row behavior and failure handling. |
| frontend/src/v2/views/GameDetails.vue | Fetches similar ROMs from the backend recommendations index and passes to Overview. |
| frontend/src/v2/utils/similarityReasons.ts | Maps recommendation “reason” facets to icons and labels for UI display. |
| frontend/src/v2/utils/similarityReasons.test.ts | Unit tests for reason icon/label mapping behavior. |
| frontend/src/v2/components/GameDetails/SimilarGamesGrid.vue | New grid component to render similar ROMs with reason chips. |
| frontend/src/v2/components/GameDetails/OverviewTab.vue | Swaps IGDB similar stubs for library-resolved similar ROMs, gated by UI setting. |
| frontend/src/v2/components/GameDetails/OverviewTab.test.ts | Tests that the similar-games section is gated by the recommendations setting. |
| frontend/src/services/api/rom.ts | Adds API client methods for similar ROMs and personalised recommendations. |
| frontend/src/composables/useUISettings.ts | Adds persisted UI setting key for showing recommendations. |
| frontend/src/generated/models/SimilarRomSchema.ts | New generated schema for similar ROM entries. |
| frontend/src/generated/models/SimilarityReasonSchema.ts | New generated schema for recommendation reasons. |
| frontend/src/generated/models/RecommendedRomSchema.ts | New generated schema for personalised feed entries. |
| frontend/src/generated/index.ts | Exports new generated recommendation-related schemas. |
| frontend/src/locales/bg_BG/settings.json | Adds settings strings for the recommendations toggle. |
| frontend/src/locales/bg_BG/recommendations.json | Adds recommendation UI strings. |
| frontend/src/locales/cs_CZ/settings.json | Adds settings strings for the recommendations toggle. |
| frontend/src/locales/cs_CZ/recommendations.json | Adds recommendation UI strings. |
| frontend/src/locales/de_DE/settings.json | Adds settings strings for the recommendations toggle. |
| frontend/src/locales/de_DE/recommendations.json | Adds recommendation UI strings. |
| frontend/src/locales/en_GB/settings.json | Adds settings strings for the recommendations toggle. |
| frontend/src/locales/en_GB/recommendations.json | Adds recommendation UI strings. |
| frontend/src/locales/en_US/settings.json | Adds settings strings for the recommendations toggle. |
| frontend/src/locales/en_US/recommendations.json | Adds recommendation UI strings. |
| frontend/src/locales/es_ES/settings.json | Adds settings strings for the recommendations toggle. |
| frontend/src/locales/es_ES/recommendations.json | Adds recommendation UI strings. |
| frontend/src/locales/fr_FR/settings.json | Adds settings strings for the recommendations toggle. |
| frontend/src/locales/fr_FR/recommendations.json | Adds recommendation UI strings. |
| frontend/src/locales/hu_HU/settings.json | Adds settings strings for the recommendations toggle. |
| frontend/src/locales/hu_HU/recommendations.json | Adds recommendation UI strings. |
| frontend/src/locales/it_IT/settings.json | Adds settings strings for the recommendations toggle. |
| frontend/src/locales/it_IT/recommendations.json | Adds recommendation UI strings. |
| frontend/src/locales/ja_JP/settings.json | Adds settings strings for the recommendations toggle. |
| frontend/src/locales/ja_JP/recommendations.json | Adds recommendation UI strings. |
| frontend/src/locales/ko_KR/settings.json | Adds settings strings for the recommendations toggle. |
| frontend/src/locales/ko_KR/recommendations.json | Adds recommendation UI strings. |
| frontend/src/locales/pl_PL/settings.json | Adds settings strings for the recommendations toggle. |
| frontend/src/locales/pl_PL/recommendations.json | Adds recommendation UI strings. |
| frontend/src/locales/pt_BR/settings.json | Adds settings strings for the recommendations toggle. |
| frontend/src/locales/pt_BR/recommendations.json | Adds recommendation UI strings. |
| frontend/src/locales/ro_RO/settings.json | Adds settings strings for the recommendations toggle. |
| frontend/src/locales/ro_RO/recommendations.json | Adds recommendation UI strings. |
| frontend/src/locales/ru_RU/settings.json | Adds settings strings for the recommendations toggle. |
| frontend/src/locales/ru_RU/recommendations.json | Adds recommendation UI strings. |
| frontend/src/locales/tr_TR/settings.json | Adds settings strings for the recommendations toggle. |
| frontend/src/locales/tr_TR/recommendations.json | Adds recommendation UI strings. |
| frontend/src/locales/zh_CN/settings.json | Adds settings strings for the recommendations toggle. |
| frontend/src/locales/zh_CN/recommendations.json | Adds recommendation UI strings. |
| frontend/src/locales/zh_TW/settings.json | Adds settings strings for the recommendations toggle. |
| frontend/src/locales/zh_TW/recommendations.json | Adds recommendation UI strings. |
| backend/tools/inspect_recommendations.py | Adds an inspection tool to build/sample the index and render a report for quality review. |
| backend/tools/backfill_igdb_tags.py | Adds a backfill tool to populate newly requested IGDB tag fields for existing libraries. |
| backend/tests/handler/recommendation/test_scoring.py | Unit tests for scoring primitives, tokenization, IDF, blending, and explanations. |
| backend/tests/handler/recommendation/test_feed.py | Unit tests for feed seeding, recency, and affinity behavior. |
| backend/tests/handler/recommendation/test_diversity.py | Unit tests for series/platform diversity caps when serving recommendations. |
| backend/tests/handler/recommendation/test_builder.py | End-to-end tests for similarity index build behavior and regressions. |
| backend/tests/handler/recommendation/init.py | Test package init for recommendation handler tests. |
| backend/tests/handler/metadata/test_igdb_handler.py | Adds tests for franchise and company-role deduplication in IGDB extraction. |
| backend/tests/endpoints/test_recommendations.py | Endpoint tests for similar ROMs and personalised recommendations. |
| backend/tasks/scheduled/build_recommendations.py | Introduces scheduled task to rebuild the recommendations index. |
| backend/startup.py | Wires scheduled recommendations build into startup task init flow. |
| backend/models/rom.py | Adds new metadata and facets fields plus relationship for similarity edges. |
| backend/models/recommendation.py | Adds RomSimilarity ORM model for the similarity edge table. |
| backend/main.py | Registers the new recommendations router. |
| backend/handler/recommendation/scoring.py | Implements the scoring primitives and content-similarity math. |
| backend/handler/recommendation/feed.py | Implements personalised feed ranking, caching, and hydration. |
| backend/handler/recommendation/diversity.py | Implements diversity caps to avoid one series/platform owning results. |
| backend/handler/recommendation/builder.py | Builds and writes the similarity graph from multiple signals. |
| backend/handler/recommendation/init.py | Exposes recommendation builder/feed APIs via package exports. |
| backend/handler/metadata/igdb_handler.py | Extends IGDB metadata extraction to include tags, vote counts, and company roles. |
| backend/handler/database/roms_handler.py | Adds get_roms_simple_by_ids to hydrate recommendation payloads cheaply. |
| backend/handler/database/recommendations_handler.py | Adds DB queries for build inputs, edge writes, and feed inputs. |
| backend/handler/database/init.py | Registers the new recommendations DB handler. |
| backend/endpoints/tasks.py | Exposes build recommendations as a scheduled/manual task in the tasks endpoint. |
| backend/endpoints/roms/init.py | Adds /roms/{id}/similar endpoint and invalidates feed cache on seed-field updates. |
| backend/endpoints/responses/recommendation.py | Adds Pydantic response schemas for similar/recommended ROMs and reasons. |
| backend/endpoints/recommendations.py | Adds /recommendations endpoint with caching and refresh support. |
| backend/config/init.py | Adds config flags and default cron for scheduled recommendations index builds. |
| backend/alembic/versions/0111_company_role_columns.py | Migration adding role-split company columns and facets/view/trigger rebuild. |
| backend/alembic/versions/0110_rating_count_column.py | Migration adding rating-count column to support confidence-weighted cold start. |
| backend/alembic/versions/0109_igdb_tag_columns.py | Migration adding keywords/themes/perspectives columns and trigger updates. |
| backend/alembic/versions/0108_rom_similarity.py | Migration adding the rom_similarity edge table and indexes. |
| backend/alembic/env.py | Includes the new recommendation model for Alembic autogeneration context. |
Files not reviewed (4)
- frontend/src/generated/index.ts: Generated file
- frontend/src/generated/models/RecommendedRomSchema.ts: Generated file
- frontend/src/generated/models/SimilarRomSchema.ts: Generated file
- frontend/src/generated/models/SimilarityReasonSchema.ts: Generated file
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Greptile SummaryThe PR adds a precomputed, library-aware similarity graph and exposes it through personalized Home recommendations and similar-game results.
Confidence Score: 3/5The PR should not merge until recommendation visibility filtering and play-session cache invalidation are corrected. The feed can discard accessible candidates by filtering only after ranking and caching, and newly recorded gameplay can leave personalized results stale for the full cache lifetime. Files Needing Attention: backend/endpoints/recommendations.py, backend/endpoints/roms/init.py, backend/handler/play_session_handler.py, backend/handler/recommendation/feed.py Important Files Changed
Prompt To Fix All With AI### Issue 1
backend/endpoints/recommendations.py:42-43
**Permission filtering underfills feeds**
When a non-admin user has top-ranked ROMs hidden through ROM or platform visibility rules, this builds and caches an already capped feed before filtering those ROMs, causing the Home row to omit accessible lower-ranked recommendations or disappear entirely.
### Issue 2
backend/endpoints/roms/__init__.py:2278-2282
**Gameplay leaves feed cache stale**
When a user plays a game or synchronizes a play session after loading Home, play-session ingestion updates the playtime, `last_played`, `now_playing`, and status signals without reaching this invalidation, causing the old recommendation ranking to remain cached for up to 15 minutes.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Reviews (1): Last reviewed commit: "feat(v2): let the recommendations toggle..." | Re-trigger Greptile |
Three separate problems, all found by the bots on rommapp#4174: The recommendations block was inserted between `convert_images_to_webp_task .init()` and the `_enqueue_convert_images_to_webp()` call beneath it, so the WebP backfill only enqueued when recommendations were enabled and enqueued even when WebP conversion was off. Moved back under its own flag; the diff against master is now purely additive. Invalidating the cached feed from the ROM-user endpoint missed every other writer of the fields it scores on: play-session ingestion, save and state uploads, and the RetroAchievements sync all move `last_played`, `status` or `now_playing` through `update_rom_user` without passing that endpoint, so a game played after loading Home left the ranking stale for the cache TTL. Invalidation moves to `update_rom_user`, which every writer goes through. The feed ranked exactly as many entries as were asked for and applied visibility filtering afterwards, so a user with hidden ROMs got a short row and, if the hidden games ranked highest, an empty one. It now ranks deeper for those users only, mirroring what the similar-games endpoint already did. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every cached ranking is computed against the edges that existed when it was built, so the nightly rebuild left users reading the previous graph until their cache expired. The same argument that moved per-user invalidation into `update_rom_user` applies to the largest input of all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Description
Closes #3794.
Recommendations currently come straight from IGDB's
similar_games. That list mostly names titles the server doesn't hold, and it's empty for anything IGDB never matched, so the "Similar games" section is frequently a row of covers you can't play, or nothing at all.This replaces it with a library-aware engine and surfaces it in two places: Similar games on game details, and a Recommended for you row on Home. Both are hidden by a single user setting, and both stay hidden until the index is built.
I described the approach in this comment first and have since built it out. Three things are worth your steer, and I'm happy to change any of them:
Everything below the fold is evidence for the choices above. It was built and measured against a real 14,952-game library across 32 platforms rather than fixtures.
How it works
A precomputed item-item graph (
rom_similarity) blends four signals, weighted to sum to 1.0 so a score reads as "fraction of maximum relatedness":roms_facets/roms_metadataigdb_metadata, resolved against the libraryrom_user.last_playedcollections_romsThe content signal is library-relative: facets are BM25-IDF weighted, so a shelf full of arcade games learns on its own that "Action" says nothing while "Metroidvania" says a great deal. Nothing needs tuning per library.
Every edge stores the facets that produced it, so the UI explains why rather than showing an unattributed row of covers.
The personalised feed is ranked on demand from those edges plus live play history, since a nightly feed would ignore the game someone played an hour ago. Ratings below the scale midpoint act as negative seeds, results are diversified per franchise and platform, and owned-but-unplayed games are boosted, since surfacing those is the point.
New metadata this needs
Four migrations (0108 to 0111) add the similarity table and expose IGDB fields we already receive but never stored:
rom_similaritykeywords,themes,player_perspectivesrating_countdevelopers,publishers0111 is the one worth a second look. RomM merges IGDB's
involved_companiesinto a singlecompanieslist, dropping the developer / publisher / porting roles. On my library the most common "company" is Tec Toy, Sega's Brazilian distributor, on 774 games, ahead of Nintendo on 756: dense enough that matches were being explained as "same distributor", which carries no signal at all. Requesting the role booleans and weighting developer well above publisher turned that noise into signal. Super Metroid's matches were being justified by Playtronic and are now justified by Nintendo R&D1.companiesis untouched and still drives display; the new columns are additive, and the scorer falls back to the merged list for games matched by a provider that reports no roles.Cost and scale
Build on 14,952 games: 102 seconds, 234 MB peak, 340,806 edges, 99.3% coverage. Time grows a little faster than the library (a 17% bigger shelf took 56% longer), because the PS2-era games carry denser metadata and so generate more candidates each. Candidate generation runs off an inverted index with a document-frequency cap, avoiding the all-pairs comparison. Writes commit in batches, so a task that dies leaves stale edges rather than an empty table.
No new dependencies.
scipyandscikit-learnwere considered and rejected as too heavy for a self-hosted image that commonly runs on a Pi or NAS.How it was validated, and the five defects a real library exposed
Built and evaluated on a real 14,952-game library across 32 platforms, which changed the design substantially rather than just confirming it. Defects only a real shelf could expose:
Length normalisation was wrong for this data. Vectors were L2-normalised, on the assumption facet counts behave like document length in text retrieval. They don't: a long document repeating a word isn't more relevant, but a game tagged with three genres and two franchises genuinely has more in common than one carrying a single tag. Normalising by a vector's own length made every top match a sparsely-tagged entry.
A series is capped by every name it goes under. IGDB lists franchises in no stable order, so some Madden titles resolved to "Madden" and others to "NFL". Keying the cap on one entry gave each spelling its own allowance, so four Maddens cleared a cap of two.
Cold-start ratings had no confidence weighting. Sixteen games in the library score a perfect 100, every one of them with no IGDB votes behind it, and the feed recommended all sixteen alphabetically. Ratings are now shrunk toward the library mean in proportion to the votes behind them.
Duplicates competed with each other. Candidates were compared against the source but never against one another, so two discs of one release took two slots.
A backfill keyed IGDB id to a single rom, so where several ROMs share an id (region and revision variants) only one got the data. That left 1,266 games with no keywords while their duplicate had them, and fixing it lifted every facet by about ten points. This one is worth flagging because it is the kind of bug that reads as a ranking problem and isn't.
Representative results after the fixes:
Open worldWhat a second, differently-shaped library changed
The first version of this was tuned on a 12,769-game retro-heavy shelf. The library it is now measured on grew to 14,952 and, more importantly, changed shape: PS2 went from 51 games to 1,770, which swaps the dominant failure mode from sparse metadata to dense metadata, where the risk is everything matching on "Action", "Third person" and "2000s".
The facet distribution barely moved: genre 23.8% to 23.5%, perspective 17.3% to 17.1%, theme 16.3% to 16.1%, developer 12.4% to 12.8%. That is the point of making the content signal library-relative, and it is the claim I most wanted a second library to try to break.
What it did surface is that the developer signal carries more of the load than I would like once the franchise runs out. Scores fall off a cliff past a series match (about 0.59 for a franchise, then 0.06 to 0.08 for everything else), and in that flat region the developer facet decides the order. Sampling PS2 at random, NPPL Championship Paintball 2009 returns Cabela's Big Game Hunter and Cabela's Dangerous Hunts, because Sand Grain Studios made all three. That is the Tony Hawk case below, and on a PS2-heavy library it is systemic rather than anecdotal. I have not found a weighting that fixes it without hurting the cases where a studio is the signal.
Why same-series results are capped at three
Same-series results are capped at three when serving, not when building, so the policy can change without a rebuild. The number is measured rather than picked: 400 random games at six results each, on the 14,952-game library.
At two the cap fires on half of all lists and each time swaps a 0.48 match for a 0.10 one, roughly five times less related, buying 0.41 more distinct series than three does. Three still keeps the tail of a deep franchise off the list, which is the whole point of the cap, without discarding a strong match so eagerly.
What this measures is score, not preference. A franchise match scores highly and easily, and may still be a game someone already knows they own, so there is a reasonable argument for a tighter cap than the numbers alone suggest. It is one constant either way.
Notes for review
The index is built by a scheduled task (
build_recommendations), which defaults to on at 05:30 daily and can also be run from the tasks UI. Flagging the default explicitly, since the feature is inert without it but it's your call. Both surfaces stay hidden when the index is empty.Four metadata fixes ride along, each with tests, because the sampling above turned them up: two ports of one game were taking two of six slots (IGDB gives each port its own id, so the id check could not catch it); a studio credited twice in one role appeared twice in
developers; and IGDB's main franchise, sent both on its own and insidefranchises, was stored twice and rendered as "Happy Feet, Happy Feet" on 12.3% of games that have a franchise. Happy to split any of them out.A port is no longer a reason to recommend.
portsfed the IGDB prior alongsidesimilar_gamesandremakes, so a game scored more highly against its own port. Dropping it took same-game port edges from 243 to 164 here. Deliberately not an exclusion: 21% of port pairs diverge sharply, an N64 game and its Game Boy Color counterpart usually being separate productions. Reasoning in the commit.The other structural relations (expansions, DLC, remakes, remasters) still feed the prior. A remake is a defensible suggestion for someone who played the original; none of them is the same product the way a port is.
The quickest way to judge this is against your own library, which is the only way to
see whether the weights hold on a shelf I have never seen. Point the usual
DB_*env vars ata copy of it (
--buildrewritesrom_similarity) and:Games are sampled at random, not chosen, so the same
--seedgives a reproducible page ratherthan a flattering one. Adding
--report out.htmlwrites a self-contained page you can attachto a comment. The 40-game sample is also how the duplicate-slot bug in this branch was found:
two ports of one game were taking two of six slots, and it was the first entry sampled.
The last five commits are the metadata work (0109 to 0111 and the scoring that uses it). They're separable from the engine itself if you'd rather review this in two passes.
Known limits
Checklist
Backend: 2,902 passed, 2 skipped. Frontend: 710 passed, plus typecheck, build and i18n parity/sorting clean. Migrations 0108 to 0111 each verified up, down and up again on MariaDB 12 (CI covers Postgres and MariaDB). Both surfaces checked in a browser in light and dark themes, including with the recommendations setting switched off.
Screenshots (if applicable)
Symphony of the Night: the series cap holds it to three Castlevanias, then it crosses to the other Metroidvanias on
Open world.Gunstar Heroes: four of six matches explained as Treasure, only possible once developer is split out from the merged
companieslist."Recommended for you" on a test account with an invented play history. No sports games, despite the library holding every yearly release.
AI Assistance Notice
This PR was written primarily by Claude Code (Opus 5), including the implementation, the tests and this description, directed and reviewed by me throughout. The code is substantially AI-generated and should be reviewed with that in mind.