Scrape and store age rating data from ScreenScraper.fr#3089
Conversation
Co-authored-by: gantoine <[email protected]>
Greptile SummaryThis PR adds ScreenScraper age-rating scraping and display to RomM by extracting Key changes and findings:
Confidence Score: 3/5
Important Files Changed
Sequence DiagramsequenceDiagram
participant SS as ScreenScraper API
participant Handler as ss_handler.py
participant DB as roms table (ss_metadata)
participant View as roms_metadata view
participant FE as GameInfo.vue
SS->>Handler: SSGame { classifications: [{type, text}] }
Handler->>Handler: _get_age_ratings()<br/>maps text→rating, type→category
Handler->>DB: SSMetadata { age_ratings: [{rating, category}] }
FE->>View: SELECT age_ratings FROM roms_metadata
View->>DB: CASE: jsonb_path_query_array(ss_metadata, '$.age_ratings[*].rating')
View-->>FE: age_ratings = ["7", "M"] (flat string array)
FE->>FE: Build ssByRating Map<br/>{ rating → SSAgeRating } from ss_metadata
FE->>FE: For each entry in metadatum.age_ratings:<br/>1. Check manual (":") format<br/>2. Look up in igdbByRating<br/>3. Look up in ssByRating → construct icon URL<br/>4. Fallback chip
FE->>FE: Render v-img (if cover URL) or v-chip (fallback)
FE->>FE: onFilterClick('ageRatings', value.rating) → route to search
Last reviewed commit: c89753c |
☂️ Python Coverage
Overall Coverage
New FilesNo new covered files... Modified Files
|
| const ssByRating = new Map( | ||
| ssRatings.map((r) => [String(r.rating).trim(), r]), | ||
| ); |
There was a problem hiding this comment.
ssByRating Map drops duplicate rating codes across categories
ssByRating is keyed by r.rating (the raw text like "12", "16", etc.). When ScreenScraper returns ratings for two different systems that share the same numeric label — e.g. PEGI 12 and USK 12 — the Map constructor will silently overwrite the first with the second (last-write wins). The flattened view column will contain two "12" entries, but both lookup calls (ssByRating.get("12")) will resolve to the same object, so only the winner's icon is shown twice while the loser falls through to the bare-chip fallback with an empty category.
PEGI and USK share the values 12, 16, and 18, so this is a realistic scenario for many European releases.
A robust approach is to key the map by ${r.category}:${r.rating} and extract the category from the view as well (either by storing "PEGI:12" style strings, or by fetching the full objects from ss_metadata). Alternatively, look up the full object list directly from ss_metadata.age_ratings and render that instead of going through the flattened metadatum.age_ratings.
| const ssByRating = new Map( | |
| ssRatings.map((r) => [String(r.rating).trim(), r]), | |
| ); | |
| const ssByCategory = new Map( | |
| ssRatings.map((r) => [`${r.category}:${r.rating}`.trim(), r]), | |
| ); |
| // ScreenScraper age ratings need to have cover URLs constructed | ||
| const ssMatch = ssByRating.get(entry.trim()); | ||
| if (ssMatch) { | ||
| const slug = categorySlug[ssMatch.category]; | ||
| return { | ||
| ...ssMatch, | ||
| rating_cover_url: slug | ||
| ? `https://www.igdb.com/icons/rating_icons/${slug}/${slug}_${normalizeRatingCode(ssMatch.rating)}.png` | ||
| : undefined, | ||
| }; | ||
| } |
There was a problem hiding this comment.
SS rating lookup does not account for shared numeric codes
Building on the map-collision issue above, the lookup ssByRating.get(entry.trim()) uses the bare rating text "7", "12", etc. as the key. Because the flattened view column also only carries the text value (not the category), there is no way to distinguish a PEGI 12 entry from a USK 12 entry at this point.
If the map key is updated to ${category}:${rating}, the lookup here must change accordingly — which requires knowing the category at this step. The cleanest fix is to skip the view-column lookup entirely for SS ratings and render directly from ss_metadata.age_ratings (the structured data is already available in ssRatings).
| def _get_age_ratings(game: SSGame) -> list[SSAgeRating]: | ||
| return [ | ||
| SSAgeRating( | ||
| rating=classification["text"], | ||
| category=classification["type"], | ||
| ) | ||
| for classification in game.get("classifications", []) | ||
| if classification.get("type") and classification.get("text") | ||
| ] |
There was a problem hiding this comment.
classifications items are accessed by subscript after a get() guard — inconsistent defensiveness
The guard classification.get("type") and classification.get("text") ensures both keys are present and truthy before the list comprehension body runs, so classification["text"] and classification["type"] are safe. However, the ScreenScraper API is loosely typed — if an entry ever contains, say, {"type": "PEGI"} (no "text" key at all), classification.get("text") returns None (filtered), but if "text" is present with value 0 (integer zero, unlikely but possible), the truthy guard would filter it when it maybe shouldn't. Using .get() consistently would make the intent clearer:
| def _get_age_ratings(game: SSGame) -> list[SSAgeRating]: | |
| return [ | |
| SSAgeRating( | |
| rating=classification["text"], | |
| category=classification["type"], | |
| ) | |
| for classification in game.get("classifications", []) | |
| if classification.get("type") and classification.get("text") | |
| ] | |
| def _get_age_ratings(game: SSGame) -> list[SSAgeRating]: | |
| return [ | |
| SSAgeRating( | |
| rating=classification.get("text", ""), | |
| category=classification.get("type", ""), | |
| ) | |
| for classification in game.get("classifications", []) | |
| if classification.get("type") and classification.get("text") | |
| ] |
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
ScreenScraper returns
classifications(PEGI, ESRB, BBFC, etc.) in its game data, but this was never extracted or stored, making age-based filtering impossible for SS-only libraries.Changes
ss_handler.py: Addedage_ratings: list[str]toSSMetadataTypedDict; added_get_age_ratings()insideextract_metadata_from_ss_rom()that mapsclassificationsentries to formatted strings:Empty
typeortextvalues are filtered out.0070_ss_age_ratings.py(new migration): Updates theroms_metadataview (PostgreSQL + MySQL/MariaDB) to includess_metadata → 'age_ratings'in theage_ratingsCOALESCE, positioned after IGDB and before LaunchBox priority order.Original prompt
🔒 GitHub Advanced Security automatically protects Copilot coding agent pull requests. You can protect all pull requests by enabling Advanced Security for your repositories. Learn more about Advanced Security.