---
title: "Médias"
description: "Tableau de bord media. Tous les compteurs viennent de la table hxa.media (base unique) : contrairement à GET /admin/stats (multi-bases), il n'y a pas de dé…"
---

## `GET /admin/media/stats`

Tableau de bord media. Tous les compteurs viennent de la table `hxa.media` (base unique) : contrairement à `GET /admin/stats` (multi-bases), il n'y a **pas** de dégradation par section — si la base est injoignable, l'appel échoue via le gestionnaire d'erreurs admin.

Couvre : total de médias, en attente de validation, uploads **par jour** (courbe), **top 5 des pays** et **total de médias par pays**.

**Paramètres**

| Paramètre | Défaut | Sens |
|---|---|---|
| `days` | `30` | longueur de la courbe d'uploads par jour, bornée **1..366**. |

**Réponse (200)**

```json
{
  "total":     53120,
  "published": 51002,
  "rejected":  88,
  "pending":   2030,
  "perDay": {
    "days": 30,
    "from": "2026-05-23",
    "to":   "2026-06-21",
    "total": 1480,
    "series": [
      { "date": "2026-05-23", "count": 0 },
      { "date": "2026-05-24", "count": 61 }
    ]
  },
  "topCountries": [
    { "country": "FR", "name": "France",        "count": 21044 },
    { "country": "US", "name": "United States",  "count": 9032 },
    { "country": "ES", "name": "Spain",          "count": 4110 },
    { "country": "IT", "name": "Italy",          "count": 3897 },
    { "country": "DE", "name": "Germany",        "count": 2510 }
  ],
  "byCountry": [
    { "country": "FR", "name": "France",         "count": 21044 },
    { "country": "US", "name": "United States",   "count": 9032 }
  ],
  "withoutCountry": 1200
}
```

| Champ | Sens |
|---|---|
| `total` / `published` / `rejected` / `pending` | mêmes définitions que la section `media` de `GET /admin/stats` (`pending` = ni publié ni rejeté). |
| `perDay.series` | uploads par jour (`media.created_at`), **zero-fillé** : chaque jour de la fenêtre est présent, `count: 0` les jours sans upload. Courbe continue. |
| `perDay.total` | somme des uploads sur la fenêtre. |
| `topCountries` | 5 premiers pays par nombre de médias (code ISO 3166-1 alpha-2 + `name`), ordre décroissant. |
| `byCountry` | **tous** les pays avec leur nombre de médias, ordre décroissant (`topCountries` en est la tête). |
| `country` / `name` | code ISO et **nom** du pays, résolu en **un seul appel batch** à l'index Meili `countries`. `name` vaut `null` si le code est absent de l'index (ou Meili injoignable — les compteurs restent servis, seul le libellé manque). |
| `withoutCountry` | médias sans pays (`country_id NULL`, upload sans GPS) — exclus des buckets pays. |

> Les axes `created_at` et `country_id` sont désormais **indexés** (migration `2026_06_21_120000_add_media_stats_indexes.sql`) : la courbe par jour devient un range scan et la répartition par pays un parcours d'index ordonné. Pour des tendances **historiques précalculées** multi-domaines, voir `GET /admin/stats/trends`.

**Exemple**

```bash
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/media/stats?days=90"
```

> `GET /admin/media/stats` est un **instantané live** (top pays = total all-time au moment de l'appel). Pour les **tendances pays dans le temps** (uploads par pays **par jour**), voir `GET /admin/media/stats/countries` ci-dessous.

---


## `GET /admin/media/stats/countries`

Tendances d'uploads de médias **par pays et par jour**. Contrairement à `GET /admin/media/stats` (live), ces séries sont **précalculées** une fois par jour par le worker `bin/platform-metrics-rollup.php` dans la table `hxa_bo.media_country_daily` — l'endpoint ne lit **que** `hxa_bo`, jamais `hxa.media`.

**Sélection des pays** (par ordre de priorité) :
1. `?country=FR,US` — liste CSV explicite de codes ISO 3166-1 alpha-2. Un code mal formé → **400**.
2. Aucun → les **top `?limit` pays** par uploads sur la fenêtre.

**Paramètres**

| Paramètre | Défaut | Sens |
|---|---|---|
| `days` | `30` | longueur de la fenêtre en jours, bornée **1..366**. |
| `limit` | `10` | nombre de pays quand `?country` est absent, borné **1..50** (ignoré si `?country` est fourni). |
| `country` | — | CSV de codes ISO ; force la sélection sur ces pays. |

**Réponse (200)**

```json
{
  "from": "2026-05-23",
  "to":   "2026-06-21",
  "days": 30,
  "countries": [
    {
      "country": "FR",
      "name":    "France",
      "total":   1200,
      "series": [
        { "date": "2026-05-23", "count": 0 },
        { "date": "2026-05-24", "count": 61 }
      ]
    }
  ]
}
```

| Champ | Sens |
|---|---|
| `countries[].series` | uploads du pays **par jour**, **zero-fillé** sur toute la fenêtre (courbe continue). |
| `countries[].total` | somme des uploads du pays sur la fenêtre. |
| `countries[].name` | nom résolu en **un seul appel batch** à l'index Meili `countries` (`null` si code absent / Meili injoignable). |

Les pays sont triés par `total` décroissant. Les médias **sans pays** (upload sans GPS) ne sont pas dans cette table — ils restent visibles via `withoutCountry` de `GET /admin/media/stats`.

> Source : table `hxa_bo.media_country_daily` (migration `2026_06_21_140000_create_media_country_daily.sql`), alimentée par le **même** worker quotidien que `GET /admin/stats/trends`. La série pour un pays jamais vu sur la fenêtre est entièrement à `0`.

**Exemple**

```bash
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/media/stats/countries?country=FR,US,ES&days=90"
```

---


## `GET /admin/media/{hex}/trends`

Série temporelle d'**engagement d'UN média** — cinq mesures **par jour** : `views`, `impressions`, `likes`, `dislikes`, `comments`. Impossible à reconstruire depuis `hxa.media_stats` (qui ne garde que le total **cumulé** à vie, sans découpage par jour) : les séries sont **précalculées** par le worker `bin/media-engagement-rollup.php` dans `hxa_bo.media_engagement_daily`. L'endpoint ne lit **que** `hxa_bo` (+ le cumulé `hxa.media_stats` pour l'en-tête `totals`).

