---
title: "Hashtags média"
description: "Système de hashtags libres, apposés par l'utilisateur au moment du POST /api/users/me/media. Stockage sur la table media_hashtag (paire media_id / slug + d…"
---

Système de hashtags **libres**, **apposés par l'utilisateur** au moment du
`POST /api/users/me/media`. Stockage sur la table `media_hashtag` (paire
`media_id` / `slug` + `display` dénormalisé + `position`), et indexation
comme **facet** Meilisearch sur l'attribut `hashtags` du document média —
ce qui permet de tout faire (autocomplete, trending, related, filtre dans
`/media/nearby` et `/media/in-bounds`) côté index sans entité hashtag
canonique en MySQL. Le slug est l'identifiant API ; le `display` n'est
conservé que pour la restitution sur la ressource `medias`.

Pipeline d'écriture (cf. [MediaHashtagSyncService](../src/Domain/Media/Hashtag/MediaHashtagSyncService.php)) :

```
raw user list
  → HashtagNormalizer (strip `#`, NFD, lower, [a-z0-9_], min/max len)
  → HashtagBlocklist  (silent drop, seed config/hashtag_blocklist.php)
  → cap MEDIA_HASHTAGS_MAX (truncate keep order)
  → MediaHashtagRepository::syncForMedia()
