-
Notifications
You must be signed in to change notification settings - Fork 0
Store APIs and Images
How the pipeline and the web app fetch game data and header images from each supported storefront. Living document; update in the same session as any pipeline change that touches a store API.
- Endpoint:
https://store.steampowered.com/api/appdetails?appids=<id>&filters=basic - No auth required. Rate limited (~200 requests / 5 min from a single IP; pipeline throttles via cache).
- Response:
{ "<id>": { "success": true, "data": { name, header_image, content_descriptors: {ids, notes}, ... } } } - Used by:
scripts/pipeline/game_images.py,scripts/pipeline/common.py(fetch_steam_title_with_source,fetch_steam_content_descriptors). - Cache:
.cache/steam-title-cache.json(30d TTL);.cache/steam-content-descriptors-cache.json(30d TTL).
Steam publishes numeric descriptor IDs on each app. The adult filter treats 3 and 4 as adult:
| ID | Meaning | Filtered? |
|---|---|---|
| 1 | Some Nudity or Sexual Content | No (too broad — BG3, Cyberpunk 2077, Rust, GTA V) |
| 2 | Frequent Violence or Gore | No (most action games) |
| 3 | Adult Only Sexual Content | Yes |
| 4 | Frequent Nudity or Sexual Content | Yes |
| 5 | General Mature Content | No (CS2, DBD, Rust — mainstream M-rated) |
Trust Steam's developer self-flagging: genuine adult titles (porn / hentai / VNs) carry ID 3 or 4. Mainstream M-rated games only get 1, 2, or 5.
Constant lives in scripts/pipeline/common.py::ADULT_DESCRIPTOR_IDS. Adding a new ID is a conscious edit; drop the test in tests/test_content_descriptors.py too.
Storefront tags vs content descriptors — do NOT confuse them. The Steam store page's Tags row ("Sexual Content", "Nudity", "Visual Novel") is user-curated community tagging. Content descriptors are a separate developer-set field returned via content_descriptors.ids in the appdetails JSON. Our filter uses only descriptors. A game with the "Sexual Content" tag but no descriptor 3/4 will not be filtered (correctly — mainstream games commonly carry that tag).
Steam's appdetails endpoint accepts a filters query parameter to trim the response. filters=basic removes content_descriptors from the payload. Any code path that needs the adult flag MUST call appdetails WITHOUT filters=basic:
- ✅
common.py::STEAM_APP_DETAILS_URL(no filter) — used byfetch_steam_content_descriptors. Correct. ⚠️ game_images.py::STEAM_APPDETAILS_URL(hasfilters=basic) — only used forheader_imageextraction. Do NOT reuse this URL if you need descriptors.
If the pipeline flips this by accident, every game silently ends up with adult: false because content_descriptors is None in the response. Caught this bug in prod on 2026-07-02 for Naughty Chat and 15k+ catalog stubs.
A second, separate failure mode. Steam's appdetails is aggressively rate-limited (HTTP 429). The old caching wrote an empty descriptor list to .cache/steam-content-descriptors-cache.json on ANY non-success, including a throttled/failed fetch, and kept it for the full 30-day TTL. A single 429 during a run therefore locked a real adult game as adult: false for a month.
Concrete case: Naughty Chat (app 3580330). Its appdetails returns
"content_descriptors": { "ids": [1, 3, 4, 5], "notes": "sexting, nudity or sexual content, general mature content, sexual acts." }3 and 4 are both in ADULT_DESCRIPTOR_IDS, so it should be flagged — but a throttled fetch had cached [].
Fix in common.py::fetch_steam_content_descriptors:
- A network error / 429 is NOT cached at all — the next run retries instead of poisoning the entry.
-
success:falseis cached with a short negative TTL (STEAM_DESCRIPTORS_NEGATIVE_TTL_SECONDS, 3 days) since it can be a transient rate-limit response, not a removed app. -
success:true(even with an empty id list) is a definitive result, cached for the full 30 days with anok: trueflag. - Legacy entries with no
okflag: a non-empty list is treated as confirmed (keep 30d); an empty list is a suspect false negative and gets the short TTL so it re-fetches and self-heals.
The stale prod data heals on the next full pipeline run after the fix ships. Tests: tests/test_content_descriptors.py.
Not every search-index row hits appdetails. Cost tradeoff:
| Entry type | Descriptor check? | Notes |
|---|---|---|
| Steam game with local reports | Always | ~16k API calls first run; cached 30d after that |
| Steam catalog stub (no reports) with adult-hint title | Yes | Only ~58 of 15k stubs match the regex (naughty|hentai|nsfw|adult|erotic|sexy|xxx|nude|nudity|lewd|kinky|18\+|sensual|waifu|ecchi|yuri|yaoi|bimbo). Force-refreshes to heal poisoned cache entries (#185). |
| Extended-index stub (no reports, no hint) | Gradual (#176) | Cached entries are used for free. Uncached stubs get descriptor-checked up to PIPELINE_STUB_ADULT_ENRICH_BUDGET per run (default 500 -- roughly 12 min at Steam's throttle). ~30 runs covers all ~15k stubs. Stable numeric-app_id sort order so the sequence is deterministic across runs. Extended index is 9-column shape ([id, title, tier, pdb, pulse, appType, releaseYear, delisted, adult]) so the frontend adult filter works uniformly. |
| GOG / Epic (any) | Skipped | No public no-auth API returns Steam-equivalent descriptors for those stores. Falls back to admin curation (#173). |
is_adult_app_cached(app_id) in common.py is the read-only variant used by the extended-index enrichment path: returns None on cache miss so the caller can decide whether to spend the per-run budget. Stale negatives (past the short TTL) are treated as misses to self-heal poisoned entries (#185). Full-catalog enrichment (#176) shipped as gradual pass; expand the hint regex when new false-negatives surface between full-cache cycles.
Steam header images live at multiple CDNs. The frontend walks the chain on 404 (see js/app/lib/steam-img.js):
-
https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/<id>/header.jpg— canonical. -
https://cdn.cloudflare.steamstatic.com/steam/apps/<id>/header.jpg— fallback CDN. -
Admin override URL from
game-images.json— the pipeline seeds this from thebox_art_overridesSupabase table before probing. Admin sets these via the Box Art Manager (Set URL / Upload buttons). Overrides beat every other source and survive every pipeline rerun. See "Admin overrides" section below. -
Hashed URL from
game-images.json— the pipeline finds the current asset hash via appdetails (data.header_image) when the canonical path 404s and stores the hashed URL there. Format:.../steam/apps/<id>/<sha>/header.jpg. -
SteamGridDB URL from
game-images.json— the pipeline falls through to SGDB when the standard CDN 404s AND appdetails has no header (usually delisted / freshly-missing apps). Community-contributed artwork; requiresSGDB_API_KEYset. See the SGDB section below. - Hidden if all five fail.
Supabase table box_art_overrides (app_id, image_url, source, set_by, created_at, updated_at) stores admin-curated header URLs. Two write paths, both gated on the manage_box_art permission:
- Set URL — admin pastes a URL; the edge function HEAD-probes it before writing so broken URLs never land in the table.
-
Upload — admin picks a PNG/JPG/WebP up to 2 MB; the edge function uploads to the
boxartSupabase Storage bucket (public read) and writes the resulting public URL into the table. -
Clear — deletes the row AND any bytes under
boxart/<app_id>.<ext>.
Pipeline behaviour (scripts/pipeline/game_images.py):
-
_fetch_admin_overrides()pulls all rows from/rest/v1/box_art_overridesat the start ofbuild_game_images(), usingSUPABASE_URL+SUPABASE_ANON_KEY(RLS grants SELECT to anon). - Each override seeds the cache with
status='override'before any probing, and the override app IDs are excluded from hot / backlog / extended-stub probe lists so we never overwrite an admin choice. - The frontend export (
game-images.json) includes override URLs first, then hashed, then SGDB.
Endpoints on the shared edge function image-refetch:
-
POST source=set_override {app_id, url}— insert/update the row (JWT +manage_box_artrequired) -
POST source=upload_overridemultipart withapp_id+file(JWT +manage_box_artrequired) -
POST source=clear_override {app_id}— delete the row and any bytes in the bucket (JWT +manage_box_artrequired)
For apps behind an age gate (18+) where the JSON API returns success: false, common.py::_scrape_steam_store_title fetches https://store.steampowered.com/app/<id> with the wants_mature_content=1 cookie and greps apphub_AppName for the title.
Community-contributed artwork DB. Used as the last-resort fallback — only when Steam's own APIs (standard CDN + appdetails) yield no header URL for a given appId. Prevents wasting SGDB rate limit (500 req/hour free tier) on apps Steam already covers.
- API base:
https://www.steamgriddb.com/api/v2 - Auth:
Authorization: Bearer <SGDB_API_KEY>on every request - Key setup: get one at https://www.steamgriddb.com/profile/preferences/api. Set as
SGDB_API_KEYin:- GitHub Actions secrets (pipeline:
game_images.py) - Supabase edge function secrets (admin interactive refetch:
image-refetch)
- GitHub Actions secrets (pipeline:
- Endpoints used:
-
GET /games/steam/<appid>→{ data: { id } }— maps a Steam appid to SGDB's internal game id -
GET /grids/game/<game_id>?dimensions=460x215&types=static→{ data: [{ url, mime, ... }] }. We ask for header-shaped static grids only (460x215 matches Steam's header ratio; static excludes animated). Prefer PNG over JPG.
-
- Pipeline behavior: on fallback trigger,
_fetch_sgdb_header(app_id)returns the top-voted URL (PNG-preferred), which then goes through_url_is_okverification before landing ingame-images.jsonwithstatus='sgdb'. - Silent on any failure: no API key, HTTP error, empty result, or 404 on the returned URL all fall through to
status='missing'(same behaviour as before SGDB was wired). - Non-Steam (GOG / Epic) SGDB integration is not in the pipeline yet — SGDB doesn't have first-class id lookup for those stores; needs a title-search path. Tracked as follow-up.
- Endpoint:
https://catalog.gog.com/v1/catalog?limit=48&locale=en-US&page=<n>&order=asc:releaseDate&productType=in:game,pack - No auth. Paginated (~48/page).
- Response contains
products[].id(numeric productId) andproducts[].slug. - Used by:
scripts/pipeline/gog_catalog.py. - Cache:
.cache/gog-catalog-cache.json. Refreshed on every pipeline run (catalog is small).
- URL format:
https://images.gog-statics.com/<sha256>.pngwhere the SHA is stored per game. - The pipeline extracts image URLs from catalog product responses and writes them to
nonsteam-images.jsonkeyed bygog:<productId>. - No fallback CDN. If the URL 404s the image is broken.
- Endpoint:
https://store.epicgames.com/graphql - No auth (public storefront queries), but requires:
Origin: https://store.epicgames.comReferer: https://store.epicgames.com/en-US/browse- Standard browser User-Agent (Cloudflare gates on this).
- Used by:
scripts/pipeline/epic_catalog.py. - Response includes
namespace,title,keyImages[](withtype= "OfferImageTall", "OfferImageWide", etc.).
- URLs come from
keyImages[]in the GraphQL response — Epic hosts on their own CDN with hashed paths. - Written to
nonsteam-images.jsonkeyed byepic:<namespace>. - No fallback CDN.
| File | Written by | Keyed by | Value |
|---|---|---|---|
game-images.json |
game_images.py (pipeline) |
Steam appId | Hashed header URL for apps where the canonical path 404s |
nonsteam-images.json |
game_images.py + catalog scripts |
gog:<id> or epic:<namespace>
|
Store CDN URL for GOG/Epic games |
steam-catalog.json |
most_played.py |
Steam appId | Title for catalog-only apps (in Steam charts but not in the ProtonDB signal export) |
.cache/steam-title-cache.json |
common.py (local, not deployed) |
Steam appId |
{title, source, ts} — 30d TTL |
.cache/steam-content-descriptors-cache.json |
common.py (local, not deployed) |
Steam appId |
{ids, ts} — 30d TTL |
.cache/gog-catalog-cache.json |
gog_catalog.py (local, not deployed) |
GOG productId | Full catalog snapshot |
.cache/epic-catalog-cache.json |
epic_catalog.py (local, not deployed) |
Epic namespace | Full catalog snapshot |
js/lib/store-url-parser.js (with an inline copy in js/lib/topbar.js — kept in sync manually) parses pasted store URLs and returns { store, appId, canonicalId, slug }.
- Steam URLs yield the canonical numeric appId directly from the path.
- GOG / Epic URLs carry a human slug (not the internal canonical id). Slug → id resolution requires reading the catalog cache; the parser leaves that to the caller.
- Tests:
tests/storeUrlParser.test.js.
/admin.html -> Missing Box Art loads all three cache files in the browser and lets an admin:
- Probe: HEAD the canonical URL for a row (Steam walks akamai -> cloudflare -> pipeline fallback; GOG/Epic re-probe the cached URL).
-
Refetch: for Steam, hit
appdetailsand return the currentheader_imageURL. For GOG/Epic, there is no public no-auth manifest API, so refetch is a re-probe of the cached URL.
Failed probes surface the HTTP status and error text so an admin can tell CORS, 404, region-lock, or removed-app apart. See js/admin/api/boxart.js and js/admin/components/boxart.js.
Any change to a store API URL, response shape, or auth requirement lands in the same commit as the code that uses it. Cache files under .cache/ are gitignored — bumping their TTL constants is a code change and needs a matching update here.