| Paramètre | Emplacement | Défaut | Sens |
|---|---|---|---|
| `hex` | path | — | id du média, 32 hex minuscules (sans tirets). |
| `days` | query | `30` | longueur de la fenêtre (finissant aujourd'hui), bornée **1..366**. |

**Réponse (200)**

```json
{
  "mediaId": "d26d1600cde54bd095e09f8b68ace05f",
  "from": "2026-06-04",
  "to":   "2026-07-03",
  "days": 30,
  "totals": { "views": 12043, "impressions": 88120, "likes": 210, "dislikes": 4, "comments": 33 },
  "series": [
    { "date": "2026-06-04", "views": 0,   "impressions": 0,    "likes": 0, "dislikes": 0, "comments": 0 },
    { "date": "2026-06-05", "views": 512, "impressions": 4300, "likes": 9, "dislikes": 0, "comments": 2 }
  ]
}
```

| Champ | Sens |
|---|---|
| `totals` | compteurs **cumulés à vie** (source de vérité `hxa.media_stats`) — toujours exacts. |
| `series` | engagement **par jour**, **zero-fillé** sur toute la fenêtre (courbe continue). |

**Erreurs**

| Status | Body |
|---|---|
| `400` | `{ "error": "Invalid days." }` |
| `404` | `{ "error": "Media not found." }` |
| `403` | `{ "error": "..." }` |

> **Caveat FLOW sur likes/dislikes** : la série compte les réactions **encore vivantes** créées ce jour-là. Un « un-like » **supprime** la ligne `media_reaction`, donc une réaction posée puis annulée le même jour n'apparaît pas dans la série (sous-compte). Les `totals`, eux, restent exacts. `views`/`impressions`/`comments` ne sont pas concernés.

> Source : table `hxa_bo.media_engagement_daily` (migration `2026_07_04_120000_create_media_engagement_daily.sql`), alimentée par `bin/media-engagement-rollup.php` (cron quotidien). Le worker refold une fenêtre glissante (`MEDIA_ENGAGEMENT_ROLLUP_LOOKBACK_DAYS`, def 7) et **purge** au-delà de `MEDIA_ENGAGEMENT_RETENTION_DAYS` (def 366, ~1 an) pour garder la table bornée. À lancer **après** `media-counters-flush` (qui pose les views/impressions du jour).

**Exemple**

```bash
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/media/d26d1600cde54bd095e09f8b68ace05f/trends?days=90" | jq
```

---


## `GET /admin/media/recent`

Derniers médias ajoutés (projection **légère**), pour la page « Médias » du back-office. Même projection que la carte d'activité du dashboard (`recentMedia` de `GET /admin/stats`), mais paginable sans payer le coût du gros agrégat multi-bases.

| Paramètre | Emplacement | Défaut | Description |
|-----------|-------------|--------|-------------|
| `limit`   | query       | `24`   | nombre d'entrées, borné **1..50** côté repository. |

Chaque entrée porte le **lien public complet** du média (`url`, WebP redimensionné) et son compagnon blurhash (`blurhash` = la chaîne, `blurhashUrl` = le WebP 16px), tous deux résolus depuis l'id — la console peut donc afficher une vignette sans second appel.

**Réponse (200, JSON plat)**

```json
{
  "recentMedia": [
    {
      "id": "4f3c1a2b5d6e7f8091a2b3c4d5e6f700",
      "name": "Sunset over Paris",
      "country": "FR",
      "url": "http://hexatrip-static.dev.com/media/4f/3c/1a/4f3c1a2b5d6e7f8091a2b3c4d5e6f700.webp",
      "blurhash": "L6Pj0^jE.AyE_3t7t7R**0o#DgR4",
      "blurhashUrl": "http://hexatrip-static.dev.com/media/4f/3c/1a/4f3c1a2b5d6e7f8091a2b3c4d5e6f700-blurhash.webp",
      "latitude": 48.85,
      "longitude": 2.35,
      "status": "published",
      "createdAt": "2026-06-30T09:12:44+00:00"
    }
  ]
}
```

`status` vaut `rejected` / `published` / `pending`. `name`, `country`, `latitude`, `longitude` peuvent être `null` ; `url` et `blurhashUrl` sont toujours présents (dérivés de l'id).

**Exemple**

```bash
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/media/recent?limit=40"
```

---


## `GET /admin/media/{hex}`

Fiche **360°** d'un media unique en **JSON:API 1.1** (cf. [Convention de format](#convention-de-format--détails-360-en-jsonapi)) : **strict superset** du public `GET /api/media/{id}` — exactement la même enveloppe et la même forme d'attributs (via `MediaResourceSerializer`), enrichie des champs masqués (toujours visibles) et des annexes admin-only fusionnées dans `data.attributes`.

| Paramètre | Emplacement | Description |
|-----------|-------------|-------------|
| `hex`     | path        | id du media, 32 hex minuscules (sans tirets). |

Le media est rendu **avec son propriétaire comme viewer**, ce qui fait remonter le bloc de modération owner-only (`flag` / `isRejected`).

**Robustesse** : seule la ligne `media` est requise — `404` (erreur JSON:API) si elle est absente (ou si le `hex` est malformé). Chaque bloc **annexe** est chargé dans son propre try/catch ; une base annexe injoignable dégrade **ce bloc** en `{ "error": "<raison>" }` au lieu de faire échouer toute la fiche (même esprit fail-soft que `GET /admin/stats`).

**Attributs ajoutés au superset public** (fusionnés dans `data.attributes`, sans écraser une clé déjà émise par le serializer public)

| Clé | Base | Table / source | Type en cas d'absence |
|------|------|----------------|-----------------------|
| `userId`         | `hxa`    | `media.user_id` (hex à plat — le public expose `author.id`) | — |
| `countryId` / `regionId` / `subregionId` | `hxa` | ids géo bruts | `null` |
| `biomeId`        | `hxa`    | `media.biome_id` (biome WWF `1..15`, scalaire brut — le public expose le bloc `biome` `{id, name}`) | `null` |
| `flags`          | `hxa`    | décomposition lisible de `media.flag` (bitmask) | `[]` |
| `impressionsCount` | `hxa`  | `media_stats.impressions` | `0` |
| `exif`           | `hxa_bo` | `media_exif` (JSON EXIF brut décodé) | `null` |
| `fileMeta`       | `hxa_bo` | `media_meta` (mime/taille/dimensions source/marque/modèle) | `null` |
| `perceptualHash` | `hxa_bo` | `media_perceptual_hash` (16 hex réassemblés depuis les 4 shards) | `null` |
| `perceptualHashSvg` | *(dérivé de `perceptualHash`)* | SVG **8×8** de l'empreinte (fingerprint visuel), prêt à injecter inline (`innerHTML`) ; `currentColor` → s'adapte au thème BO | `null` |
| `describeQueue`  | `work`   | `media_to_describe` (`{ "inQueue": bool }`) | — |

Le champ `flag` est le bitmask de modération brut ; `flags` en donne la décomposition lisible (`illegal` 1, `violent` 2, `sexual` 4, `selfie` 8, `screenshot` 16, `ai_generated` 32).

**Exemple**

```bash
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  -H "Accept: application/vnd.api+json" \
  "http://hydrogen.dev.com/admin/media/4f3c1a2b5d6e7f8091a2b3c4d5e6f700"
```

```json
{
  "jsonapi": { "version": "1.1" },
  "data": {
    "type": "medias",
    "id": "4f3c1a2b-5d6e-7f80-91a2-b3c4d5e6f700",
    "attributes": {
      "type": "photo",
      "name": "…",
      "url": "http://hexatrip-static.dev.com/media/4f/3c/1a/4f3c1a2b5d6e7f8091a2b3c4d5e6f700.webp",
      "blurHash": "…",
      "blurhashUrl": "http://hexatrip-static.dev.com/media/4f/3c/1a/4f3c1a2b5d6e7f8091a2b3c4d5e6f700-blurhash.webp",
      "latitude": 48.85, "longitude": 2.35,
      "openLocationCode": "8FW4V75V+8Q",
      "width": 1920, "height": 1080,
      "orientation": "landscape",
      "biome": { "id": 4, "name": "Forêts tempérées" },
      "isPublished": true,
      "flag": 0,
      "isRejected": false,
      "stats": { "likes": 12, "dislikes": 0, "views": 340, "comments": 3 },
      "hashtags": [ { "slug": "paris", "display": "Paris" } ],
      "author":  { "id": "…", "username": "…", "displayName": "…", "level": 4 },
      "country": { "id": "fr", "name": "France", "slug": "france" },

      "userId": "…",
      "countryId": "FR", "regionId": "FR-IDF", "subregionId": null,
      "biomeId": 4,
      "flags": [],
      "impressionsCount": 980,
      "exif":    { "Make": "Canon", "Model": "EOS R6" },
      "fileMeta": { "mimeType": "image/jpeg", "sizeBytes": 4823100, "width": 6000, "height": 4000, "cameraBrand": "Canon", "cameraModel": "EOS R6" },
      "perceptualHash": "f0e1d2c3b4a59687",
      "perceptualHashSvg": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 87 87\" …>…8×8…</svg>",
      "describeQueue": { "inQueue": false }
    }
  }
}
```

```json
// 404 — media inexistant (erreur JSON:API)
{ "jsonapi": { "version": "1.1" }, "errors": [ { "status": "404", "title": "Media not found" } ] }
```

---


## `PUT /admin/media/{hex}`

Éditeur **éditorial** back-office d'un média. Couvre les champs qu'un opérateur
corrige à la main et qui **n'ont pas** d'endpoint dédié. JSON plat (convention
admin), partiel : **seuls les champs présents dans le body sont touchés**.

**Hors périmètre** (state machines / effets de bord dédiés, inchangés) :
publication → `PUT /admin/media/{hex}/published` · modération →
`PUT /admin/media/{hex}/flag` · cycle de vie pipeline (`claim`/`fail`/`describe`)
· géo (city/region/subregion/country, lat/lng) → `POST /admin/media/backfill-geo`.

**Body** (tous les champs optionnels)

| Champ | Type | Notes |
|---|---|---|
| `name` | string ≤255 \| `null` | Nom de fichier d'origine. `null` / `""` efface. |
| `shotAt` | ISO-8601 datetime | Date de prise de vue (parsée par Carbon). |
| `title` | string ≤250 \| `null` | Titre humain (`media_description`). `null` / `""` efface. |
| `metaTitle` | string ≤255 \| `null` | SEO. `null` / `""` efface. |
| `metaDescription` | string ≤500 \| `null` | SEO. `null` / `""` efface. |
| `description` | string ≤`MEDIA_DESCRIPTION_MAX_LENGTH` (1024) | Texte libre (`NOT NULL`, `""` autorisé). |
| `hashtags` | list\<string\> | **Remplacement complet** via le pipeline normalisation → blocklist → cap (`MEDIA_HASHTAGS_MAX`). Tokens invalides/bannis/au-delà du cap silencieusement écartés. |

**Écriture** : `name` + le bloc contenu sont appliqués dans **une transaction**
`hxa` ; les hashtags suivent (remplacement atomique géré par le repo) ; un
**reindex Meili best-effort** clôt l'opération si quelque chose a changé. Aucun
XP, aucune notif.

**Idempotent** : un body sans changement effectif renvoie `200 transition: "none"`
sans rien écrire (la comparaison hashtags se fait sur le set *accepté*, après
normalisation, donc renvoyer la même casse ne déclenche pas de réécriture).

**Réponse (200)**

```json
{
  "status":     "ok",
  "mediaId":    "d26d1600cde54bd095e09f8b68ace05f",
  "transition": "update",
  "changed":    ["name", "shotAt", "title", "description", "hashtags"],
  "hashtags":   ["paris", "sunset"]
}
```

- `changed` : liste des champs effectivement modifiés.
- `hashtags` : présent **uniquement** si `hashtags` a changé — le set accepté
  (post-normalisation/blocklist/cap), dans l'ordre persisté.

**Exemple curl**

```bash
curl -s -X PUT -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"title":"Coucher de soleil","description":"Vue depuis la jetée","hashtags":["paris","sunset"]}' \
  "http://hydrogen.dev.com/admin/media/d26d1600cde54bd095e09f8b68ace05f"
```

**Erreurs**

| Status | Body | Sens |
|---|---|---|
| `400` | `{ "error": "Body must be a JSON object." }` | JSON invalide / non-objet |
| `404` | `{ "error": "Media not found." }` | hex malformé ou aucun média |
| `422` | `{ "error": "Validation failed.", "fields": { "title": ["title.tooLong"] } }` | type invalide (`<field>.invalidType`), trop long (`<field>.tooLong`), `shotAt` non parsable (`shotAt.invalidFormat`) |
| `500` | `{ "error": "Failed to apply edit: ..." }` | échec de la transaction (rollback) |

---


## `POST /admin/media/{hex}/reindex`

Re-pousse un media unique dans Meilisearch, en relisant la DB (media + description + stats + hashtags) via [MediaIndexService::reindex()](../src/Domain/Media/MediaIndexService.php).

À appeler par Talend dès qu'un script SQL mute un media (`is_published`, `score`, description AI, etc.) ou manuellement pour résoudre une drift entre DB et index.

**Path params**
- `hex` : id du media en 32 hex (format `media.id` BINARY(16) → hex lowercase).

**Réponses**

| Status | Body | Sens |
|---|---|---|
| `200` | `{ "status": "reindexed", "mediaId": "<hex>" }` | document Meili mis à jour |
| `200` | `{ "status": "removed",   "mediaId": "<hex>" }` | media supprimé en DB depuis → le doc Meili stale est purgé |
| `400` | `{ "error": "Invalid media id." }` | hex mal formé |
| `403` | `{ "error": "..." }` | auth KO |

**Exemple curl**

```bash
curl -X POST \
  -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/media/d26d1600cde54bd095e09f8b68ace05f/reindex"
```

---


## `POST /admin/media/reindex-all`

Backfill complet de l'index Meili `media` par **lots keyset-paginés**. Chaque appel traite UN batch et renvoie le curseur du suivant. Le client (Talend / Postman) boucle jusqu'à `done = true`.

Pagination par clé primaire BINARY(16) ASC : pas de drift offset, robuste aux insertions/suppressions concurrentes.

**Query params**

| Param | Type | Défaut | Min | Max |
|---|---|---|---|---|
| `cursor` | hex (32 chars) | `null` (début) | — | — |
| `batchSize` | int | `200` | `1` | `1000` |

`cursor` exclu : passer l'id du dernier média traité par l'appel précédent. Vide ou absent ⇒ on part du début.

**Réponse (200)**

```json
{
  "processed":  198,
  "removed":    2,
  "failed": [
    { "mediaId": "a1b2…", "error": "Meilisearch: connection refused" }
  ],
  "lastId":     "f0e1d2c3b4a5969788798a8b8c8d8e8f",
  "nextCursor": "f0e1d2c3b4a5969788798a8b8c8d8e8f",
  "done":       false,
  "totalAll":   12_487,
  "durationMs": 3421
}
```

| Champ | Sens |
|---|---|
| `processed` | médias indexés avec succès dans ce batch |
| `removed`   | rows manquants en DB (déjà supprimés) dont le doc Meili stale a été purgé |
| `failed`    | liste des erreurs par-média — **n'interrompt pas le batch** |
| `lastId`    | dernier id parcouru dans le batch (`null` si batch vide) |
| `nextCursor`| à passer en `?cursor=` au prochain appel ; `null` quand `done=true` |
| `done`      | `true` quand le batch a renvoyé moins de rows que demandé → fin du backfill |
| `totalAll`  | `COUNT(*) media` au moment de l'appel — pour reporter une progression côté caller |
| `durationMs`| latence serveur du batch |

**Erreurs**

| Status | Body |
|---|---|
| `400` | `{ "error": "Invalid cursor." }` |
| `400` | `{ "error": "Invalid batchSize." }` |
| `403` | `{ "error": "..." }` |

**Pattern d'utilisation (Talend / curl boucle)**

```bash
cursor=""
while : ; do
  resp=$(curl -s -X POST \
    -H "Authorization: Bearer $ADMIN_API_TOKEN" \
    "http://hydrogen.dev.com/admin/media/reindex-all?batchSize=500&cursor=$cursor")
  echo "$resp" | jq '{processed, removed, done, durationMs}'

  done=$(echo "$resp" | jq -r '.done')
  cursor=$(echo "$resp" | jq -r '.nextCursor // empty')

  [ "$done" = "true" ] && break
done
```

Côté Talend : un tLoop sur l'appel HTTP, condition de sortie `done == true`, variable de contexte `cursor` mise à jour entre itérations.

---


## `POST /admin/media/backfill-geo`

Backfill **massif** des 4 colonnes administratives (`city_id`, `subregion_id`, `region_id`, `country_id`) sur les médias qui ont des coordonnées GPS mais au moins un des 4 ids manquant.

Pour chaque ligne candidate, l'endpoint :

1. Appelle la procédure stockée `geo.locate(latitude, longitude)` via [GeoLookupService](../src/Domain/Media/GeoLookupService.php).
2. **Écrase** les 4 colonnes avec ce que `locate` renvoie (peut inclure des `NULL` partiels — toujours cohérent avec la résolution la plus fraîche).
3. Depuis **les mêmes coordonnées**, résout le biome WWF via `geo_v2.get_biome` ([BiomeLookupService](../src/Domain/Media/BiomeLookupService.php)) et écrit `biome_id` **uniquement si** le point matche un polygone. Un miss ne **remet jamais** `biome_id` à `NULL` (la maintenance biome-seule reste du ressort de `POST /admin/media/backfill-biome`).
4. Bump `updated_at`.
5. Réindexe le média **une seule fois** (si quelque chose a changé) via [MediaIndexService::reindex()](../src/Domain/Media/MediaIndexService.php) pour que les 4 blocs hiérarchiques (`city`/`subregion`/`region`/`country`) et la facette biome apparaissent immédiatement sur les listings publics.

> Un point terrestre matche presque toujours un biome mais peut manquer la cascade administrative (ou l'inverse) : `updated` (ids admin) et `biomeUpdated` sont donc comptés **indépendamment**. Une ligne peut être `skipped` (aucun id admin matché) tout en ayant son `biome_id` renseigné.

Pagination keyset sur la PK BINARY(16), même pattern que `reindex-all`. Boucle Talend / Postman jusqu'à `done = true`.

**Sélection des candidats (SQL)**

```sql
WHERE latitude IS NOT NULL AND longitude IS NOT NULL
  AND (country_id IS NULL OR region_id IS NULL
       OR subregion_id IS NULL OR city_id IS NULL)
```

**Query params**

| Param | Type | Défaut | Min | Max |
|---|---|---|---|---|
| `cursor` | hex (32 chars) | `null` (début) | — | — |
| `batchSize` | int | `200` | `1` | `1000` |

**Réponse (200)**

```json
{
  "processed":       200,
  "updated":         171,
  "skipped":         27,
  "biomeUpdated":    189,
  "failed": [
    { "mediaId": "a1b2…", "error": "SQLSTATE[…]" }
  ],
  "lastId":          "f0e1d2c3b4a5969788798a8b8c8d8e8f",
  "nextCursor":      "f0e1d2c3b4a5969788798a8b8c8d8e8f",
  "done":            false,
  "totalCandidates": 4_812,
  "durationMs":      6125
}
```

| Champ | Sens |
|---|---|
| `processed` | nombre de rows parcourus dans ce batch |
| `updated`   | rows dont les 4 ids administratifs ont été ré-écrits avec succès |
| `skipped`   | `geo.locate(lat,lng)` n'a rien matché (point hors polygones connus) — ids admin laissés intacts, sera retenté au prochain run si `geo_v2` s'enrichit |
| `biomeUpdated` | rows dont `biome_id` a été (ré)écrit (`geo_v2.get_biome` a matché) — indépendant de `updated`/`skipped` |
| `failed`    | erreurs par-média (UPDATE / reindex) — **n'interrompent pas le batch** |
| `lastId`    | dernier id parcouru dans le batch (`null` si batch vide) |
| `nextCursor`| à passer en `?cursor=` au prochain appel ; `null` quand `done=true` |
| `done`      | `true` quand le batch a renvoyé moins de rows que demandé → fin du backfill |
| `totalCandidates` | snapshot `COUNT(*)` des rows encore éligibles au moment de l'appel — décroît au fil de la progression |
| `durationMs`| latence serveur du batch (inclut les appels Meili) |

**Erreurs**

| Status | Body |
|---|---|
| `400` | `{ "error": "Invalid cursor." }` |
| `400` | `{ "error": "Invalid batchSize." }` |
| `403` | `{ "error": "..." }` |

**Pattern d'utilisation (curl boucle)**

```bash
cursor=""
while : ; do
  resp=$(curl -s -X POST \
    -H "Authorization: Bearer $ADMIN_API_TOKEN" \
    "http://hydrogen.dev.com/admin/media/backfill-geo?batchSize=500&cursor=$cursor")
  echo "$resp" | jq '{processed, updated, skipped, biomeUpdated, totalCandidates, done, durationMs}'

  done=$(echo "$resp" | jq -r '.done')
  cursor=$(echo "$resp" | jq -r '.nextCursor // empty')

  [ "$done" = "true" ] && break
done
```

> Remarque : `skipped` reste positif tant que `geo_v2` n'a pas de polygones pour la zone (ex. Tokyo, NYC). Ces médias seront automatiquement re-sélectionnés au prochain appel de l'endpoint.

---


## `POST /admin/media/backfill-biome`

Backfill **massif** de la colonne `biome_id` (biome WWF `1..15`) sur la longue traîne des médias qui ont des coordonnées GPS (`latitude` + `longitude`) mais **aucun biome** encore (`biome_id` NULL) — lignes légales antérieures au pipeline biome, ou re-géolocalisées à la main.

Endpoint **dédié** (pas replié dans `backfill-geo`) pour que le projet **Hyperion** puisse piloter l'enrichissement biome indépendamment. Pour chaque candidat, l'endpoint :

1. Appelle la procédure stockée `geo_v2.get_biome(latitude, longitude)` via [BiomeLookupService](../src/Domain/Media/BiomeLookupService.php).
2. Écrit le biome WWF retourné (`1..15`) via `updateBiome()` + bump `updated_at`.
3. Réindexe le média via [MediaIndexService::reindex()](../src/Domain/Media/MediaIndexService.php) pour que le facet `biome_id` apparaisse immédiatement sur les listings publics.

Pagination keyset sur la PK BINARY(16), même pattern que `backfill-geo`. Boucle Talend / Postman jusqu'à `done = true`.

**Sélection des candidats (SQL)**

```sql
WHERE latitude IS NOT NULL AND longitude IS NOT NULL
  AND biome_id IS NULL
```

**Query params**

| Param | Type | Défaut | Min | Max |
|---|---|---|---|---|
| `cursor` | hex (32 chars) | `null` (début) | — | — |
| `batchSize` | int | `200` | `1` | `1000` |

**Réponse (200)**

```json
{
  "processed":       200,
  "updated":         188,
  "skipped":         12,
  "failed": [
    { "mediaId": "a1b2…", "error": "SQLSTATE[…]" }
  ],
  "lastId":          "f0e1d2c3b4a5969788798a8b8c8d8e8f",
  "nextCursor":      "f0e1d2c3b4a5969788798a8b8c8d8e8f",
  "done":            false,
  "totalCandidates": 3_204,
  "durationMs":      5980
}
```

| Champ | Sens |
|---|---|
| `processed` | nombre de rows parcourus dans ce batch |
| `updated`   | rows dont le biome a été écrit avec succès |
| `skipped`   | `get_biome(lat,lng)` n'a matché aucun polygone (océan / zone non cartographiée) — row laissée `NULL`. **Un re-run ne l'aidera pas** (mêmes coords) : un `skipped` positif est attendu et stable pour les médias côtiers / marins |
| `failed`    | erreurs par-média (UPDATE / reindex) — **n'interrompent pas le batch** |
| `lastId`    | dernier id parcouru dans le batch (`null` si batch vide) |
| `nextCursor`| à passer en `?cursor=` au prochain appel ; `null` quand `done=true` |
| `done`      | `true` quand le batch a renvoyé moins de rows que demandé → fin du backfill |
| `totalCandidates` | snapshot `COUNT(*)` des rows encore éligibles au moment de l'appel |
| `durationMs`| latence serveur du batch (inclut les appels Meili) |

**Erreurs**

| Status | Body |
|---|---|
| `400` | `{ "error": "Invalid cursor." }` |
| `400` | `{ "error": "Invalid batchSize." }` |
| `403` | `{ "error": "..." }` |

**Pattern d'utilisation (curl boucle)**

```bash
cursor=""
while : ; do
  resp=$(curl -s -X POST \
    -H "Authorization: Bearer $ADMIN_API_TOKEN" \
    "http://hydrogen.dev.com/admin/media/backfill-biome?batchSize=500&cursor=$cursor")
  echo "$resp" | jq '{processed, updated, skipped, totalCandidates, done, durationMs}'

  done=$(echo "$resp" | jq -r '.done')
  cursor=$(echo "$resp" | jq -r '.nextCursor // empty')

  [ "$done" = "true" ] && break
done
```

> ⚙️ **Ops** : le facet `biome_id` doit être déclaré `filterable` dans l'index Meili — lancer `bin/media-meili-apply-settings.php` une fois avant d'exploiter `/media/nearby?biome=…`.

---


## `GET /admin/media/without-geo`

Liste **paginée keyset** des médias qui n'ont **aucune coordonnée GPS** (`latitude` OU `longitude` NULL). Ce sont les lignes que `POST /admin/media/backfill-geo` ne pourra jamais réparer (il lui faut un couple GPS pour appeler `locate`). L'opérateur les identifie ici, puis les géolocalise à la main via `PUT /admin/media/{hex}/geo` (le pendant naturel de cet endpoint).

À distinguer de `backfill-geo`, qui cible les lignes qui **ont** des coordonnées mais des ids administratifs manquants.

Chaque item porte une projection légère (ni EXIF ni hash) : l'id, l'id du propriétaire, le `name` d'origine, les drapeaux `status`/`isPublished`, et — résolus depuis l'id via [MediaUrlResolver](../src/Domain/Media/MediaUrlResolver.php) — l'`url` WebP pleine taille (host static) et son compagnon `blurhash`/`blurhashUrl`, pour qu'un opérateur puisse visualiser la photo avant de la situer.

**Sélection (SQL)**

```sql
WHERE latitude IS NULL OR longitude IS NULL
```

**Query params**

| Param | Type | Défaut | Min | Max |
|---|---|---|---|---|
| `cursor` | hex (32 chars) | `null` (début) | — | — |
| `batchSize` | int | `200` | `1` | `1000` |

**Réponse (200)**

```json
{
  "items": [
    {
      "id":          "d26d1600cde54bd095e09f8b68ace05f",
      "userId":      "9f8b68ace05fd26d1600cde54bd095e0",
      "name":        "IMG_4821.jpg",
      "url":         "https://hexatrip-static.dev.com/media/d2/6d/16/d26d1600cde54bd095e09f8b68ace05f.webp",
      "blurhash":    "L6Pj0^jE.AyE_3t7t7R**0o#DgR4",
      "blurhashUrl": "https://hexatrip-static.dev.com/media/d2/6d/16/d26d1600cde54bd095e09f8b68ace05f-blurhash.webp",
      "isPublished": true,
      "status":      3,
      "createdAt":   "2026-05-14T09:31:07+00:00"
    }
  ],
  "lastId":     "d26d1600cde54bd095e09f8b68ace05f",
  "nextCursor": "d26d1600cde54bd095e09f8b68ace05f",
  "done":       false,
  "total":      312,
  "durationMs": 41
}
```

| Champ | Sens |
|---|---|
| `items`     | batch de médias sans GPS, projection légère (cf. ci-dessus) |
| `lastId`    | dernier id parcouru dans le batch (`null` si batch vide) |
| `nextCursor`| à passer en `?cursor=` au prochain appel ; `null` quand `done=true` |
| `done`      | `true` quand le batch a renvoyé moins de rows que demandé → fin |
| `total`     | snapshot `COUNT(*)` des médias sans coordonnées au moment de l'appel |
| `durationMs`| latence serveur du batch |

**Erreurs**

| Status | Body |
|---|---|
| `400` | `{ "error": "Invalid cursor." }` |
| `400` | `{ "error": "Invalid batchSize." }` |
| `403` | `{ "error": "..." }` |

---


## `PUT /admin/media/{hex}/geo`

Géolocalisation **manuelle** d'un média — le pendant de `GET /admin/media/without-geo`. L'opérateur fournit la position GPS d'une photo qui n'en a jamais eu (pas d'EXIF, ou GPS retiré à l'upload), ce que `backfill-geo` ne peut pas faire.

Endpoint **dédié** (pas `PUT /admin/media/{hex}`, qui reste éditorial), car poser des coordonnées déclenche la cascade de géocodage puis une réindexation — même logique que les transitions `published` / `flag`.

Side-effects, dans l'ordre :

1. `UPDATE media SET latitude = ?, longitude = ?, updated_at = NOW()`.
2. `CALL locate(lat, lng)` via [GeoLookupService](../src/Domain/Media/GeoLookupService.php) pour dériver les 4 ids administratifs, puis les écrit via `updateGeoIds()`. Un **miss** (point hors polygones connus) met les 4 ids à `NULL` — la ligne garde ses coordonnées et pourra être re-jouée quand `geo_v2` couvrira la zone.
2b. `CALL get_biome(lat, lng)` via [BiomeLookupService](../src/Domain/Media/BiomeLookupService.php) pour dériver le biome WWF (`1..15`) depuis les mêmes coordonnées, persisté via `updateBiome()`. `NULL` quand le point ne matche aucun polygone de biome (océan / zone non cartographiée). Indépendant de la cascade administrative (procédure `geo_v2` distincte).
3. `MediaIndexService::reindex()` (best-effort) — pousse le nouveau point `_geo` + les 4 blocs hiérarchiques + le facet `biome_id` vers Meili pour qu'ils apparaissent immédiatement sur `/media/nearby` et `/media/in-bounds`.

**Body**

```json
{ "latitude": 48.8566, "longitude": 2.3522 }
```

| Champ | Type | Contrainte |
|---|---|---|
| `latitude`  | number | `-90` .. `90` |
| `longitude` | number | `-180` .. `180` |

**Réponse (200)**

```json
{
  "status":      "ok",
  "mediaId":     "d26d1600cde54bd095e09f8b68ace05f",
  "latitude":    48.8566,
  "longitude":   2.3522,
  "geoResolved": true,
  "cityId":      "67104949-52b7-11f1-96d5-00155dda08de",
  "subregionId": "FR-75C",
  "regionId":    "FR-IDF",
  "countryId":   "FR",
  "biomeId":     4
}
```

| Champ | Sens |
|---|---|
| `geoResolved` | `false` si `locate()` n'a matché aucun polygone (les 4 ids sont alors `null`) |
| `cityId` | UUID dashé de la ville (`geo.city` est keyé UUID) ou `null` |
| `subregionId` / `regionId` | ISO 3166-2 ou `null` |
| `countryId` | ISO 3166-1 alpha-2 ou `null` |
| `biomeId` | biome WWF `1..15` (indépendant de `geoResolved`) ou `null` si le point ne matche aucun polygone de biome |

**Erreurs**

| Status | Body |
|---|---|
| `400` | `{ "error": "Body must be JSON object with 'latitude' and 'longitude' numbers." }` |
| `422` | `{ "error": "latitude must be between -90 and 90, longitude between -180 and 180." }` |
| `404` | `{ "error": "Media not found." }` |
| `403` | `{ "error": "..." }` |

**Exemple**

```bash
curl -s -X PUT \
  -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"latitude":48.8566,"longitude":2.3522}' \
  http://hydrogen.dev.com/admin/media/d26d1600cde54bd095e09f8b68ace05f/geo | jq
```

---


## `PUT /admin/media/{hex}/published`

Mute le drapeau de publication d'un media et propage les side-effects techniques. **Hydrogen ne juge pas** de la pertinence du flip — Talend a déjà tranché. C'est l'endpoint que le pipeline IA appelle après avoir généré la description.

**Body (JSON)**

```json
{ "isPublished": true }
```

`isPublished` est obligatoire, doit être un booléen strict (`true` ou `false`, pas `"true"` ni `1`).

**Comportement par transition**

| Transition | UPDATE `media` | DELETE `work.media_to_describe` | Notif followers | Reindex Meili |
|---|---|---|---|---|
| `none` (déjà à l'état demandé) | non | non | non (jamais de fake "X a publié" sur un republish toggle) | non |
| `publish` (0 → 1) | oui | oui | oui (`media.published` à tous les followers du créateur) | oui |
| `unpublish` (1 → 0) | oui | non (la description reste, pas un retour en arrière du pipeline) | non | oui |

La notif `media.published` est dispatchée via le système existant : elle honore la préférence `inApp` de chaque follower (un follower qui a opt-out reçoit `null` et n'est pas comptabilisé dans `notificationsSent`). La fenêtre de dedup (`NOTIFICATION_DEDUP_WINDOW_MINUTES`, défaut 5min) collapse les republish toggles rapides sur le même media en une seule ligne de feed.

**Réponses**

| Status | Body | Sens |
|---|---|---|
| `200` | `{ "status": "ok", "mediaId": "<hex>", "isPublished": true, "transition": "publish", "notificationsSent": 142, "notificationsFailed": 0 }` | flip 0→1 OK, 142 followers notifiés |
| `200` | `{ "status": "ok", "mediaId": "<hex>", "isPublished": true, "transition": "none", "notificationsSent": 0, "notificationsFailed": 0 }` | déjà publié, no-op idempotent |
| `200` | `{ "status": "ok", "mediaId": "<hex>", "isPublished": false, "transition": "unpublish", "notificationsSent": 0, "notificationsFailed": 0 }` | dépublié (modération) |
| `400` | `{ "error": "Body must be JSON object with 'isPublished' boolean." }` | body mal formé |
| `404` | `{ "error": "Media not found." }` | media absent en DB |
| `403` | `{ "error": "..." }` | auth KO |

**Exemple curl**

```bash
# Publier (cas standard pipeline IA)
curl -X PUT \
  -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"isPublished": true}' \
  http://hydrogen.dev.com/admin/media/d26d1600cde54bd095e09f8b68ace05f/published

# Dépublier (modération)
curl -X PUT \
  -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"isPublished": false}' \
  http://hydrogen.dev.com/admin/media/d26d1600cde54bd095e09f8b68ace05f/published
```

**Notes**

- Le compteur `notificationsFailed` regroupe les échecs d'insert per-follower (DB lock, blip…). Chaque échec individuel est silencieux côté logs — on préfère que le fan-out aille jusqu'au bout que d'avorter à la première transient. Si ce nombre n'est pas zéro, Talend peut journaliser et relancer la commande (idempotente, transition=none donc pas de double notif).
- Les transitions `none` ne touchent ni DB ni Meili ni followers — aucun coût.
- Le dispatch des notifs respecte la dedup-window (cf. `NOTIFICATION_DEDUP_WINDOW_MINUTES`) : si vous re-publish/unpublish/re-publish le même media dans la fenêtre, la ligne notification existante est bumpée plutôt que dupliquée.

---


## `GET /admin/media/{hex}/base64`

Renvoie un thumbnail d'un media existant, redimensionné à la volée par Glide et encodé en base64 (data URI). À utiliser pour embarquer une miniature directement dans une payload externe (prompt LLM, e-mail, rapport, etc.) sans avoir à fetcher le binaire puis l'encoder soi-même côté caller.

**Comportement**

- Source : WebP canonique `MEDIA_STORAGE_PATH/AA/BB/CC/<hex>.webp`.
- Resize : `w = h = MEDIA_ADMIN_BASE64_MAX_SIZE` (default `800`), `fit = max` → bestfit dans une boîte carrée, proportions **préservées**, image jamais upscalée. **Aucun des deux côtés** ne dépasse la borne : un media portrait est donc plafonné en hauteur aussi, pas seulement en largeur (un media déjà ≤ max retourne ses dimensions d'origine).
- Format de sortie : **WebP par défaut**, **JPEG** via `?format=jpg` (alias `jpeg`). Le JPEG est indispensable aux consommateurs qui ne décodent pas le WebP — notamment le **serveur de modèle vision**, qui rejette un data URI WebP (`400 'url' field must be a base64 encoded image`).
- Cache : partagé avec `/media/{hex}.{ext}` public via Glide → les appels suivants avec les mêmes params (`MEDIA_ADMIN_BASE64_MAX_SIZE` + format) sont servis depuis disque (sub-100 ms typique).

**Path params**
- `hex` : id du media en 32 hex lowercase.

**Query params**
- `format` : `webp` (défaut) · `jpg` · `jpeg`. Toute autre valeur → `400`.

**Réponse (200)**

```json
{
  "status":  "ok",
  "mediaId": "01a3471992e44c60a8f08321f713635a",
  "maxSize": 800,
  "format":  "webp",
  "image":   "data:image/webp;base64,UklGRmgoAQBXRUJQVlA4WAo..."
}
```

| Champ | Sens |
|---|---|
| `mediaId` | echo du hex demandé |
| `maxSize` | valeur effective de l'env `MEDIA_ADMIN_BASE64_MAX_SIZE` au moment de l'appel — borne max de chaque côté du thumbnail, pour que le caller sache à quoi correspond le data URI sans introspect |
| `format`  | format effectivement encodé (`webp` ou `jpeg`) — echo du `?format=` normalisé |
| `image`   | data URI complet (`data:<mime>;base64,<…>`) directement utilisable dans `<img src=…>` ou un attribut JSON tiers |

**Erreurs**

| Status | Body | Sens |
|---|---|---|
| `404` | `{ "error": "Media not found." }` | row absente en DB |
| `404` | `{ "error": "Media file not found on disk." }` | row présente mais WebP source manquant (incohérence DB/disque) |
| `400` | `{ "error": "Query 'format' must be 'webp', 'jpg' or 'jpeg'." }` | valeur `?format=` non reconnue |
| `500` | `{ "error": "Image processing failed: …" }` | exception Glide / Flysystem non récupérable |
| `403` | `{ "error": "..." }` | auth KO |

**Exemple curl**

```bash
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/media/01a3471992e44c60a8f08321f713635a/base64?format=jpg" \
  | jq -r .image \
  | head -c 80
# data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEA...
```

**Notes**

- Pas de query param accepté — la largeur max est fixée côté serveur via env pour borner la taille du payload (les data URI dépassant quelques centaines de KB sont contre-productifs).
- Pour changer la largeur en prod sans redéployer : modifier l'env et relancer le pool PHP-FPM. Le cache Glide existant n'est pas purgé automatiquement — les vieilles dérivées resteront jusqu'à wipe manuel de `MEDIA_CACHE_PATH`.

---


## `GET /admin/media/{hex}/phash-diff/{other}`

Inspection visuelle de **quasi-doublon** entre DEUX médias, à partir de leurs empreintes perceptuelles (pHash DCT 64 bits). Renvoie chaque empreinte en **grille SVG 8×8**, la grille **diff** (cases où les bits diffèrent = distance de Hamming), et le verdict doublon vs `MEDIA_SIMILARITY_HAMMING_THRESHOLD` (le seuil que le pipeline d'upload utilise pour rejeter les quasi-doublons). Les deux ids sont des hex 32 (ids média) ; JSON plat, SVG inline prêts à injecter (`innerHTML`).

**Réponse (200)**

```json
{
  "a":    { "mediaId": "…", "hash": "c3f0a55a3c0f1e2d", "svg": "<svg …>" },
  "b":    { "mediaId": "…", "hash": "c3f0a55a3c0f1e6f", "svg": "<svg …>" },
  "diff": { "svg": "<svg …>", "distance": 2, "bits": 64 },
  "threshold": 5,
  "isDuplicate": true
}
```

- `404` : un id média est malformé, inconnu, ou sans empreinte stockée.

---

## `POST /admin/media/describe`

Ingestion de l'enrichissement produit par le pipeline IA (description) pour **un** média. Le pipeline émet un document JSON autonome par média, donc l'id voyage **dans le corps**, pas dans l'URL.

Sémantique de **remplacement intégral** : le pipeline est propriétaire de l'enrichissement complet, on écrase l'existant (jamais de merge partiel). Les quatre écritures partagent la connexion `hxa` et tournent dans **une seule transaction** — un enrichissement partiel ne peut donc jamais atterrir. Le réindex Meili est best-effort, **après** le commit (un incident d'index ne doit pas annuler une écriture MySQL committée).

**Body (JSON)**

```json
{
  "id":          "b086801b-46b3-4cdc-b3b9-6ed26c132d5d",
  "flag":        8,
  "focus":       ["city", "experience", "nightlife", "tourism"],
  "title":            "Vue nocturne sur la Tour Eiffel depuis un ponton fluvial",
  "meta_title":       "Tour Eiffel nocturne depuis un ponton fluvial",
  "meta_description": "Découvrez la Tour Eiffel illuminée vue depuis la Seine…",
  "description":      "Cette image captée…",
  "objects": [
    { "name": "Tour Eiffel",  "probability": 1.0 },
    { "name": "Ciel nocturne", "probability": 0.9 }
  ]
}
```

| Champ | Sens / destination |
|---|---|
| `id` | UUID **dashé** du média (pas le hex 32). 404 si la row n'existe pas. |
| `flag` | Masque binaire de modération → `media.flag`. `0` = valide, `1` = illégal, `2` = violent, `4` = sexuel, `8` = selfie, `16` = screenshot, `32` = généré par IA. Indexé dans Meili (filterable), mais exposé dans l'API **au seul auteur** du média (gating dans le serializer). |
| *(dérivé)* | `media.is_rejected` = `(flag & ~8) > 0` : rejeté dès qu'un motif **autre** que selfie est levé. Un selfie seul (`flag = 8`) n'est **pas** rejeté. |
| *(dérivé)* | `media.is_published` = `!is_rejected` : le verdict pilote la publication. Un média non rejeté (selfie inclus) est **publié** (`1`) ; un média rejeté est **dépublié** (`0`). L'étape describe fait donc aussi office de barrière de publication. |
| `title` | → `media_description.title` (nullable). |
| `meta_title` | → `media_description.meta_title` (nullable). |
| `meta_description` | → `media_description.meta_description` (nullable). |
| `description` | → `media_description.description` (chaîne ; `""` accepté). |
| `focus` | Liste de `focus.name`. Résolus en ids puis écrits dans `media_focus` (DELETE + ré-INSERT). Les noms inconnus sont **silencieusement ignorés** et remontés dans `focusUnknown`. |
| `objects` | Liste `{name, probability}` → table `media_object` (DELETE + ré-INSERT). |

Champs optionnels : `flag` défaut `0`, `focus`/`objects` défaut `[]`, `title`/`meta_title`/`meta_description` défaut `null`, `description` défaut `""`.

**Réponse (200)**

```json
{
  "status":        "ok",
  "mediaId":       "b086801b46b34cdcb3b96ed26c132d5d",
  "flag":          8,
  "isRejected":    false,
  "isPublished":   true,
  "status":        "published",
  "focusMatched":  ["city", "experience"],
  "focusUnknown":  ["nightlife", "tourism"],
  "objectsStored": 2
}
```

| Champ | Sens |
|---|---|
| `mediaId` | hex 32 du média enrichi |
| `flag` | echo du masque appliqué |
| `isRejected` | décision dérivée effectivement persistée |
| `isPublished` | état de publication appliqué (`!isRejected`) |
| `status` | étape terminale du cycle de vie posée : `published` (non rejeté) ou `rejected` |
| `focusMatched` | noms de focus résolus en ids (liés) |
| `focusUnknown` | noms de focus absents de la table `focus` (ignorés) |
| `objectsStored` | nombre d'objets écrits dans `media_object` |

**Erreurs**

| Status | Body | Sens |
|---|---|---|
| `400` | `{ "error": "Body must be a JSON object." }` | corps vide ou JSON invalide |
| `400` | `{ "error": "Field 'id' is required (UUID string)." }` | `id` absent / vide |
| `400` | `{ "error": "Field 'id' is not a valid UUID." }` | `id` mal formé |
| `400` | `{ "error": "Field 'flag' must be a non-negative integer." }` | `flag` invalide |
| `400` | `{ "error": "..." }` | `focus` / `objects` / `description` / `title` mal typés |
| `404` | `{ "error": "Media not found." }` | aucun média pour cet `id` |
| `500` | `{ "error": "Failed to persist enrichment: …" }` | transaction rollback (l'enrichissement n'a rien écrit) |
| `403` | `{ "error": "..." }` | auth KO |

**Exemple curl**

```bash
curl -s -X POST -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"id":"b086801b-46b3-4cdc-b3b9-6ed26c132d5d","flag":8,"focus":["city"],"title":"…","meta_title":"…","description":"…","objects":[{"name":"Tour Eiffel","probability":1.0}]}' \
  "http://hydrogen.dev.com/admin/media/describe"
```

**Notes**

- `flag` est indexé dans Meili (`filterableAttributes`) au même titre que `is_rejected` → après le premier déploiement, relancer `bin/media-meili-apply-settings.php` pour que les nouveaux attributs filtrables soient acceptés par l'index.

---


## `POST /admin/media/{hex}/describe-ai`

Enrichissement **à la demande** par le serveur IA vision : au lieu d'attendre le worker hors-bande, un opérateur déclenche la description d'**un** média et récupère (ou applique) ce que le modèle propose. L'id voyage **dans l'URL** (hex 32).

Le flux : l'action lit le prompt `media.identification` dans la base `ai` (table `prompts`), génère une vignette base64 **JPEG** du média via Glide (bornée par `MEDIA_ADMIN_BASE64_MAX_SIZE` ; JPEG car le serveur modèle refuse le WebP), envoie prompt + image à `POST {AI_SERVER_BASE_URL}/v1/chat/completions` (endpoint OpenAI-compatible, **un unique message `user` multimodal** — texte + `image_url` — pour satisfaire les gabarits de chat stricts type Mistral), lit la réponse dans `choices[0].message.content`, la parse et la mappe sur le **même** jeu d'écritures que `POST /admin/media/describe` — plus les deux champs que le modèle produit en supplément : `person_count` (→ `media.person_count`) et `poi` (→ table `media_poi`).

> **Alternative hors-bande** : pour éviter l'appel modèle **synchrone** (lent, sujet au timeout du frontal), un worker externe peut appeler le modèle lui-même puis persister via `POST /admin/media/describe` + `POST /admin/media/{hex}/enrichment` (ci-dessous). Voir le worker `bin/describe_worker.py` dans [Scripts bin/](/ops/scripts-bin/).

Le modèle est prompté pour ne renvoyer **que** du JSON ; en pratique il l'entoure d'une clôture Markdown ```` ```json ```` (retirée) et **oublie parfois une virgule** entre deux membres — une passe de réparation conservatrice rattrape ce défaut avant décodage.

**Query params**

| Param | Défaut | Rôle |
|---|---|---|
| `mode` | `preview` | `preview` = renvoie la proposition **sans** rien écrire (dry-run) ; `save` = applique en base (transaction `hxa` unique) + réindex Meili best-effort. |
| `model` | `AI_DESCRIBE_MODEL` (`mistralai/ministral-3-3b`) | Override du modèle pour **un** appel. |

**Mapping IA → domaine**

| Champ IA | Destination |
|---|---|
| `title` / `description` / `meta_title` / `meta_description` | `media_description` (upsert). |
| `themes` (liste de slugs EN) | résolus en `focus.name` → `media_focus` (DELETE + ré-INSERT) ; inconnus remontés dans `focusUnknown`. |
| `objects` (`{name, probability}`) | `media_object` (DELETE + ré-INSERT). |
| `poi` (liste de chaînes **ou** `{name, probability}`) | `media_poi` (DELETE + ré-INSERT) ; une chaîne nue prend `probability = 1.0`. |
| `person_count` | `media.person_count` (entier ≥ 0, nullable). |
| `is_illegal`/`is_violent`/`is_sexual`/`is_selfie`/`is_screenshot`/`is_ai` (`{status, probability}`) | pliés en masque `media.flag` (bits 1/2/4/8/16/32 sur `status = true`). |
| *(dérivé)* | `is_rejected = (flag & ~8) > 0`, `is_published = !is_rejected`, `status` = `rejected`/`published` — **logique de publication identique** à `POST /admin/media/describe`. |

**Réponse `preview` (200)**

```json
{
  "status":        "preview",
  "mediaId":       "b086801b46b34cdcb3b96ed26c132d5d",
  "model":         "mistralai/ministral-3-3b",
  "mode":          "preview",
  "isRejected":    false,
  "isPublished":   true,
  "willPublishAs": "published",
  "proposal":      { "title": "…", "description": "…", "themes": ["travel","city"], "objects": [ … ], "poi": [ { "name": "Eiffel Tower", "probability": 1 } ], "personCount": 1, "flag": 8, "flags": { "selfie": { "status": true, "probability": 0.75 }, … } },
  "focusMatched":  ["city", "experience"],
  "focusUnknown":  ["tourism"],
  "stats":         { "input_tokens": 2727, "total_output_tokens": 505, "tokens_per_second": 154.2, "time_to_first_token_seconds": 0.87 }
}
```

**Réponse `save` (200)** — mêmes champs proposition, plus l'écho de ce qui a été persisté :

```json
{
  "status":        "ok",
  "mediaId":       "b086801b46b34cdcb3b96ed26c132d5d",
  "model":         "mistralai/ministral-3-3b",
  "mode":          "save",
  "flag":          16,
  "isRejected":    true,
  "isPublished":   false,
  "mediaStatus":   "rejected",
  "focusMatched":  ["experience", "city", "waterways"],
  "focusUnknown":  ["tourism"],
  "objectsStored": 5,
  "poiStored":     1,
  "personCount":   1,
  "proposal":      { … },
  "stats":         { … }
}
```

**Erreurs**

| Status | Body | Sens |
|---|---|---|
| `400` | `{ "error": "Query 'mode' must be 'preview' or 'save'." }` | `mode` invalide |
| `404` | `{ "error": "Media not found." }` | aucun média pour ce hex |
| `404` | `{ "error": "Media file not found on disk." }` | row présente mais WebP absent |
| `502` | `{ "error": "AI describe failed: …" }` | transport IA KO **ou** JSON du modèle irréparable |
| `500` | `{ "error": "Prompt 'media.identification' not found in the ai database." }` | prompt manquant |
| `500` | `{ "error": "Failed to persist enrichment: …" }` | transaction rollback (mode `save`) |
| `403` | `{ "error": "..." }` | auth KO |

**Exemple curl**

```bash
# Prévisualisation (aucune écriture)
curl -s -X POST -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/media/b086801b46b34cdcb3b96ed26c132d5d/describe-ai?mode=preview"

# Application en base + réindex Meili, avec un autre modèle
curl -s -X POST -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/media/b086801b46b34cdcb3b96ed26c132d5d/describe-ai?mode=save&model=qwen/qwen3.5-9b"
```

**Notes**

- L'inférence vision est **lente** (plusieurs secondes) : le timeout HTTP côté serveur est `AI_DESCRIBE_TIMEOUT_SECONDS` (def 120), pense à un timeout client au moins aussi large.
- `AI_SERVER_BASE_URL` change entre dev (`http://localhost:1234`) et prod — c'est le seul réglage à basculer pour repointer le serveur IA.

---


## `POST /admin/media/{hex}/enrichment`

Persiste les **deux** champs enrichis que `POST /admin/media/describe` ne gère **pas** : `poi` (points d'intérêt reconnus) et `person_count`. L'id voyage **dans l'URL** (hex 32).

Raison d'être : permettre à un **worker hors-bande** (le script `bin/describe_worker.py`) d'appeler le modèle lui-même, de POSTer l'enrichissement principal sur `/admin/media/describe`, puis de déposer poi / person_count ici — atteignant la **parité** avec `POST /admin/media/{hex}/describe-ai` **sans** l'appel modèle inline (lent) de ce dernier.

**Corps (JSON, les deux champs optionnels — au moins un requis)**

```json
{
  "poi":          [ { "name": "Eiffel Tower", "probability": 0.9 }, "Louvre" ],
  "person_count": 3
}
```

| Champ | Destination |
|---|---|
| `poi` (liste de chaînes **ou** `{name, probability}`) | `media_poi` (**remplacement en gros** : DELETE + ré-INSERT ; liste vide = purge). Une chaîne nue prend `probability = 1.0`. |
| `person_count` | `media.person_count` (entier ≥ 0, ou `null` pour effacer). |

Les deux écritures partagent le PDO `hxa` dans **une** transaction ; le réindex Meili best-effort suit le commit.

**Réponse (200)**

```json
{ "status": "ok", "mediaId": "<hex>", "poiStored": 2, "personCount": 3 }
```

`poiStored` / `personCount` valent `null` si le champ correspondant n'était pas dans le corps.

**Erreurs** : `400` (corps malformé / types invalides / aucun des deux champs), `404` (`Media not found.`), `500` (échec de persistance).

---


## Cycle de vie du traitement (`media.status`)

La colonne `media.status` matérialise l'avancement du traitement d'un média — l'état que le **propriétaire** sonde (polling) pour savoir « où en est mon upload ? ». Distinct de `is_published` (visibilité, pilotable à part via `PUT /admin/media/{hex}/published`) : les deux concordent sur les états terminaux mais `processing`/`failed` n'ont pas d'équivalent côté `is_published`.

| `status` | int | Posé par | Sens |
|---|---|---|---|
| `pending`    | `0` | upload | fichier stocké + mis en file `work.media_to_describe`, en attente du worker IA |
| `processing` | `1` | `POST /admin/media/{hex}/claim` | le worker a pris le média et l'analyse |
| `published`  | `2` | `POST /admin/media/describe` (verdict propre) | terminal succès, mis en ligne |
| `rejected`   | `3` | `POST /admin/media/describe` (flag rejetant) | terminal refus de modération |
| `failed`     | `4` | `POST /admin/media/{hex}/fail` | le worker a abandonné (erreur/timeout), **retryable** |

Transitions autorisées (gardées par `MediaStatus::canTransitionTo()`, sinon `409`) :

```
pending    → processing | published | rejected | failed
processing → published | rejected | failed
failed     → processing | published | rejected      (retry via claim)
published  → rejected                                (re-modération)
rejected   → processing | published                 (re-traitement)
```

Le slug `status` est exposé sur la ressource média publique (API JSON:API) ; les libellés traduits vivent dans `media.status.*` (`resources/lang/<locale>/media.php`).

> **Ops** : après déploiement, jouer la migration `2026_06_18_140000_backfill_media_status_lifecycle.sql` (backfill des lignes existantes depuis `is_published`/`is_rejected` + index `idx_media_status`). Aucun `ALTER` de colonne — `status` existait déjà.

---


## `POST /admin/media/{hex}/claim`

Le worker IA signale qu'il **commence** la description : `pending` (ou `failed` lors d'un retry) → `processing`. Permet à l'UI du propriétaire d'afficher « analyse en cours » au lieu d'un trou silencieux jusqu'au describe. Ne dé-file PAS `media_to_describe` (c'est describe / publish qui le font). Réindex Meili best-effort.

**Path params** — `hex` : id du média en 32 hex lowercase.

**Réponse (200)**

```json
{ "status": "ok", "mediaId": "<hex>", "state": "processing", "transition": "claim" }
```

| Status | Body | Sens |
|---|---|---|
| `200` | `… "transition": "claim"` | passage `→ processing` effectué |
| `200` | `… "transition": "none"` | déjà `processing`, no-op idempotent (retry worker) |
| `404` | `{ "error": "Media not found." }` | hex inconnu / mal formé |
| `409` | `{ "error": "Cannot claim a media in state '<state>'." }` | transition interdite (ex. média déjà `published`) |
| `403` | `{ "error": "..." }` | auth KO |

```bash
curl -s -X POST -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/media/d26d1600cde54bd095e09f8b68ace05f/claim"
```

---


## `POST /admin/media/{hex}/fail`

Le worker IA **abandonne** le média (erreur d'inférence, timeout répété) : → `failed`. Distinct de `rejected` (verdict de modération) — `failed` est un échec **technique**, rien de mal sur le média. `is_published` n'est pas touché (un média `failed` n'a jamais été en ligne). Le média reste en file `media_to_describe` ; un nouveau `claim` le renvoie en `processing` pour un retry. Réindex Meili best-effort.

**Path params** — `hex` : id du média en 32 hex lowercase.

**Réponse (200)**

```json
{ "status": "ok", "mediaId": "<hex>", "state": "failed", "transition": "fail" }
```

| Status | Body | Sens |
|---|---|---|
| `200` | `… "transition": "fail"` | passage `→ failed` effectué |
| `200` | `… "transition": "none"` | déjà `failed`, no-op idempotent |
| `404` | `{ "error": "Media not found." }` | hex inconnu / mal formé |
| `409` | `{ "error": "Cannot fail a media in state '<state>'." }` | transition interdite (ex. média déjà `published`) |
| `403` | `{ "error": "..." }` | auth KO |

```bash
curl -s -X POST -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/media/d26d1600cde54bd095e09f8b68ace05f/fail"
```

---


## `POST /admin/media/{hex}/recompute-stats`

Répare la dérive de `media_stats` pour **un** média en recalculant les compteurs dérivables depuis leurs tables source :

- `likes_count` / `dislikes_count` ← `COUNT` sur `media_reaction` (`value = 'like'` / `'dislike'`),
- `comments_count` ← commentaires **racine** non supprimés (`parent_id IS NULL AND deleted_at IS NULL`).

`views_count` / `impressions_count` **ne sont pas** recalculés : ils proviennent du pipeline compteurs (deltas append-only, sans lignes source), les re-dériver écraserait du trafic réel à zéro.

En temps normal ces compteurs sont tenus par les triggers (`media_reaction`) et par `MediaCommentService` (transactionnel). Cet endpoint est l'**unique** point qui UPDATE directement les colonnes — un outil de réparation hors-bande pour réaligner après un trigger manqué, une transaction commentaire avortée, un fix SQL manuel, etc. Après réparation, le média est repoussé dans Meili (best-effort) pour que l'index reflète les compteurs réparés.

**Path params**
- `hex` : id du media en 32 hex lowercase.

**Réponse (200)**

```json
{
  "status":  "ok",
  "mediaId": "01a3471992e44c60a8f08321f713635a",
  "before":  { "likes": 5, "dislikes": 1, "views": 1280, "impressions": 9931, "comments": 3 },
  "after":   { "likes": 6, "dislikes": 1, "views": 1280, "impressions": 9931, "comments": 4 },
  "changed": true
}
```

| Champ | Sens |
|---|---|
| `before` / `after` | snapshot des 5 compteurs avant / après recalcul (`views`/`impressions` reportés à l'identique) |
| `changed` | `true` si l'un des 3 compteurs dérivables a bougé (réparation effective) |

**Erreurs**

| Status | Body | Sens |
|---|---|---|
| `400` | `{ "error": "Invalid media id." }` | hex mal formé |
| `404` | `{ "error": "Media not found." }` | aucune row pour ce média |
| `403` | `{ "error": "..." }` | auth KO |

**Exemple curl**

```bash
curl -s -X POST -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/media/01a3471992e44c60a8f08321f713635a/recompute-stats"
```

---


## `PUT /admin/media/{hex}/flag`

**Override manuel de la modération** par un humain. Le verdict est normalement posé automatiquement par le pipeline IA (`POST /admin/media/describe`) ; cet endpoint donne à un opérateur le levier pour corriger un faux positif / faux négatif. Le `flag` (bitmask) fourni **remplace** la valeur courante et tout l'état dépendant est re-dérivé **exactement** comme dans describe, dans une transaction `hxa` unique :

- `is_rejected` ← `(flag & ~8) > 0` (rejeté si flaggé pour autre chose qu'un selfie),
- `is_published` ← `!is_rejected`,
- `status` ← `rejected` si rejeté, sinon `published`.

Réindex Meili best-effort après le commit.

Bits combinables : `1` illégal, `2` violent, `4` sexuel, `8` selfie, `16` capture d'écran, `32` généré par IA. `flag = 0` ⇒ média valide (publié).

**Path params** — `hex` : id du média en 32 hex lowercase.

**Body**

| Champ | Type | Requis | Sens |
|---|---|---|---|
| `flag` | int ≥ 0 | oui | nouveau bitmask de modération (0 = valide) |

**Réponse (200)**

```json
{
  "status":      "ok",
  "mediaId":     "d26d1600cde54bd095e09f8b68ace05f",
  "flag":        4,
  "isRejected":  true,
  "isPublished": false,
  "mediaStatus": "rejected"
}
```

**Erreurs**

| Status | Body | Sens |
|---|---|---|
| `400` | `{ "error": "Body must be JSON object with 'flag' non-negative integer." }` | corps absent / `flag` manquant ou invalide |
| `404` | `{ "error": "Media not found." }` | hex inconnu / mal formé |
| `403` | `{ "error": "..." }` | auth KO |

```bash
curl -s -X PUT -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "flag": 4 }' \
  "http://hydrogen.dev.com/admin/media/d26d1600cde54bd095e09f8b68ace05f/flag"
```

---


## `DELETE /admin/media/{hex}`

**Hard-delete** d'un média par la modération, quel que soit son propriétaire (le même service que `DELETE /api/users/me/media/{mediaId}`, jusqu'ici réservé au propriétaire). Supprime le WebP publié + le compagnon blurhash, l'original archivé, toutes les lignes des tables annexes (`media_meta` / `media_exif` / `media_perceptual_hash`), la ligne principale `hxa.media`, et best-effort le document Meilisearch. Les erreurs disque / index n'interrompent pas la suppression de la ligne DB (source de vérité). **Irréversible.**

**Path params** — `hex` : id du média en 32 hex lowercase.

**Réponse (200)**

```json
{ "status": "deleted", "mediaId": "d26d1600cde54bd095e09f8b68ace05f" }
```

**Erreurs**

| Status | Body | Sens |
|---|---|---|
| `404` | `{ "error": "Media not found." }` | hex inconnu / mal formé |
| `403` | `{ "error": "..." }` | auth KO |

```bash
curl -s -X DELETE -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/media/d26d1600cde54bd095e09f8b68ace05f"
```

---


## `GET /admin/media/{hex}/comments`

Firehose d'un média : **tous** les commentaires **quelle que soit la
profondeur** (top-level ET réponses inline), **y compris les soft-deleted**,
en ordre anté-chronologique. Keyset sur `(created_at DESC, id DESC)`.

**Query** (tous optionnels)

| Param | Défaut | Sens |
|---|---|---|
| `cursorAt` | — | ISO-8601, `created_at` de la dernière ligne de la page |
| `cursorId` | — | hex 32, id de cette même ligne (tiebreaker) |
| `limit` | `50` | borné `1..100` |

Les deux moitiés du curseur vont **ensemble** ; une seule ⇒ `400`.

**Réponse (200)**

```json
{
  "items": [
    {
      "id": "0a1b2c3d4e5f60718293a4b5c6d7e8f9",
      "mediaId": "9f8e7d6c5b4a39281706f5e4d3c2b1a0",
      "userId": "1122334455667788990011223344556677",
      "parentId": null,
      "rootId": "0a1b2c3d4e5f60718293a4b5c6d7e8f9",
      "depth": 0,
      "isTopLevel": true,
      "body": "Superbe cliché !",
      "replyCount": 2,
      "createdAt": "2026-06-20T14:03:00+00:00",
      "editedAt": null,
      "deletedAt": null,
      "isDeleted": false
    }
  ],
  "nextCursor": { "at": "2026-06-20T14:03:00+00:00", "id": "0a1b2c3d4e5f60718293a4b5c6d7e8f9" }
}
```

`nextCursor` vaut `null` sur la dernière page.

```bash
# Première page
curl -s "$BASE/admin/media/9f8e7d6c5b4a39281706f5e4d3c2b1a0/comments?limit=50" -H "$AUTH"
# Page suivante
curl -s "$BASE/admin/media/9f8e.../comments?cursorAt=2026-06-20T14:03:00%2B00:00&cursorId=0a1b..." -H "$AUTH"
```

**Erreurs**

| Status | Body | Sens |
|---|---|---|
| `400` | `{ "error": "Both cursorAt and cursorId must be supplied together." }` | curseur partiel |
| `400` | `{ "error": "cursorAt is not a valid datetime." }` | `cursorAt` illisible |
| `400` | `{ "error": "cursorId is not a valid hex UUID." }` | `cursorId` malformé |
| `404` | `{ "error": "Media not found." }` | hex de média malformé |
| `403` | `{ "error": "..." }` | auth KO |

> Note : un média sans commentaire renvoie `items: []` (pas `404`). Le `404`
> ne couvre que le hex malformé — il n'y a pas de vérification d'existence du
> média (la liste vide est indiscernable d'un média inexistant, ce qui est
> acceptable côté back-office).

---


## `GET /admin/media/{hex}/reactions`

Firehose d'un média : **toutes** les réactions actives, `like` **et** `dislike`
entrelacés en ordre anté-chronologique, avec le `userId` du réacteur. Keyset
sur `(created_at DESC, user_id DESC)`. Pendant « réactions » du firehose
commentaires ci-dessus.

**Query** (tous optionnels)

| Param | Défaut | Sens |
|---|---|---|
| `value` | — | `like` \| `dislike` — restreint à un seul type |
| `cursorAt` | — | ISO-8601, `created_at` de la dernière ligne de la page |
| `cursorId` | — | hex 32, `user_id` de cette même ligne (tiebreaker) |
| `limit` | `50` | borné `1..100` |

Les deux moitiés du curseur vont **ensemble** ; une seule ⇒ `400`.

**Réponse (200)**

```json
{
  "items": [
    {
      "mediaId": "9f8e7d6c5b4a39281706f5e4d3c2b1a0",
      "userId": "1122334455667788990011223344556677",
      "value": "like",
      "createdAt": "2026-06-20T14:03:00+00:00"
    }
  ],
  "nextCursor": { "at": "2026-06-20T14:03:00+00:00", "id": "1122334455667788990011223344556677" }
}
```

`nextCursor` vaut `null` sur la dernière page.

```bash
# Toutes les réactions, première page
curl -s "$BASE/admin/media/9f8e7d6c5b4a39281706f5e4d3c2b1a0/reactions?limit=50" -H "$AUTH"
# Uniquement les dislikes
curl -s "$BASE/admin/media/9f8e.../reactions?value=dislike" -H "$AUTH"
# Page suivante
curl -s "$BASE/admin/media/9f8e.../reactions?cursorAt=2026-06-20T14:03:00%2B00:00&cursorId=1122..." -H "$AUTH"
```

**Erreurs**

| Status | Body | Sens |
|---|---|---|
| `400` | `{ "error": "value must be one of: like, dislike." }` | `value` invalide |
| `400` | `{ "error": "Both cursorAt and cursorId must be supplied together." }` | curseur partiel |
| `400` | `{ "error": "cursorAt is not a valid datetime." }` | `cursorAt` illisible |
| `400` | `{ "error": "cursorId is not a valid hex UUID." }` | `cursorId` malformé |
| `404` | `{ "error": "Media not found." }` | hex de média malformé |
| `403` | `{ "error": "..." }` | auth KO |

> Réserve FLOW : un un-like **supprime** sa ligne `media_reaction` ; une
> réaction annulée n'apparaît donc plus ici. Cette liste est l'état **courant**
> des réactions actives, pas un journal d'événements — les compteurs à vie
> (`likesCount`/`dislikesCount`) restent autoritatifs dans `media_stats`. Comme
> pour les commentaires, un média sans réaction renvoie `items: []` (pas `404`).

---
