---
title: "Photos / médias"
description: "Pipeline de gestion des photos uploadées par les utilisateurs confirmés. Les images vivent dans hxa.media (1 ligne = 1 photo), accompagnées de trois tables…"
---

Pipeline de gestion des photos uploadées par les utilisateurs **confirmés**.
Les images vivent dans `hxa.media` (1 ligne = 1 photo), accompagnées de
trois tables annexes sur la connexion back-office (`hxa_bo`) :

- `media_meta` : `mime_type`, `size`, `width`, `height`, `brand`, `model`
- `media_exif` : blob JSON des sections EXIF/GPS/IFD0
- `media_perceptual_hash` : pHash 16 hex éclaté en 4 shards CHAR(4) pour
  rendre la recherche de doublons indexable (filtre MySQL grossier, puis
  Hamming complet en PHP).

Le fichier WebP canonique et son compagnon blurhash 16 px sont écrits
sous `MEDIA_STORAGE_PATH` ; l'original (JPEG/PNG/HEIC/…) est archivé
intact sous `MEDIA_ORIGINALS_PATH`. Le chemin est ventilé sur les 3
premières paires d'octets du hex de l'UUID :

```
<root>/AA/BB/CC/<32-hex>.webp
<root>/AA/BB/CC/<32-hex>-blurhash.webp
<root-originals>/AA/BB/CC/<32-hex>.<ext-source>
```

Un job AI externe (Talend) reprend les uploads, tague la photo et flippe
`is_published = 1` quand elle est validée. Tant qu'`is_published = 0`,
la photo n'est visible **que pour son propriétaire** (`GET /api/users/me/media`)
et reste exclue des listings publics.

---

## `POST /api/users/me/media`

Téléverse **1 à N photos** (max `MEDIA_MAX_PER_REQUEST`, défaut 10) pour
l'utilisateur authentifié.

- **Auth** : requise (Bearer) **+ email confirmé** (`confirmed_at IS NOT NULL`).
  Un compte non confirmé reçoit `403 userNotVerified`.
- **Action** : [UploadMediaAction](../src/Http/Action/Api/Users/UploadMediaAction.php)
- **Body** : `multipart/form-data` avec le champ `media` répété, OU `media[]`.
  - Formats acceptés : JPEG, PNG, WebP, GIF, HEIC, HEIF.
  - Taille max par fichier : `MEDIA_MAX_UPLOAD_BYTES` (défaut 12 Mo).
  - Optionnel — `description` (string, max `MEDIA_DESCRIPTION_MAX_LENGTH`,
    défaut 1024 caractères). Texte libre rédigé par l'utilisateur,
    appliqué **à tous les fichiers du POST** (même sémantique que les
    hashtags — un POST = une rafale cohérente). Trimé côté serveur ; une
    chaîne vide ou blanche est ignorée (= aucune description). Sur
    dépassement, le POST entier est rejeté `422 descriptionTooLong`
    avant la première écriture. Persistée dans `media_description`
    (table 1-1) et incluse dans l'index Meilisearch via `MediaIndexService`.
  - Optionnel — `hashtags[]` (répété, ex. `hashtags[]=sunset&hashtags[]=beach`)
    OU `hashtags` en CSV (`hashtags=sunset,beach`). **Le même jeu s'applique
    à TOUS les fichiers du POST** (les uploads en batch partagent un thème
    en pratique — pas de granularité par fichier en V1). Pipeline silencieux :
    chaque token est normalisé via [HashtagNormalizer](../src/Domain/Media/Hashtag/HashtagNormalizer.php)
    (strip `#`, décomposition NFD, lower, `[a-z0-9_]`, longueur dans
    `[MEDIA_HASHTAG_MIN_LEN, MEDIA_HASHTAG_MAX_LEN]`), les slugs bannis
    (`config/hashtag_blocklist.php`) sont écartés, et le surplus au-delà de
    `MEDIA_HASHTAGS_MAX` (défaut 30) est tronqué dans l'ordre de saisie. Le
    set effectivement persisté est renvoyé en `meta.hashtags` (paires
    `{slug, display}`).
