Skip to content

feat: library-aware recommendations engine - #4174

Open
sdornan wants to merge 21 commits into
rommapp:masterfrom
sdornan:claude/igdb-keywords-themes-perspectives
Open

feat: library-aware recommendations engine#4174
sdornan wants to merge 21 commits into
rommapp:masterfrom
sdornan:claude/igdb-keywords-themes-perspectives

Conversation

@sdornan

@sdornan sdornan commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

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:

  1. Is a precomputed index acceptable? It adds one table and one scheduled task. Computing on demand is simpler but slower on large libraries.
  2. Should the scheduled task default on? It does today. The feature is inert without it, but that's your call, not mine.
  3. Is the Home row in scope? The engine generalises to it, but it's a second surface. Happy to drop it and keep this to the game-details section.

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":

Signal Weight Source
Content (metadata overlap) 0.55 roms_facets / roms_metadata
IGDB prior 0.20 igdb_metadata, resolved against the library
Co-play 0.15 rom_user.last_played
Collection co-membership 0.10 collections_roms

The 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:

Migration Adds Why
0108 rom_similarity the edge table
0109 keywords, themes, player_perspectives the facets that carry the most signal after franchise
0110 rating_count so a rating can be weighted by the votes behind it
0111 developers, publishers see below

0111 is the one worth a second look. RomM merges IGDB's involved_companies into a single companies list, 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.

companies is 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. scipy and scikit-learn were 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.

Before After
Super Mario World's top matches Golf, F-1 Race, Pinball (6 to 8 tokens) Yoshi's Island, Super Mario 64, Donkey Kong (12 to 16 tokens)

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:

  • Super Metroid to Metroid: Zero Mission, Metroid Prime 2, then Kirby Super Star, Donkey Kong Country 2, Zelda: A Link to the Past
  • Castlevania: Symphony of the Night to two Castlevanias (capped), then Metroid: Zero Mission and Terraria on Open world
  • Gunstar Heroes to Gunstar Super Heroes, then Alien Soldier, Dynamite Headdy, Ikaruga and Guardian Heroes, the rest of Treasure's catalogue
  • Wirehead (Sega CD, no ratings anywhere) to Fox Hunt and Brain Dead 13, the other FMV comedy adventures
  • Mario Kart 64 to two other Mario Karts, then F-Zero X, Wave Race 64, 1080 Snowboarding
What 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.

cap lists it changed mean score displaced mean score of replacement distinct series shown
2 52% 0.480 0.101 5.08 / 6
3 44% 0.481 0.116 4.67 / 6
4 39% 0.477 0.131 4.33 / 6

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 inside franchises, 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. ports fed the IGDB prior alongside similar_games and remakes, 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 at
    a copy of it (--build rewrites rom_similarity) and:

    cd backend
    uv run tools/inspect_recommendations.py --build --sample 40 --seed 7
    uv run tools/inspect_recommendations.py --stats                    # index health only
    uv run tools/inspect_recommendations.py --name "Super Metroid"     # one game
    uv run tools/inspect_recommendations.py --platform ps2 --sample 20 # one platform

    Games are sampled at random, not chosen, so the same --seed gives a reproducible page rather
    than a flattering one. Adding --report out.html writes a self-contained page you can attach
    to 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

  • The facet weights are principled (series > franchise > genre > publisher) and validated on one real library, but they're one library. The inspection tool exists so this is checkable rather than a matter of faith.
  • A wide-ranging studio is a weaker signal than a tight one. Tony Hawk's Pro Skater surfaces Neversoft's unrelated titles ahead of other skateboarding games. Four approaches were tried and none moved it: measuring studio breadth (Neversoft is more genre-concentrated than Treasure, so it isn't a signal), damping company matches when two games share no genre, raising the keyword weight, and sweeping the developer weight from 1.0 to 0.4. That entry carries one keyword and two genres and the competing candidates land within 0.01 of each other, so it's thin metadata rather than something a weight can fix.
  • Weak matches are presented like strong ones. Where a game has nothing to match on, results fall back to "same publisher" and the score drops accordingly (0.06 against 0.6 to 0.8 for a good match), but nothing acts on that. Arguably fine when every result is a game you already own, but flagging it in case you see it differently.
  • Not benchmarked beyond ~15k games.

Checklist

  • I've tested the changes locally
  • I've updated relevant comments
  • I've assigned reviewers for this PR
  • I've added unit tests that cover the changes

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)

image

Symphony of the Night: the series cap holds it to three Castlevanias, then it crosses to the other Metroidvanias on Open world.

image

Gunstar Heroes: four of six matches explained as Treasure, only possible once developer is split out from the merged companies list.

image

"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.

sdornan and others added 13 commits August 7, 2026 20:12
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>
@sdornan
sdornan force-pushed the claude/igdb-keywords-themes-perspectives branch from f9846a5 to 3fe0a21 Compare August 10, 2026 14:24
sdornan and others added 6 commits August 10, 2026 09:34
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>
@sdornan
sdornan marked this pull request as ready for review August 10, 2026 23:47
Copilot AI lite review requested due to automatic review settings August 10, 2026 23:47

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_similarity graph build, a personalised /api/recommendations feed, and a /api/roms/{id}/similar endpoint, 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.

Comment thread backend/startup.py Outdated
@greptile-apps

greptile-apps Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR adds a precomputed, library-aware similarity graph and exposes it through personalized Home recommendations and similar-game results.

  • Adds four database migrations for similarity edges and additional IGDB scoring metadata.
  • Adds scheduled index construction, recommendation ranking, diversification, caching, and API endpoints.
  • Adds frontend recommendation surfaces, user settings, generated models, translations, tests, and inspection/backfill tools.

Confidence Score: 3/5

The 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

Filename Overview
backend/endpoints/recommendations.py Adds the personalized feed endpoint, but caps and caches candidates before permission filtering, allowing visible results to be lost.
backend/handler/recommendation/feed.py Implements affinity ranking, diversification, hydration, and caching; its 15-minute cache requires complete invalidation across every signal writer.
backend/handler/play_session_handler.py Existing play ingestion now affects recommendation inputs but does not invalidate the newly introduced feed cache.
backend/endpoints/roms/init.py Adds similar-ROM serving and partial feed invalidation, but the invalidation is confined to one ROM-user mutation route.
backend/handler/database/recommendations_handler.py Adds the database queries and batched persistence used by similarity building and personalized ranking.
backend/handler/recommendation/builder.py Builds the library-relative item graph from metadata, IGDB relations, collections, and co-play signals.
backend/alembic/versions/0109_igdb_tag_columns.py Adds tag facets while preserving the existing metadata-view projections and trigger-mirrored fields.
backend/alembic/versions/0110_rating_count_column.py Adds generated rating-count metadata for Bayesian cold-start ranking without changing facet triggers.
backend/alembic/versions/0111_company_role_columns.py Adds developer and publisher facets and correctly carries forward prior view and trigger columns.
frontend/src/v2/views/Home.vue Adds the Recommended for you row and hides it when the API returns no recommendations.

Fix All in Claude Code

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

Comment thread backend/endpoints/recommendations.py Outdated
Comment thread backend/endpoints/roms/__init__.py Outdated
sdornan and others added 2 commits August 10, 2026 19:41
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>
@gantoine
gantoine self-requested a review August 16, 2026 15:14
@gantoine gantoine added the on-hold Pending further research or blocked by another issue label Aug 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

on-hold Pending further research or blocked by another issue

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] Add an option to remove the new IGDB "similar games" section, and/or make it local only

3 participants