Skip to content

Hashtags média

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) :

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.

{
"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).

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.
  • 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é.
{
"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.

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.
  • 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.
{
"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 :

{
"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.

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.
  • 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é.
{
"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.

Endpoint public hors /api de redimensionnement/transcodage à la volée via league/glide.

  • Auth : optionnelle (soft) via OptionalAuthenticationMiddleware. Un Bearer token valide est honoré mais pas obligatoire — l’absence de token ne renvoie jamais 401.

  • Action : ServeMediaAction

  • 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.

  • 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).