- **Pipeline** ([MediaUploadService](../src/Domain/Media/MediaUploadService.php)),
  appliqué **par fichier**, halt-on-first-fail, un fichier mauvais n'aborte
  pas ses voisins :
  1. Validation pré-décode (taille, code PSR-7, non vide).
  2. Décodage Imagick + whitelist du format.
  3. **pHash perceptuel** + scan des hashes existants de l'utilisateur ;
     un candidat à distance de Hamming ≤ `MEDIA_SIMILARITY_HAMMING_THRESHOLD`
     (défaut 5) est rejeté `duplicateImage`.
  4. Extraction EXIF (sections EXIF/GPS/IFD0 — pas FILE/COMPUTED qui sont
     synthétisées par PHP). GPS → conversion rational→decimal → appel à
     `geo.locate(lat, lng)` qui renvoie `city_id` (cascade ville → région
     → pays côté SP). En présence de GPS, l'**Open Location Code**
     (Plus Code) est aussi calculé via
     [vectorial1024/open-location-code-php](https://github.com/Vectorial1024/open-location-code-php)
     et persisté sur `media.open_location_code` (longueur normale, 10
     chiffres significatifs + séparateur, ex. `8FW4V942+JV` pour Paris) ;
     resté `null` quand l'EXIF n'a pas de GPS. Sans EXIF, `is_manual = 1`.
  5. Auto-orient + **strip** complet (privacy : on conserve les EXIF dans
     `media_exif` mais on les vire du WebP livré).
  6. Resize `bestfit` à 2400 px max (pas d'upscale).
  7. Encodage WebP (qualité 85, `method=6`).
  8. Blurhash 4×3 composants → string `CHAR(30)` + petit WebP 16 px peint
     pixel par pixel pour servir de tile CSS.
  9. Écriture atomique (`tmp + rename`) du WebP + blurhash + original.
  10. Inserts `media`, `media_meta`, `media_exif` (si EXIF présent),
      `media_perceptual_hash`. Sur échec DB, les fichiers déjà écrits
      sont supprimés (best-effort rollback).
  11. Enqueue dans `work.media_to_describe` (PK `media_id BINARY(16)`,
      `INSERT IGNORE` idempotent) pour que le worker IA out-of-band génère
      la description du media. L'appel est **dans** le bloc try protégé
      par le rollback fichiers : si la queue est down, on préfère échouer
      l'upload plutôt que de livrer un media qui ne sera jamais décrit.
  12. Best-effort push Meilisearch `media_dev` via
      [MediaIndexService](../src/Domain/Media/MediaIndexService.php) — le
      service agrège le `media`, sa `media_description` (table 1-1 sur
      `hxa`), et tout futur side-data avant d'envoyer un document complet.
      Toute mutation ultérieure du media (édition de description, flip
      `is_published` par l'IA, mise à jour de score…) doit appeler
      `reindex($id)` pour rester en phase avec l'index. Jamais rollback
      MySQL sur échec d'indexation.
  13. **Gamification** — `XP_PER_MEDIA_UPLOAD` (défaut **50**) est ajouté à
      `user.experience` via un UPDATE atomique, et une ligne est insérée
      dans `user_transaction` (type=1 Experience, `user_emitter_id` NULL
      car c'est une self-action) dans la **même** transaction DB. Le
      `level` exposé dans la ressource `users` est recalculé à la lecture
      (voir [Conventions générales](#conventions-générales)). Avec les
      valeurs par défaut, un premier upload fait passer L1 → L2.

- **Réponse `200 OK`** (au moins un succès, ou `422` si tous ont échoué) :
  collection JSON:API `medias`, **partiel** par construction. Le front
  corrèle chaque ressource avec son fichier source via
  `meta.originalName`. Les fichiers refusés sont en `meta.errors[]`
  (pas dans `errors[]` racine).

```json
{
  "data": [
    {
      "type": "medias",
      "id": "5cc2a02b-f014-4207-9808-7229781aab14",
      "attributes": {
        "type": "photo",
        "cityId": null,
        "blurHash": "L4Aw…",
        "blurhashUrl": "http://hexatrip-static.dev.com/media/5c/c2/a0/5cc2…ab14-blurhash.webp",
        "url":         "http://hexatrip-static.dev.com/media/5c/c2/a0/5cc2…ab14.webp",
        "latitude": null,
        "longitude": null,
        "openLocationCode": null,
        "width": 800,
        "height": 600,
        "orientation": "landscape",
        "biome": null,
        "shotAt": "2026-06-07T12:34:56+00:00",
        "isManual": true,
        "isPublished": false,
        "status": "pending",
        "description": "Coucher de soleil sur la plage de Biarritz.",
        "createdAt": "2026-06-07T12:34:56+00:00",
        "updatedAt": null
      },
      "meta": { "originalName": "photo.jpg" }
    }
  ],
  "meta": {
    "accepted": 1,
    "rejected": 1,
    "errors": [
      { "originalName": "dup.jpg", "code": "duplicateImage", "title": "Une image similaire existe déjà." }
    ],
    "hashtags": [
      { "slug": "sunset", "display": "Sunset" },
      { "slug": "beach",  "display": "beach"  }
    ],
    "limits": { "maxPerRequest": 10 }
  }
}
```

- **Codes d'erreur par fichier** (mapping `MediaErrorCode` → i18n key
  `media.<code>`) :
  - `missing`, `uploadFailed`, `empty`, `tooLarge`, `tooMany`
  - `invalidImage`, `unsupportedFormat`
  - `duplicateImage`
  - `encodingFailed`, `blurhashFailed`, `storageWriteFailed`

- **`422 descriptionTooLong`** : `description` dépasse
  `MEDIA_DESCRIPTION_MAX_LENGTH`. Renvoyé au format `errors[]` racine,
  **avant** la boucle d'upload (fast-fail global, aucun fichier n'est traité).

- **`403 userNotVerified`** : compte non confirmé (renvoyé au format
  `errors[]` racine, pas par-fichier).

---

## `GET /api/users/me/media`

Liste les médias de l'utilisateur authentifié, **plus récent d'abord**.
Contrairement à l'endpoint public, retourne aussi les photos
`is_published = 0` (en attente de validation par le pipeline AI). C'est ici
que le front **sonde** l'attribut `status` pour suivre l'avancement du
traitement de chaque upload (voir ci-dessous).

- **Auth** : requise (Bearer)
- **Action** : [ListMyMediaAction](../src/Http/Action/Api/Users/ListMyMediaAction.php)
- **Query** :
  - `limit` (int, 1..50, défaut 20)
  - `cursor` (opaque base64url) — page suivante (rows plus anciennes que le curseur)
  - `before` (opaque base64url) — page précédente (rows plus récentes que le curseur) ; gagne sur `cursor` si les deux sont fournis
  - Format du curseur : `base64url("<unix-ts>.<uuid-hex>")` — cursor partagé avec les autres listings paginés (notifs, follows).
- **Réponse `200 OK`** : collection JSON:API `medias`, ordre
  `(created_at DESC, id DESC)`, comparaison de tuple côté SQL pour gérer
  les égalités de timestamp. Navigation via l'objet `links` racine (`self/first/prev/next`).

```json
{
  "data": [ /* …ressources medias… */ ],
  "links": {
    "self":  "https://api.example/api/users/me/media?limit=20",
    "first": "https://api.example/api/users/me/media?limit=20",
    "prev":  null,
    "next":  "https://api.example/api/users/me/media?cursor=MTcxMjM0…&limit=20"
  },
  "meta": { "limit": 20, "total": 47 }
}
```

- `meta.total` : nombre **exact** de médias de l'utilisateur authentifié (publiés ET non-publiés), `COUNT(*)` indexé sur `user_id`.
- **`400 Invalid cursor`** si `cursor` ou `before` est mal formé.

---

## `GET /api/users/me/feed`

**Fil d'actualité** de l'utilisateur authentifié : une timeline chronologique
unique (**plus récent d'abord**) fusionnant les médias **publiés** de **toutes
les personnes qu'il suit**. C'est l'endpoint « ce que postent les gens que je
suis ».

- **Auth** : requise (Bearer)
- **Action** : [ListMyFeedAction](../src/Http/Action/Api/Users/ListMyFeedAction.php)
- **Query** : identique à `GET /api/users/me/media` (`limit` 1..50 défaut 20, `cursor`, `before`).
- **Périmètre** :
  - Seuls les médias `is_published = 1` remontent — les uploads en cours de
    traitement AI d'un auteur suivi ne fuitent **jamais** dans le fil d'un
    autre utilisateur (cette vue owner-only reste sur `GET /api/users/me/media`).
  - Les auteurs suivis sont résolus via
    [UserFollowRepository::listAllFollowingIds](../src/Domain/User/Follow/UserFollowRepository.php),
    puis la timeline via un `user_id IN (…)` filtré `is_published = 1`,
    keyset-paginé sur `(created_at DESC, id DESC)`.
  - Chaque média porte le bloc `author` inline habituel ; comme le fil mélange
    plusieurs auteurs, ils sont **batch-loadés** (dédupliqués) en un seul
    round-trip `UserRepository` — pas de N+1.
  - Afficher une page compte **une impression** par média pour les stats de son
    auteur, exactement comme la galerie publique `GET /api/users/{userId}/media`.
- **Réponse `200 OK`** : collection JSON:API `medias`, ordre
  `(created_at DESC, id DESC)`. Navigation via l'objet `links` racine
  (`self/first/prev/next`).

```json
{
  "data": [ /* …ressources medias, tous auteurs confondus… */ ],
  "links": {
    "self":  "https://api.example/api/users/me/feed?limit=20",
    "first": "https://api.example/api/users/me/feed?limit=20",
    "prev":  null,
    "next":  "https://api.example/api/users/me/feed?cursor=MTcxMjM0…&limit=20"
  },
  "meta": { "limit": 20, "total": 128 }
}
```

- `meta.total` : nombre **exact** de médias publiés de l'ensemble des auteurs
  suivis (`COUNT(*)` avec `user_id IN (…) AND is_published = 1`).
- **Fil vide** : un utilisateur qui ne suit personne reçoit une collection vide
  (`data: []`, `meta.total: 0`) — **jamais** une erreur, et aucune requête n'est
  émise sur la table `media`.
- **`400 Invalid cursor`** si `cursor` ou `before` est mal formé.

> **Note de montée en charge (V1)** : les IDs suivis sont chargés en un seul lot
> et injectés dans un `IN (…)`. Acceptable jusqu'à des dizaines de milliers de
> follows ; au-delà, remplacer par un générateur + fetch par batch (même réserve
> que le fan-out de notifications à la publication d'un média).

---

## `DELETE /api/users/me/media/{mediaId}`

Supprime définitivement un média **possédé par l'utilisateur authentifié**.

- **Auth** : requise (Bearer)
- **Action** : [DeleteMyMediaAction](../src/Http/Action/Api/Users/DeleteMyMediaAction.php)
- **Effet** ([MediaDeleteService](../src/Domain/Media/MediaDeleteService.php)) :
  1. Unlink du WebP publié + blurhash WebP.
  2. Unlink de l'original archivé (`glob "<hex>.*"` — l'extension d'origine
     n'est pas stockée séparément, mais l'archive ne contient qu'un seul
     fichier portant ce hex).
  3. Delete dans `media_meta`, `media_exif`, `media_perceptual_hash`.
  4. Delete dans `hxa.media`.
  5. Best-effort delete du document Meilisearch.

  Le cache Glide (`MEDIA_CACHE_PATH`) **n'est pas purgé** : la source
  étant absente, Glide retournera 404 sur la prochaine requête et l'ops
  vide le cache hors-bande.

- **Réponses** :
  - `204 No Content` : suppression effectuée.
  - `403 forbidden` : le média existe mais appartient à un autre user.
  - `404 notFound` : aucun média avec cet id (ou déjà supprimé — le 2e
    appel sur le même id renvoie 404, ce n'est pas idempotent au sens
    HTTP strict).
  - `422` : `mediaId` n'est pas un UUID.

---

## `PUT /api/users/me/media/{mediaId}/description`

Met à jour (ou crée) la **description libre** d'un média possédé par
l'utilisateur authentifié. Texte simple, max
`MEDIA_DESCRIPTION_MAX_LENGTH` caractères (défaut 1024).

- **Auth** : requise (Bearer).
- **Action** : [SetMediaDescriptionAction](../src/Http/Action/Api/Users/SetMediaDescriptionAction.php).
- **Body** : `application/json`
  ```json
  { "description": "Coucher de soleil sur la plage de Biarritz." }
  ```
  - Le serveur **trime** la valeur avant validation.
  - Une chaîne **vide** ou exclusivement blanche est traitée comme une
    suppression implicite (= équivalente à un
    `DELETE /api/users/me/media/{mediaId}/description`) : la ligne
    `media_description` est supprimée et le document Meilisearch est
    réindexé sans description.
- **Effet** :
  1. Upsert dans `media_description` (PK `media_id`, table 1-1).
  2. Réindexation Meilisearch via
     [MediaIndexService::reindex](../src/Domain/Media/MediaIndexService.php)
     pour que la description soit visible côté recherche full-text.
- **Réponses** :
  - `204 No Content` : description écrite (ou supprimée si vide).
  - `403 forbidden` : le média existe mais appartient à un autre user.
  - `404 notFound` : aucun média avec cet id.
  - `422 descriptionTooLong` : longueur > `MEDIA_DESCRIPTION_MAX_LENGTH`.
    Source-pointer `/data/attributes/description`.
  - `422` : `mediaId` n'est pas un UUID, ou body JSON invalide.

---

## `DELETE /api/users/me/media/{mediaId}/description`

Supprime la description d'un média possédé par l'utilisateur authentifié.
**Idempotent** : renvoie `204` même si aucune description n'était
enregistrée (pas de `404` au 2ᵉ appel).

- **Auth** : requise (Bearer).
- **Action** : [DeleteMediaDescriptionAction](../src/Http/Action/Api/Users/DeleteMediaDescriptionAction.php).
- **Effet** :
  1. Delete de la ligne `media_description` (no-op si absente).
  2. Réindexation Meilisearch (description = `null` dans le document).
- **Réponses** :
  - `204 No Content` : ligne supprimée ou déjà absente.
  - `403 forbidden` : le média existe mais appartient à un autre user.
  - `404 notFound` : aucun média avec cet id (la garde owner-only
    prévaut sur l'idempotence — un id inexistant reste un 404).
  - `422` : `mediaId` n'est pas un UUID.

---

## `GET /api/users/{userId}/media`

Liste **publique** des médias d'un utilisateur — restreinte à
`is_published = 1` (les uploads en attente AI restent invisibles aux
autres utilisateurs).

- **Auth** : aucune
- **Action** : [ListUserMediaAction](../src/Http/Action/Api/Users/ListUserMediaAction.php)
- **Query** : identique à `GET /api/users/me/media` (`limit`, `cursor`, `before`).
- **Réponses** :
  - `200 OK` + collection JSON:API `medias` (vide si l'user n'a aucune
    photo publiée). `meta.total` donne le nombre **exact** de médias publiés de l'utilisateur ciblé (`COUNT(*) WHERE user_id = ? AND is_published = 1`).
  - `404 User not found` si l'id n'existe pas.
  - `422 Invalid user id` si `userId` n'est pas un UUID.

---

## `GET /api/media/nearby`

Découverte géographique : retourne les médias **publiés** situés dans un rayon `distance` (en mètres) autour d'un point GPS, **triés du plus proche au plus éloigné**. Adossé à Meilisearch (filtre `_geoRadius`, tri `_geoPoint:asc`).

- **Auth** : aucune.
- **Action** : [SearchNearbyMediaAction](../src/Http/Action/Api/Media/SearchNearbyMediaAction.php).
- **Query (obligatoires)** :
  - `lat` : float dans `[-90, 90]`
  - `lng` : float dans `[-180, 180]`
  - `distance` : entier positif (mètres), borné par `MEDIA_NEARBY_MAX_DISTANCE_METERS` (défaut **100 000 m = 100 km**).
- **Query (optionnels)** :
  - `q` : full-text sur `name` + `description` (vide = pur browse géo)
  - `hashtags` : répété (`hashtags[]=sunset&hashtags[]=beach`) OU CSV (`hashtags=sunset,beach`). Les tokens passent par [HashtagNormalizer](../src/Domain/Media/Hashtag/HashtagNormalizer.php) — `#Sunset!` matche bien le facet `sunset`. **OR** entre les slugs : un média est retenu s'il porte AU MOINS UN des hashtags. La liste effectivement appliquée est renvoyée dans `meta.hashtags`.
  - `orientation` : `landscape`, `portrait`, `square` ou `panorama` (whitelist stricte ; toute autre valeur → `422`). Filtre exact sur le facet `orientation` du document Meili. Absent = toutes orientations. La valeur effectivement appliquée est renvoyée dans `meta.orientation` (ou `null`).
  - `limit` : 1..50 (défaut 20)
  - `offset` : 0+ (défaut 0)
- **Filtrage Meilisearch** : `_geoRadius(lat, lng, distance) AND is_published = true` (+ `AND hashtags IN [...]` et/ou `AND orientation = '...'` si fournis). Une seconde garantie est appliquée côté MySQL au moment de la ré-hydratation (`publishedOnly: true`) pour neutraliser un éventuel document Meili obsolète.
- **Pipeline** : Meilisearch renvoie des `id` ordonnés par distance croissante, Hydrogen ré-hydrate les entités `Media` depuis MySQL via `MediaRepository::findManyByIds()` → mêmes ressources `medias` que les autres endpoints, avec un attribut supplémentaire :
  - `attributes.distanceMeters` : distance en mètres au point de référence (lu depuis le `_geoDistance` que Meili calcule lors du tri par `_geoPoint`).

```json
{
  "jsonapi": { "version": "1.1" },
  "data": [
    {
      "type": "medias",
      "id":   "0193…",
      "attributes": {
        "name": "Pont du Gard",
        "url": "http://hexatrip-static.dev.com/media/…",
        "latitude": 43.9476,
        "longitude": 4.5354,
        "distanceMeters": 248.7,
        "...": "…"
      }
    }
  ],
  "links": {
    "self":  "https://api.example/api/media/nearby?distance=2000&lat=43.95&lng=4.54&limit=20",
    "first": "https://api.example/api/media/nearby?distance=2000&lat=43.95&lng=4.54&limit=20",
    "prev":  null,
    "next":  "https://api.example/api/media/nearby?distance=2000&lat=43.95&lng=4.54&limit=20&offset=20",
    "last":  "https://api.example/api/media/nearby?distance=2000&lat=43.95&lng=4.54&limit=20&offset=80"
  },
  "meta": {
    "totalHits": 95,
    "limit":     20,
    "offset":    0,
    "center":    { "lat": 43.95, "lng": 4.54 },
    "distance":  2000,
    "query":     ""
  }
}
```

- **Erreurs** :
  - `422` : `lat`/`lng`/`distance` manquant, non numérique, hors bornes, ou `distance > MEDIA_NEARBY_MAX_DISTANCE_METERS`.
  - `503 Search backend unavailable` : Meilisearch indisponible ou index mal configuré (voir prérequis ci-dessous) — l'erreur API est propagée dans `errors[].detail`.

- **Prérequis index Meilisearch** (one-shot, à appliquer côté ops sur l'index `MEILISEARCH_MEDIA_INDEX`) :
  - `filterableAttributes` doit contenir `_geo` ET `is_published`
  - `sortableAttributes` doit contenir `_geo`
  - Exemple (curl) :
    ```bash
    curl -X PATCH "$MEILI/indexes/$INDEX/settings" -H "Authorization: Bearer $KEY" \
      -H "Content-Type: application/json" \
      -d '{"filterableAttributes":["_geo","is_published","user_id"],"sortableAttributes":["_geo","shot_at","created_at"]}'
    ```
  - Les documents poussés par [MeilisearchMediaSync](../src/Infrastructure/Meilisearch/MeilisearchMediaSync.php) émettent automatiquement l'objet `_geo: {lat, lng}` quand les deux coordonnées sont connues côté domaine ; un média sans GPS n'apparaîtra simplement pas dans les résultats de cet endpoint.

---

## `GET /api/media/in-bounds`

Découverte par **rectangle géographique** (bounding box). Retourne les médias **publiés** dont la position GPS tombe dans le rectangle décrit par les quatre coins, sans tri par distance (le rectangle n'a pas de centre canonique — l'ordre suit la pertinence de `q` ou l'ordre d'index). Pensé pour des UI carto qui pan/zoom et veulent peupler le viewport courant (équivalent de `map.getBounds()` côté Leaflet/Mapbox).

- **Auth** : optionnelle. Quand un Bearer token est présent, les `viewerReaction` sont peuplées (sinon `null`).
- **Action** : [SearchMediaInBoundsAction](../src/Http/Action/Api/Media/SearchMediaInBoundsAction.php).
- **Query (obligatoires)** :
  - `north` : float dans `(-90, 90]`
  - `south` : float dans `[-90, 90)`, **strictement** `< north`
  - `east`  : float dans `[-180, 180]`
  - `west`  : float dans `[-180, 180]`, `<= east` (le wrap au méridien 180 n'est PAS supporté — le client doit envoyer deux requêtes s'il en a besoin)
- **Query (optionnels)** :
  - `q` : full-text sur `name` + `description`
  - `hashtags` : répété OU CSV (mêmes règles que `/media/nearby`).
  - `orientation` : `landscape`, `portrait`, `square` ou `panorama` (mêmes règles que `/media/nearby`).
  - `limit` : 1..50 (défaut 20)
  - `offset` : 0+ (défaut 0)
- **Filtrage Meilisearch** : `_geoBoundingBox([north, east], [south, west]) AND is_published = true` (+ `AND hashtags IN [...]` et/ou `AND orientation = '...'` si fournis). Convention Meilisearch : `[top-right_lat, top-right_lng], [bottom-left_lat, bottom-left_lng]`. Comme `/media/nearby`, une seconde garantie est appliquée côté MySQL au moment de la ré-hydratation (`publishedOnly: true`).

```json
{
  "jsonapi": { "version": "1.1" },
  "data": [
    { "type": "medias", "id": "0193…", "attributes": { "name": "…", "latitude": 43.9476, "longitude": 4.5354, "...": "…" } }
  ],
  "links": {
    "self":  "https://api.example/api/media/in-bounds?north=44.0&south=43.9&east=4.6&west=4.5&limit=20",
    "first": "…",
    "prev":  null,
    "next":  "…",
    "last":  "…"
  },
  "meta": {
    "totalHits": 137,
    "limit":     20,
    "offset":    0,
    "bbox":      { "north": 44.0, "south": 43.9, "east": 4.6, "west": 4.5 },
    "query":     ""
  }
}
```

- **Erreurs** :
  - `422` : un des quatre paramètres manquant, non numérique, hors bornes ; `south >= north` ; `west > east`.
  - `503 Search backend unavailable` : Meilisearch indisponible ou index mal configuré.

- **Prérequis index Meilisearch** : identiques à `/media/nearby` côté filtres (`_geo` et `is_published` dans `filterableAttributes`). `_geoBoundingBox` n'a PAS besoin que `_geo` soit dans `sortableAttributes` (aucun tri par distance ici), mais le partager avec `/media/nearby` ne pose aucun problème.

---

## `GET /api/media/recommended/nearby`

Recommandation personnalisée « médias près de chez vous ». Variante de `/media/nearby` où **le centre géographique et le filtre thématique sont dérivés du viewer** plutôt qu'épelés dans l'URL. Tri du plus proche au plus éloigné (`_geoPoint:asc`).

- **Auth** : optionnelle. Connecté = centre déduit du profil + personnalisation par topics ; anonyme = `lat`+`lng` obligatoires, pas de personnalisation.
- **Action** : [RecommendedNearbyMediaAction](../src/Http/Action/Api/Media/RecommendedNearbyMediaAction.php).
- **Résolution du centre** (par priorité) :
  1. `lat` + `lng` explicites (fix GPS du téléphone) — priment toujours. Fournis ensemble ou pas du tout.
  2. sinon, la **ville de naissance** du viewer (`user.birthplaceCityId`) résolue en coordonnées via l'index Meili `cities` ([CitySummaryResolver](../src/Domain/City/CitySummaryResolver.php)).
  - Aucun des deux → `422` (impossible de recommander « près d'ici » sans « ici »).
- **Personnalisation par topics** (`personalize`, défaut **on**) : quand le viewer suit des topics ET que ces topics sont mappés vers des slugs de hashtags dans la table `topic_hashtag`, les résultats sont restreints aux médias portant **au moins un** de ces slugs (`hashtags IN [...]`). Sans viewer / sans mapping / `personalize=0` → filtre omis, l'endpoint dégrade proprement vers une simple recherche par rayon. Les slugs effectivement appliqués sont renvoyés dans `meta.topics`.
- **Query (optionnels)** :
  - `lat`, `lng` : décimaux, ensemble ou pas du tout (override du fallback ville de naissance).
  - `distance` : entier positif (mètres), défaut **et** plafond = `MEDIA_NEARBY_MAX_DISTANCE_METERS` (100 km).
  - `personalize` : `0`/`false`/`no`/`off` désactive le filtre topics→hashtags.
  - `limit` : 1..50 (défaut 20) ; `offset` : 0+ (défaut 0).
- **Filtrage Meilisearch** : `_geoRadius(lat, lng, distance) AND is_published = true` (+ `AND hashtags IN [...]` si personnalisation active). Ré-hydratation MySQL `publishedOnly: true` comme `/media/nearby`. Chaque ressource porte `attributes.distanceMeters`.
- **meta** : `totalHits`, `limit`, `offset`, `center: { lat, lng, source }` (`source` = `explicit` | `birthplace`), `distance`, `personalize`, `topics: [...]`.
- **Erreurs** :
  - `422 Invalid center` : `lat` sans `lng` (ou inverse).
  - `422 No usable location` : aucun `lat`/`lng` et pas de ville de naissance exploitable.
  - `422` : `lat`/`lng` hors bornes, `distance` non positif ou `> MEDIA_NEARBY_MAX_DISTANCE_METERS`.
  - `503 Search backend unavailable` : Meilisearch indisponible ou index mal configuré.
- **Prérequis index Meilisearch** : identiques à `/media/nearby` (`_geo` + `is_published` filterables, `_geo` sortable). La personnalisation requiert l'attribut `hashtags` filterable.

---

## `GET /api/media/trending`

« Tendances », éventuellement cadrées sur un pays. Modèle de ranking volontairement simple (pas de job nocturne) : médias **publiés** créés dans une **fenêtre récente**, triés par `likes_count` décroissant (tie-break `created_at` desc). Le champ `score` (qualité IA) n'est **pas** utilisé : il mesure la qualité intrinsèque, pas l'engagement, et n'est pas sortable.

- **Auth** : optionnelle. Connecté = fallback pays via ville de naissance + personnalisation topics ; anonyme = `?country` explicite ou mondial.
- **Action** : [TrendingMediaAction](../src/Http/Action/Api/Media/TrendingMediaAction.php).
- **Résolution du pays** (par priorité) :
  1. `country` explicite (ISO 3166-1 alpha-2, insensible à la casse).
  2. sinon, le pays de la **ville de naissance** du viewer.
  3. sinon, **mondial** (aucun filtre pays).
- **Personnalisation par topics** : identique à `/media/recommended/nearby` (`personalize`, défaut on ; `meta.topics`).
- **Query (optionnels)** :
  - `country` : ISO alpha-2. Vide → chaîne de fallback ci-dessus.
  - `windowDays` : entier 1..`MEDIA_TRENDING_MAX_WINDOW_DAYS` (défaut **365**), défaut `MEDIA_TRENDING_WINDOW_DAYS` (**30**). Fenêtre de fraîcheur (`created_at >= now - windowDays * 86400`).
  - `personalize` : `0`/`false`/`no`/`off` désactive le filtre topics→hashtags.
  - `limit` : 1..50 (défaut 20) ; `offset` : 0+ (défaut 0).
- **Filtrage Meilisearch** : `is_published = true AND created_at >= <since>` (+ `AND country_id = 'XX'` si pays résolu, + `AND hashtags IN [...]` si personnalisation active). Tri `["likes_count:desc", "created_at:desc"]`. Ré-hydratation MySQL `publishedOnly: true`.
- **meta** : `totalHits`, `limit`, `offset`, `country` (ISO uppercase ou `null`), `countrySource` (`explicit` | `birthplace` | `null`), `windowDays`, `personalize`, `topics: [...]`.
- **Erreurs** :
  - `422 Invalid country` : `country` non conforme à ISO alpha-2.
  - `422 Invalid windowDays` : `windowDays` non entier.
  - `503 Search backend unavailable` : Meilisearch indisponible ou index mal configuré.
- **Prérequis index Meilisearch** : `is_published`, `created_at`, `country_id` et `hashtags` filterables ; `likes_count` et `created_at` sortables.

---

## `GET /api/media/search`

Recherche **plateforme** de médias publiés : full-text **+ facettes**, **sans** contrainte géographique (contrairement à `/media/nearby` et `/media/in-bounds`). L'endpoint « trouver un média » générique.

- **Auth** : aucune (`OptionalAuthenticationMiddleware` — un Bearer fait juste apparaître `viewerReaction`).
- **Action** : [SearchMediaAction](../src/Http/Action/Api/Media/SearchMediaAction.php).
- **Query (tous optionnels, mais voir la garde)** :
  - `q` : full-text sur `name` + `description` + `hashtags`. Vide = navigation par facettes seulement.
  - `hashtags` : répété (`hashtags[]=sunset&hashtags[]=beach`) OU CSV (`hashtags=sunset,beach`). Normalisés ; **OR** entre slugs.
  - `orientation` : `landscape` | `portrait` | `square` | `panorama`.
  - `type` : id de type de média (entier) → facette `media_type_id`.
  - `biome` : id de biome WWF (entier) → facette `biome_id`.
  - `country` : ISO 3166-1 alpha-2 (insensible à la casse) → facette `country_id`.
  - `sort` : `relevance` (défaut) | `recent` | `popular` | `views`.
  - `limit` : 1..50 (défaut 20) · `offset` : 0+ (défaut 0).
- **Garde** : il faut **au moins** `q` non vide OU un filtre de facette. Un appel nu (`/media/search` sans rien) est refusé en `422` (ce serait un dump complet du catalogue → rôle de `/media/latest`, authentifié).
- **Périmètre** : `is_published = true` uniquement, ré-hydratation MySQL, mêmes ressources `medias` que partout, impression comptée par média affiché.
- **Distribution de facettes** : `meta.facets` renvoie le comptage vivant des facettes (`hashtags`, `orientation`, `media_type_id`, `biome_id`, `country_id`) sur le jeu de résultats courant — de quoi peupler une UI à facettes sans second appel.
- **Réponses** :
  - `200 OK` + collection JSON:API `medias` + `links` offset `{self, first, prev, next, last}` + `meta` (`totalHits` estimé, filtres appliqués, `facets`).
  - `422` : garde ci-dessus, ou `orientation` / `type` / `biome` / `country` / `sort` invalide.
  - `503 Search backend unavailable` : Meilisearch indisponible.
- **Prérequis index** : `is_published`, `hashtags`, `orientation`, `media_type_id`, `biome_id`, `country_id` filterables ; `created_at`, `likes_count`, `views_count` sortables.

---

## `GET /api/media/latest`

Timeline **plateforme** : flux chronologique (du plus récent au plus ancien) de **tous** les médias publiés, tous auteurs confondus. Pendant non filtré du *following feed* `GET /api/users/me/feed` (même contrat keyset, sans le graphe d'abonnements).

- **Auth** : **requise** (`AuthenticationMiddleware`, Bearer = session). Volontairement **pas** anonyme : un firehose « tout, du plus récent » est un vecteur de scraping, et la ressource porte des champs relatifs au viewer (`viewerReaction`).
- **Action** : [ListLatestMediaAction](../src/Http/Action/Api/Media/ListLatestMediaAction.php).
- **Query** :
  - `cursor` (opaque) : curseur avant (page suivante) ; `before` (opaque) : curseur arrière (page précédente).
  - `limit` (int, 1..50) : taille de page, défaut 20.
- **Tri / pagination** : keyset sur `(created_at DESC, id DESC)` — même mécanique que le following feed et la galerie utilisateur.
- **Périmètre** : `is_published = 1` uniquement. Bloc `author` inline, batch-chargé (dédupliqué) en un seul aller-retour. Chaque média affiché compte **une impression** pour les stats de son auteur.
- **`meta.total`** : **absent par design** (COUNT non borné hors hot path). Seul `meta.limit` est renvoyé ; paginer via `links.next`.
- **Réponses** :
  - `200 OK` + collection JSON:API `medias` (+ `links` keyset `{self, prev, next}`).
  - `400 Invalid cursor` si `cursor` / `before` malformé.
  - `401` si aucune session valide.

---
