# Game Images API

**Versión:** 1.1  
**URL base (público):** `https://grabbr.jailbroken.tech`  
**Local:** `http://127.0.0.1:8790`  
**Content-Type:** `application/json; charset=utf-8` (salvo media y HTML)  
**Auth:** ninguna  
**CORS:** `Access-Control-Allow-Origin: *` en todas las respuestas. `OPTIONS` permitido (`GET, POST, OPTIONS`, header `Content-Type`).

Con un **nombre de juego** y un **proveedor**, la API toma **siempre** el arte de lobby de **Stake** (`thumbnailUrl` en mediumrare/imgix), lo recorta en modo cover y devuelve archivos **WebP** en tamaños de píxel configurables. El GraphQL de esa búsqueda sale **siempre por IP de Chile**.

Tamaños por defecto: **400×400**, **400×300**, **300×400**. Cualquier tamaño extra (por ejemplo **900×1200**) se agrega por query, body, env o `config.json` — el renderer no limita cuántos tamaños pedís.

---

## Contenido

1. [Inicio rápido](#inicio-rápido)
2. [Convenciones](#convenciones)
3. [GET /v1/images](#get-v1images)
4. [POST /v1/images](#post-v1images)
5. [GET /v1/info](#get-v1info)
6. [Modo de información extendida](#modo-de-información-extendida)
7. [Esquemas de respuesta](#esquemas-de-respuesta)
8. [GET /media/{key}/{WxH}.webp](#get-mediakeywxhwebp)
9. [GET /v1/providers](#get-v1providers)
9b. [GET /v1/suggest](#get-v1suggest)
10. [GET /v1/sizes](#get-v1sizes)
11. [GET /health](#get-health)
12. [UI de prueba](#ui-de-prueba)
13. [Errores](#errores)
14. [Proveedores y alias](#proveedores-y-alias)
15. [Cómo se elige la fuente](#cómo-se-elige-la-fuente)
16. [Configuración de tamaños](#configuración-de-tamaños)
17. [Pipeline de imagen](#pipeline-de-imagen)
18. [Caché y URLs públicas](#caché-y-urls-públicas)
19. [Arranque y entorno](#arranque-y-entorno)
20. [Catálogo](#catálogo)
21. [Ejemplo medido — Azure Roulette](#ejemplo-medido-azure-roulette)
22. [Ejemplo medido — Blood Suckers (extendido)](#ejemplo-medido-blood-suckers-extendido)
23. [OpenAPI](#openapi)

---

## Inicio rápido

```bash
# Objeto completo (juego + source + images[])
curl -sS 'https://grabbr.jailbroken.tech/v1/images?name=azure%20roulette&provider=pragmatic%20play%20live'

# Solo el array de las tres imágenes
curl -sS 'https://grabbr.jailbroken.tech/v1/images?name=azure%20roulette&provider=pragmatic%20play%20live&format=array'

# Tamaño extra
curl -sS 'https://grabbr.jailbroken.tech/v1/images?name=azure%20roulette&provider=pragmatic%20play%20live&sizes=400x400,400x300,300x400,900x1200'

# Stats extendidos (RTP, varianza, max win, …) + imágenes oficiales
curl -sS 'https://grabbr.jailbroken.tech/v1/images?name=Blood%20Suckers&provider=NetEnt&mode=extended'

# Solo stats
curl -sS 'https://grabbr.jailbroken.tech/v1/info?name=Blood%20Suckers&provider=NetEnt'

# POST
curl -sS -X POST https://grabbr.jailbroken.tech/v1/images \
  -H 'content-type: application/json' \
  -d '{"name":"azure roulette","provider":"Pragmatic Play Live"}'
```

Documentación en inglés: [`API.en.md`](API.en.md) · OpenAPI: [`openapi.yaml`](openapi.yaml)

---

## Convenciones

| Regla | Detalle |
|-------|---------|
| Campo de juego | Query/body `name` **o** `game` (mismo significado). |
| Campo de proveedor | Texto libre. Match case-insensitive contra id, label y alias. |
| String de tamaño | `ANCHOxALTO`, solo dígitos, `x` insensible a mayúsculas. Rango **16–4000** por lado. Varios tamaños: separados por coma o espacio. |
| Tamaños default | De `config.json` → `sizes`, override con `GAME_IMAGES_SIZES` o `sizes` por request. |
| Formato de salida | Siempre **WebP**. Default recorte **cover** (centro, LANCZOS). Crop opcional del original grabado antes. Calidad default **82**. |
| Array de imágenes | Una entrada por tamaño pedido, **mismo orden** que la lista. |
| `format=array` | El body HTTP 200 es **solo** el array `images`. |
| URL pública | `url` = `{publicBase}/media/{key}/{W}x{H}.webp`. `path` es lo mismo sin host. |
| Scheme | `http` salvo que el request mande `X-Forwarded-Proto: https`. |
| Cache-Control | `public, max-age=86400` en HTTP 200; `no-store` en errores. |

---

## GET /v1/images

Resuelve arte oficial y devuelve variantes WebP.

**URL:** `/v1/images`

### Query parameters

| Nombre | Requerido | Tipo | Descripción |
|--------|-----------|------|-------------|
| `name` | sí* | string | Título del juego, p. ej. `azure roulette`. |
| `game` | sí* | string | Alias de `name`. Se usa si `name` está vacío. |
| `provider` | sí | string | Label o alias del proveedor, p. ej. `pragmatic play live`. |
| `sizes` | no | string | Override de tamaños, p. ej. `400x400,400x300,300x400` o `900x1200`. |
| `format` | no | string | `array` → el body es solo el array de imágenes. Cualquier otro valor → objeto completo. En este modo no se incluye `info`. |
| `mode` | no | string | `extended` (también `info` / `full`) agrega stats de SlotCatalog en `info`. Las imágenes siguen siendo oficiales, sin marca de agua. |
| `extended` | no | string | `1` / `true` / `yes` — igual que `mode=extended`. |
| `crop` | no | string | Caja opcional sobre el **original grabado**: `x,y,ancho,alto`. Si se omite, cover centrado. |
| `cropUnit` | no | string | `px` o `ratio` (0–1). Si se omite: los cuatro valores ≤ 1 → ratio, si no píxeles. |
| `cropX` `cropY` `cropW` `cropH` | no | number | La misma caja en cuatro query params. |

\* Uno de `name` o `game` tiene que venir con valor.

El crop opcional se aplica al original de Stake **antes** del cover-fit de cada tamaño. Las variantes auto quedan en `/media/{key}/{W}x{H}.webp`. Las recortadas usan `/media/{key}/{cropToken}/{W}x{H}.webp`. El original sin tocar: `GET /media/{key}/original`. La respuesta incluye `original` y `crop` (`null` si no se usó).

### Ejemplo

```http
GET /v1/images?name=azure%20roulette&provider=pragmatic%20play%20live HTTP/1.1
Host: 127.0.0.1:8790
```

El body 200 medido está en [ejemplo medido](#ejemplo-medido-azure-roulette).

---

## POST /v1/images

Mismo handler que GET. Body JSON.

**URL:** `/v1/images`  
**Header:** `Content-Type: application/json`

### Body

```json
{
  "name": "azure roulette",
  "game": "opcional, se usa si falta name",
  "provider": "Pragmatic Play Live",
  "sizes": "400x400,400x300,300x400",
  "format": "array",
  "crop": { "x": 40, "y": 20, "width": 280, "height": 280, "unit": "px" }
}
```

| Campo | Requerido | Tipo | Descripción |
|-------|-----------|------|-------------|
| `name` | sí* | string | Título del juego. |
| `game` | sí* | string | Alias de `name`. |
| `provider` | sí | string | Proveedor. |
| `sizes` | no | string o array | `"400x400,900x1200"` **o** `[{ "id": "poster", "width": 900, "height": 1200 }, "400x400"]`. |
| `format` | no | string | `"array"` para un array pelado. |
| `mode` | no | string | `"extended"` para incluir `info`. |
| `extended` | no | boolean | `true` — igual que `mode: "extended"`. |
| `crop` | no | object o string | Opcional. `{ "x", "y", "width", "height", "unit": "px"|"ratio" }` o `"x,y,w,h"`. Sin esto: cover centrado. |

JSON inválido → `400` `{ "error": "invalid json" }`.

```bash
curl -sS -X POST https://grabbr.jailbroken.tech/v1/images \
  -H 'content-type: application/json' \
  -d '{
    "name": "Gates of Olympus",
    "provider": "Pragmatic Play",
    "sizes": [{"id":"square","width":400,"height":400},{"id":"poster","width":900,"height":1200}]
  }'
```

---

## GET /v1/info

Solo metadata estilo SlotCatalog. Sin imágenes.

**URL:** `/v1/info`  
También **POST** `/v1/info` con JSON `{ "name", "provider" }`.

| Nombre | Requerido | Tipo | Descripción |
|--------|-----------|------|-------------|
| `name` / `game` | sí | string | Título. Se prueban alias y orden invertido (`azure roulette` → `Roulette Azure`). |
| `provider` | no | string | Ayuda a elegir la fila correcta (`NetEnt` vs un remake Megaways). |

HTTP **200** si hay página; **404** `{ "info": { "found": false, … } }` si el search no pega.

```bash
curl -sS 'https://grabbr.jailbroken.tech/v1/info?name=Blood%20Suckers&provider=NetEnt'
```

---

## Modo de información extendida

El mismo tipo de ficha que [la página de SlotCatalog](https://slotcatalog.com/en/slots/Blood-Suckers) (proveedor, RTP, varianza, max win, betways, fecha de salida, más type / layout / apuestas / features / themes / rating si están).

**Las imágenes siempre salen de Stake** (`thumbnailUrl` / imgix). Los thumbs de SlotCatalog llevan marca de agua y no se usan. El host `slotcatalog.com` está prohibido como fuente de imagen.

| Cómo activarlo | Ejemplo |
|----------------|---------|
| Query | `mode=extended` o `extended=1` en `/v1/images` |
| Body JSON | `"mode": "extended"` o `"extended": true` |
| Solo stats | `GET /v1/info` |

Si SlotCatalog no tiene hit, los stats pueden salir de Stake o de un stub `generated`. `images[]` sigue siendo solo Stake. HTTP 404 cuando Stake no tiene thumbnail. `format=array` sigue siendo solo imágenes.

Search: typeahead SlotCatalog (`/json.php?blck=topSrch`). Se parsea la tabla `#attributes` y el JSON-LD VideoGame. HTML cacheado en `cache/slotcatalog/` 7 días. Fallback de metadata+thumb: Stake `kuratorGameQuery`, siempre por IP de Chile.

---

## Esquemas de respuesta

### Objeto completo (`format` omitido)

```json
{
  "game": "Roulette Azure",
  "query": "azure roulette",
  "provider": "pragmatic-play-live",
  "providerLabel": "Pragmatic Play Live",
  "source": {
    "url": "https://mediumrare.imgix.net/....",
    "kind": "stake-thumbnail",
    "page": "https://stake1017.com/casino/games/..."
  },
  "images": [ { "...": "ImageObject" } ]
}
```

| Campo | Tipo | Descripción |
|-------|------|-------------|
| `game` | string | Título de catálogo si existe; si no, el nombre consultado. |
| `query` | string | Nombre que enviaste. |
| `provider` | string | **Id** canónico del proveedor (slug). |
| `providerLabel` | string | Label humano. |
| `source.url` | string | URL absoluta del original descargado. |
| `source.kind` | string | Cómo se encontró. Ver [kinds de fuente](#kinds-de-fuente). |
| `source.symbol` | string o null | Id del juego en el proveedor (PP `227`, `vs20olympgate`, …). |
| `source.page` | string o null | Página de marketing/catálogo si se scrapeo HTML. |
| `images` | array | Un WebP por tamaño pedido. |
| `mode` | string | Si extendido: `"extended"`. |
| `info` | object | Si extendido. Ver [InfoObject](#infoobject). |
| `imagesError` | string | Solo si extendido y falló el arte oficial. |

### ImageObject (cada ítem de `images[]`)

| Campo | Tipo | Descripción |
|-------|------|-------------|
| `id` | string | Id del tamaño (`square`, `landscape`, `portrait`, o `{W}x{H}`). |
| `size` | string | `{width}x{height}` |
| `width` | integer | Ancho de salida en píxeles. |
| `height` | integer | Alto de salida en píxeles. |
| `format` | string | Siempre `"webp"`. |
| `url` | string | URL absoluta del archivo. |
| `path` | string | Solo path, p. ej. `/media/{key}/400x400.webp`. |
| `bytes` | integer | Tamaño en bytes de ese WebP. |

### InfoObject (`info` con `mode=extended`, y `/v1/info`)

Forma de la tabla de atributos de SlotCatalog. `null` si la página no tiene valor (`N/A` → `null`).

| Campo | Tipo | Ejemplo Blood Suckers |
|-------|------|------------------------|
| `found` | boolean | `true` |
| `source` | string | `"slotcatalog"` |
| `page` | string | `https://slotcatalog.com/en/slots/Blood-Suckers` |
| `name` | string | `Blood Suckers` |
| `providerName` | string | `NetEnt` |
| `providerPage` | string | `https://slotcatalog.com/en/soft/NetEnt` |
| `rtp` | string | `98%` |
| `rtpValue` | number | `98` |
| `rtpRanges` | boolean | `true` si la página tiene **RTP Ranges!** |
| `variance` | string | `Low` |
| `maxWin` | string | `x1014.00` |
| `maxWinValue` | number | `1014` |
| `betways` | string | `25` |
| `betwaysValue` | integer | `25` |
| `releaseDate` | string | `2013-11-10` |
| `type` | string | `Video Slots` |
| `layout` | string | `5-3` |
| `minBet` / `maxBet` | string | `0.25` / `50` |
| `features` / `themes` | string[] | listas de mecánicas |
| `rating` / `ratingValue` | string / number | `8.5` |
| `cached` | boolean | hit de caché en disco |

**No hay URL de imagen en `info`.** El `image` del JSON-LD de SlotCatalog se descarta.

### Array pelado (`format=array`)

El body HTTP 200 es `ImageObject[]` — los mismos tres (o N) ítems, sin wrapper.

---

## GET /media/{key}/original

Original de Stake sin recortar (JPEG/PNG/WebP tal como se bajó). Lo usa el overlay del tester.

## GET /media/{key}/{WxH}.webp

WebP binario generado por `/v1/images` **sin** crop de usuario (cover centrado).

| Pieza | Regla |
|-------|-------|
| `key` | 20 hex (`[a-f0-9]{20}`). Prefijo SHA-256 de `providerId\|nombreNormalizado\|sourceUrl`. |
| `WxH` | Mismo `anchoxalto` pedido, p. ej. `400x400`. |

## GET /media/{key}/{cropToken}/{WxH}.webp

Mismos tamaños después de un crop opcional. `cropToken` tipo `c-px-40-20-280-280`. No pisa los archivos auto.

**Headers en 200:** `Content-Type: image/webp`, CORS, `Cache-Control: public, max-age=86400`.

**404 JSON:**

- `{ "error": "bad media path" }` — el path no matchea el patrón.
- `{ "error": "missing variant" }` — esa key/tamaño todavía no se generó.

Primero llamá `/v1/images`; después usá `url` / `path` de la respuesta. No inventes keys.

---

## GET /v1/suggest

Typeahead de casino Stake (`kuratorGameQuery`, GraphQL Chile). Lo usa el autocomplete del tester (dispara cada **2** caracteres).

**URL:** `/v1/suggest`  
**También POST** JSON `{ "q", "provider?", "limit?" }`

| Param | Req | Notas |
|-------|-----|--------|
| `q` | sí | Alias `query` / `name`. Mínimo 2 caracteres. |
| `provider` | no | Prioriza ese proveedor. |
| `limit` | no | Default 20, máx 50. |

```bash
curl -sS 'https://grabbr.jailbroken.tech/v1/suggest?q=sw&provider=pragmatic-play'
```

`games[]`: `name`, `slug`, `provider`, `providerLabel`, `thumbnailUrl`, `page`, `type`. Lista vacía si no hay match (HTTP 200).

---

## Capture (REA v2)

`/v1/images` incluye `slot.url` (página del juego en Stake). Capture abre esa URL en el Chrome Chile, hace click en Play, mapea cada respuesta capturable (iframes + CDN: imágenes, audio, configs, sprites, JS, HTML) y rearmá el árbol `host/path` como el slot original.

| Método | Path | Notas |
|--------|------|--------|
| POST | `/v1/capture` | Body `{ "name", "provider", "slotUrl?" }`. HTTP 202 job. |
| GET | `/v1/capture` | Jobs recientes. |
| GET | `/v1/capture/{id}` | Status, árbol, kinds, counts. |
| GET | `/v1/capture/{id}/bundle.zip` | Zip entero (árbol original + `MANIFEST.json`). |
| POST | `/v1/capture/{id}/bundle` | Body `{ "paths": ["host/a.png"] }` zip seleccionado. |
| GET | `/v1/capture/{id}/file?path=` | Un archivo. |

Poll hasta `status` `done` o `error`. Tester: modo **Capture**.

---

## GET /v1/providers

Lista ids canónicos, labels y alias.

```http
GET /v1/providers HTTP/1.1
```

```json
{
  "providers": [
    {
      "id": "pragmatic-play-live",
      "label": "Pragmatic Play Live",
      "aliases": [
        "pragmatic play live",
        "pragmatic live",
        "pp live",
        "pragmaticplaylive",
        "pragmatic play live casino"
      ]
    }
  ]
}
```

---

## GET /v1/sizes

Lista de tamaños **actualmente cargada** (`config.json` + `GAME_IMAGES_SIZES`).

```json
{
  "sizes": [
    { "id": "square", "width": 400, "height": 400 },
    { "id": "landscape", "width": 400, "height": 300 },
    { "id": "portrait", "width": 300, "height": 400 }
  ]
}
```

---

## GET /health

Liveness. No chequea CDNs upstream.

```json
{
  "ok": true,
  "service": "game-images-api",
  "images": "stake",
  "chile": { "ok": true, "requireChile": true, "city": "Pucón", "countryCode": "CL" },
  "stakeCdp": true
}
```

`images` es siempre `"stake"`. `stakeCdp` es el Chromium headed (Chile) usado para GraphQL.

---

## UI de prueba

| Path | Tipo |
|------|------|
| `GET /` | Tester: dropdown de proveedores con buscador + autocomplete de juego cada 2 teclas (Stake) → `/v1/images`. |
| `GET /index.html` | La misma página. |
| `GET /docs` | Índice de documentación (EN / ES). |
| `GET /docs/en` | Este documento en HTML (inglés). |
| `GET /docs/es` | Este documento en HTML (español). |
| `GET /docs/API.en.md` | Markdown en inglés. |
| `GET /docs/API.es.md` | Markdown en español. |
| `GET /openapi.yaml` | OpenAPI 3.1. |
| `GET /openapi.json` | La misma spec en JSON. |

---

## Errores

Todos los errores son JSON `{ "error": "..." }` más campos extra cuando aplica. `Cache-Control: no-store`.

| HTTP | `error` | Cuándo |
|------|---------|--------|
| 400 | `name and provider are required` | Falta name/game o provider. |
| 400 | `unknown provider` | El string de proveedor no matcheó. Extra: `provider`, `known` (lista de labels). |
| 400 | `invalid json` | El body POST no es JSON. |
| 400 | `no valid sizes (expected 400x400,400x300,300x400)` | `sizes` parseó a lista vacía. |
| 400 | `size out of range: {token}` | Ancho o alto menor que 16 o mayor que 4000. |
| 404 | `not found` | Ruta desconocida. |
| 404 | `bad media path` | Forma de `/media/...` incorrecta. |
| 404 | `missing variant` | El archivo no está en disco. |
| 404 | `no official image found for game='…' provider=…` | El resolver se quedó sin fuentes. Extra: `game`, `provider` (id canónico). |
| 502 | (mensaje de excepción) | Falló descarga, decode o render. |

Ejemplo de proveedor desconocido:

```json
{
  "error": "unknown provider",
  "provider": "unknown",
  "known": [
    "Pragmatic Play",
    "Pragmatic Play Live",
    "KAGaming",
    "Ruby Play",
    "Evolution Gaming",
    "Booming Games",
    "100HP",
    "Ezugi",
    "Live Vegas",
    "NetEnt"
  ]
}
```

---

## Proveedores y alias

Mandá **cualquier** alias; la API guarda el **id**.

| id | Label | Alias |
|----|-------|-------|
| `pragmatic-play` | Pragmatic Play | `pragmatic play`, `pragmatic`, `pp`, `pragmaticplay` |
| `pragmatic-play-live` | Pragmatic Play Live | `pragmatic play live`, `pragmatic live`, `pp live`, `pragmaticplaylive`, `pragmatic play live casino` |
| `kagaming` | KAGaming | `ka gaming`, `ka`, `kaga`, `kaga88`, `ka gaming kaga` |
| `ruby-play` | Ruby Play | `rubyplay`, `ruby play`, `ruby` |
| `evolution-gaming` | Evolution Gaming | `evolution`, `evolution gaming`, `evo`, `evolution live` |
| `booming-games` | Booming Games | `booming`, `booming games` |
| `100hp` | 100HP | `100hp`, `100 hp`, `100hp gaming`, `100 hp gaming` |
| `ezugi` | Ezugi | `ezugi`, `ezugi live`, `ezugi gaming` |
| `live-vegas` | Live Vegas | `live vegas`, `livevegas`, `vegas live` |
| `netent` | NetEnt | `netent`, `net ent` |

El match es case-insensitive, id con o sin guiones/espacios, después alias exacto, después contains para alias de longitud ≥ 5.

Dominios oficiales para rankear hits de búsqueda:

| id | Hosts |
|----|-------|
| `pragmatic-play`, `pragmatic-play-live` | `pragmaticplay.com`, `ppgames.net`, `pragmaticplay.net` |
| `kagaming` | `kaga88.com`, `kagaming.com` |
| `ruby-play` | `rubyplay.com` |
| `evolution-gaming` | `games.evolution.com`, `evolution.com`, `egcdn.com` |
| `booming-games` | `booming-games.com`, `boominggames.com` |
| `100hp` | `100hp.game`, `100hp-games.com`, `casino.100hp.game` |
| `ezugi` | `ezugi.com`, `games.evolution.com` |
| `live-vegas` | `livevegas.live`, `live-vegas.com` |
| `netent` | `netent.com`, `netentcloud.com` |

CDN extra de confianza (después de hosts oficiales): `agstatic.com`, `primeapi.com`, `softswiss.net`.

---

## Cómo se elige la fuente

Las **imágenes siempre** salen de Stake: `thumbnailUrl` del lobby (imgix/mediumrare). SlotCatalog, CDN de PP, Bing y páginas de estudio **no** se usan como fuente de imagen. GraphQL siempre por IP de Chile (Chromium headed). HTTP **404** si Stake no tiene el juego o el thumb no se puede bajar.

Los **stats** extendidos siguen pudiendo venir de SlotCatalog; las tres WebP siguen siendo Stake.

### Kinds de fuente

| `kind` | Significado |
|--------|-------------|
| `stake-thumbnail` | Único kind en vivo: `thumbnailUrl` de Stake (nunca SlotCatalog). |

---

## Configuración de tamaños

Tres capas; gana la última aplicable **por request**:

1. `config.json` → `sizes`
2. Env del proceso `GAME_IMAGES_SIZES=400x400,400x300,300x400,900x1200`
3. `sizes` del request (query o JSON)

Ejemplo `config.json`:

```json
{
  "sizes": [
    { "id": "square", "width": 400, "height": 400 },
    { "id": "landscape", "width": 400, "height": 300 },
    { "id": "portrait", "width": 300, "height": 400 }
  ]
}
```

Agregar 900×1200 mañana sin tocar código:

```bash
GAME_IMAGES_SIZES=400x400,400x300,300x400,900x1200 python3 server.py
```

O en un solo llamado:

```bash
curl -sS 'https://grabbr.jailbroken.tech/v1/images?name=azure%20roulette&provider=pp%20live&sizes=900x1200'
```

`GET /v1/sizes` relee `config.json` en **cada** request.

---

## Pipeline de imagen

1. Descarga bytes originales (PNG/JPEG/WebP/GIF).
2. PIL load + EXIF transpose.
3. `ImageOps.fit` **cover** a `(width, height)`, LANCZOS, centro `(0.5, 0.5)`.
4. Aplana alpha sobre `#0c0c10`.
5. Guarda WebP `quality=webpQuality` (default 82), `method=6`.

Cover llena el recuadro; el excedente se recorta. El thumb de Stake (imgix ~800px) se recorta a cada tamaño pedido.

---

## Caché y URLs públicas

| Path | Contenido |
|------|-----------|
| `cache/originals/{key}.bin` | Original descargado. |
| `cache/variants/{key}/{W}x{H}.webp` | WebP renderizado. |

`key` = `sha256(providerId|nombreNormalizado|sourceUrl)[:20]`.

`/v1/images` escribe esos archivos y devuelve URLs. `/media/...` solo **lee**.

`publicBase` en config o `GAME_IMAGES_PUBLIC_BASE` prefija `url`. Vacío → `http(s)://{Host}`.

`cacheTtlSec` en `config.json` está reservado; no hay eviction. Borrá `cache/` para regenerar.

---

## Arranque y entorno

```bash
python3 /opt/hallallow/apps/game-images-api/server.py
```

Bind default: `0.0.0.0:8790`. Dependencia: **Pillow** (`requirements.txt`).

| Variable | Efecto |
|----------|--------|
| `GAME_IMAGES_CONFIG` | Path al JSON de config (default `./config.json`). |
| `GAME_IMAGES_PORT` | Puerto. |
| `GAME_IMAGES_SIZES` | Lista default, `WxH` separados por coma. |
| `GAME_IMAGES_PUBLIC_BASE` | Prefijo absoluto de `images[].url` (CDN / reverse proxy). |

Otras keys de `config.json`: `host`, `port`, `publicBase`, `webpQuality`, `fit` (`cover`), `userAgent`, `timeoutSec` (HTTP upstream, default 18s), `sizes`.

Detrás de TLS, mandá `X-Forwarded-Proto: https` para que `url` salga `https://...`.

---

## Catálogo

Archivo: `data/catalog.json`. Semilla opcional para que juegos live resuelvan sin página de marketing.

```json
{
  "games": [
    {
      "provider": "pragmatic-play-live",
      "names": ["azure roulette", "roulette azure", "roulette 1 azure"],
      "symbol": "227",
      "title": "Roulette Azure"
    }
  ]
}
```

Match: nombre exacto (score 100), contains (70+), overlap de tokens (60+). Mínimo 60. Nombres normalizados (minúsculas, guiones → espacios).

Agregá una fila cuando sepas el id PP/Evolution. No hace falta restart; el catálogo se lee en cada resolve.

---

## Ejemplo medido — Azure Roulette

Medido en este host contra la CDN de Pragmatic Play `rec/325/227.png`.

**Request**

```bash
curl -sS 'https://grabbr.jailbroken.tech/v1/images?name=azure%20roulette&provider=pragmatic%20play%20live'
```

**Respuesta (HTTP 200)**

```json
{
  "game": "Roulette Azure",
  "query": "azure roulette",
  "provider": "pragmatic-play-live",
  "providerLabel": "Pragmatic Play Live",
  "source": {
    "url": "https://mediumrare.imgix.net/....",
    "kind": "stake-thumbnail",
    "page": "https://stake1017.com/casino/games/..."
  },
  "images": [
    {
      "id": "square",
      "size": "400x400",
      "width": 400,
      "height": 400,
      "format": "webp",
      "url": "https://grabbr.jailbroken.tech/media/1f976b03a7d9dde1cb54/400x400.webp",
      "path": "/media/1f976b03a7d9dde1cb54/400x400.webp",
      "bytes": 28968
    },
    {
      "id": "landscape",
      "size": "400x300",
      "width": 400,
      "height": 300,
      "format": "webp",
      "url": "https://grabbr.jailbroken.tech/media/1f976b03a7d9dde1cb54/400x300.webp",
      "path": "/media/1f976b03a7d9dde1cb54/400x300.webp",
      "bytes": 26408
    },
    {
      "id": "portrait",
      "size": "300x400",
      "width": 300,
      "height": 400,
      "format": "webp",
      "url": "https://grabbr.jailbroken.tech/media/1f976b03a7d9dde1cb54/300x400.webp",
      "path": "/media/1f976b03a7d9dde1cb54/300x400.webp",
      "bytes": 22324
    }
  ]
}
```

`format=array` devuelve solo ese array `images`.  
`sizes=900x1200,400x400` agregó un WebP 900×1200 (70384 bytes) más 400×400.

Segundo juego medido: **Gates of Olympus** / Pragmatic Play → `vs20olympgate` en la misma CDN rec/325, tres WebP con tamaños de píxel exactos.

---

## Ejemplo medido — Blood Suckers (extendido)

Los stats coinciden con la tabla de atributos de [Blood Suckers](https://slotcatalog.com/en/slots/Blood-Suckers). Las imágenes, si hay, siguen siendo oficiales (nunca `/userfiles/image/games/…` en slotcatalog.com).

```bash
curl -sS 'https://grabbr.jailbroken.tech/v1/images?name=Blood%20Suckers&provider=NetEnt&mode=extended'
```

`info` incluye:

| Campo | Valor |
|-------|--------|
| Provider | NetEnt (`/en/soft/NetEnt`) |
| RTP | 98% (`rtpRanges: true`) |
| Variance | Low |
| Max Win | x1014.00 |
| Betways | 25 |
| Release Date | 2013-11-10 |

Solo stats:

```bash
curl -sS 'https://grabbr.jailbroken.tech/v1/info?name=Blood%20Suckers&provider=NetEnt'
```

---

## OpenAPI

Spec machine-readable: [`openapi.yaml`](openapi.yaml) (también `GET /openapi.yaml` y `GET /openapi.json`).

Generar clientes (ejemplo):

```bash
npx @redocly/cli build-docs https://grabbr.jailbroken.tech/openapi.yaml
```