```

**Synonymes** : Meilisearch résout symétriquement les paires déclarées dans
`config/hashtag_synonyms.php` — une recherche `q=sunset` matche les médias
portant `#sundown` ou `#dusk` si la map les y associe. Les slugs canoniques
restent inchangés sur les documents (donc `trending` / `related` comptent
chaque slug pour ce qu'il vaut). La map est statique, éditable par PR,
poussée via :

```
php bin/media-meili-apply-settings.php
```

qui applique aussi les `filterableAttributes` / `searchableAttributes` /
`typoTolerance.disableOnAttributes` (la typo-tolérance est désactivée sur
`hashtags` — `#suns3t` ne doit pas matcher `#sunset`).

**Ressource `media-hashtags`** : type JSON:API émis par les 3 endpoints
ci-dessous. `id = slug`.

```json
{
  "type": "media-hashtags",
  "id":   "sunset",
  "attributes": { "slug": "sunset", "mediaCount": 12453 }
}
```

**Variables d'env** :
- `MEDIA_HASHTAGS_MAX` (défaut 30) — cap par média à l'upload.
- `MEDIA_HASHTAG_MIN_LEN` (défaut 2) — longueur slug min.
- `MEDIA_HASHTAG_MAX_LEN` (défaut 50) — longueur slug max.
- `MEDIA_HASHTAG_AUTOCOMPLETE_MAX_LIMIT` (défaut 20).
- `MEDIA_HASHTAG_TRENDING_MAX_LIMIT`     (défaut 20).
- `MEDIA_HASHTAG_RELATED_MAX_LIMIT`      (défaut 20).

---

## `GET /api/media/hashtags/autocomplete`

Suggère des slugs commençant par un préfixe. Utilise `facetSearch` de
Meilisearch sur l'attribut `hashtags`, restreint aux médias **publiés**.

- **Auth** : aucune.
- **Action** : [AutocompleteMediaHashtagsAction](../src/Http/Action/Api/Media/Hashtag/AutocompleteMediaHashtagsAction.php).
- **Query** :
  - `q` (obligatoire) : préfixe (1..`MEDIA_HASHTAG_MAX_LEN` chars). Normalisé
    avant la requête — l'utilisateur peut taper `#Sunset!`. Un `q` vide
    déclenche `422 Missing query` ; un `q` qui dégénère en slug vide après
    normalisation (uniquement de la ponctuation) renvoie une collection vide
    plutôt que `422` (UI silencieuse pendant la frappe).
  - `limit` (optionnel, 1..`MEDIA_HASHTAG_AUTOCOMPLETE_MAX_LIMIT`, défaut 10).
- **Réponse `200 OK`** : collection JSON:API `media-hashtags`. `meta.prefix`
  contient le préfixe **normalisé** appliqué.

```json
{
  "data": [
    { "type": "media-hashtags", "id": "sunset",   "attributes": { "slug": "sunset",   "mediaCount": 12453 } },
    { "type": "media-hashtags", "id": "sundown",  "attributes": { "slug": "sundown",  "mediaCount":   234 } },
    { "type": "media-hashtags", "id": "sunshine", "attributes": { "slug": "sunshine", "mediaCount":    89 } }
  ],
  "links": { "self": "https://api.example/api/media/hashtags/autocomplete?q=sun&limit=10" },
  "meta":  { "total": 3, "prefix": "sun", "limit": 10 }
}
```

- **Erreurs** :
  - `422 Missing query` : `q` absent ou trim vide.
  - `503 Search backend unavailable` : Meilisearch KO.

---

## `GET /api/media/hashtags/trending`

Histogramme live des hashtags les plus portés par les médias **publiés**,
trié par `mediaCount` desc puis `slug` asc (tiebreaker déterministe).
Calculé via `facetDistribution` sur une requête vide — pas de table
matérialisée, pas de job nocturne. Filtre géographique optionnel pour
basculer "tendances mondiales" ↔ "tendances autour de moi".

- **Auth** : aucune.
- **Action** : [TrendingMediaHashtagsAction](../src/Http/Action/Api/Media/Hashtag/TrendingMediaHashtagsAction.php).
- **Query** :
  - `limit` (optionnel, 1..`MEDIA_HASHTAG_TRENDING_MAX_LIMIT`, défaut 10).
  - Trio géo (tout-ou-rien — une présence partielle = `422`) :
    - `lat` : float `[-90, 90]`
    - `lng` : float `[-180, 180]`
    - `distance` : entier > 0, en mètres.
- **Réponse `200 OK`** : collection JSON:API `media-hashtags`. `meta.geo`
  reflète le trio appliqué quand il est présent.

```json
{
  "data": [
    { "type": "media-hashtags", "id": "sunset", "attributes": { "slug": "sunset", "mediaCount": 12453 } },
    { "type": "media-hashtags", "id": "beach",  "attributes": { "slug": "beach",  "mediaCount":  9821 } }
  ],
  "links": { "self": "https://api.example/api/media/hashtags/trending?limit=10" },
  "meta":  { "total": 2, "limit": 10 }
}
```

Avec le filtre géo :

```json
{
  "meta": {
    "total": 2,
    "limit": 10,
    "geo":   { "lat": 43.95, "lng": 4.54, "distance": 5000 }
  }
}
```

- **Erreurs** :
  - `422 Invalid geo filter` : trio géo partiel, non numérique, hors bornes,
    ou `distance <= 0`.
  - `503 Search backend unavailable` : Meilisearch KO.

---

## `GET /api/media/hashtags/related`

Co-occurrence : "parmi les médias publiés portant `#sunset`, quels AUTRES
hashtags reviennent le plus ?". `facetDistribution` sur une requête filtrée
par le slug d'ancrage. L'ancrage lui-même est retiré du résultat (il
dominerait toujours), et les slugs bannis le sont aussi.

- **Auth** : aucune.
- **Action** : [RelatedMediaHashtagsAction](../src/Http/Action/Api/Media/Hashtag/RelatedMediaHashtagsAction.php).
- **Query** :
  - `hashtag` (obligatoire) : slug d'ancrage. Passé par `HashtagNormalizer`.
  - `limit` (optionnel, 1..`MEDIA_HASHTAG_RELATED_MAX_LIMIT`, défaut 10).
- **Réponse `200 OK`** : collection JSON:API `media-hashtags`. `meta.anchor`
  contient le slug normalisé.

```json
{
  "data": [
    { "type": "media-hashtags", "id": "beach",  "attributes": { "slug": "beach",  "mediaCount": 4321 } },
    { "type": "media-hashtags", "id": "summer", "attributes": { "slug": "summer", "mediaCount": 1987 } }
  ],
  "links": { "self": "https://api.example/api/media/hashtags/related?hashtag=sunset&limit=10" },
  "meta":  { "total": 2, "anchor": "sunset", "limit": 10 }
}
```

- **Erreurs** :
  - `422 Missing anchor hashtag` : `hashtag` absent ou trim vide.
  - `422 Invalid anchor hashtag` : dégénère en slug vide après normalisation.
  - `503 Search backend unavailable` : Meilisearch KO.

---

## `GET /media/{hex}.{ext}`

Endpoint **public hors `/api`** de redimensionnement/transcodage à la
volée via [league/glide](https://glide.thephpleague.com/).

- **Auth** : optionnelle (soft) via [OptionalAuthenticationMiddleware](../src/Http/Middleware/OptionalAuthenticationMiddleware.php). Un Bearer token valide est honoré mais pas obligatoire — l'absence de token ne renvoie jamais `401`.
- **Action** : [ServeMediaAction](../src/Http/Action/Media/ServeMediaAction.php)
- **Route** : `/media/{hex:[0-9a-f]{32}}.{ext:webp|jpg|jpeg|png|gif|avif}`
  - `hex` : hex lowercase 32 chars de l'UUID du média
  - `ext` : format de SORTIE souhaité (le source est **toujours** le WebP
    canonique à `MEDIA_STORAGE_PATH/AA/BB/CC/<hex>.webp`)

- **Query Glide** standard : `w`, `h`, `fit`, `q`, `dpr`, `bri`, `con`,
  `blur`, `sharp`, `crop`, etc. — voir [la doc Glide](https://glide.thephpleague.com/2.0/api/quick-reference/).
- **Signature** : si `MEDIA_SIGN_KEY` est défini, le param `s=<md5>` est
  **obligatoire** (`SignatureFactory` Glide standard, signature calculée
  sur `"<hex>.<ext>" + params triés alphabétiquement`). En dev,
  `MEDIA_SIGN_KEY` vide → toute requête est acceptée.
- **Cache** : derivés écrits sous `MEDIA_CACHE_PATH` (ventilé sur le
  source, `cache_with_file_extensions=true`). Une requête identique
  ultérieure est servie directement depuis le cache (~0.5 ms vs ~60 ms à
  froid).
- **Pilote** : Imagick (cohérent avec le pipeline d'upload).
- **Garde-fou** : Glide refuse toute production excédant
  `MEDIA_GLIDE_MAX_PIXELS` (défaut 16 MP) → `400`.

- **Réponse `200 OK`** : binaire image, `Content-Type` cohérent avec
  `ext`, `Cache-Control: max-age=31536000, public` (1 an, immuable —
  toute modification produit une URL différente).
- **Contrôle d'accès** :
  - Média **publié** (`is_published = 1`) → accessible anonymement.
  - Média **non publié** (encore en attente du pipeline AI, ou retiré) →
    servi **uniquement à son propriétaire** (Bearer token requis ET
    `media.user_id` doit correspondre au porteur du token). Tout autre
    cas (token absent, autre utilisateur, média inexistant) → `404`
    indifférencié, pour ne jamais confirmer l'existence d'un upload
    privé.
- **Erreurs** :
  - `400` : `hex` ou `ext` invalide, ou paramètres Glide aberrants.
  - `403` : signature manquante / invalide (uniquement si
    `MEDIA_SIGN_KEY` est défini).
  - `404` : média inconnu, source filesystem manquante, ou média non
    publié réclamé par quelqu'un d'autre que son propriétaire (cas
    indistinguables côté client par design).

---
