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.phpqui 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).
GET /api/media/hashtags/autocomplete
Section titled “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.
- Query :
q(obligatoire) : préfixe (1..MEDIA_HASHTAG_MAX_LENchars). Normalisé avant la requête — l’utilisateur peut taper#Sunset!. Unqvide déclenche422 Missing query; unqqui dégénère en slug vide après normalisation (uniquement de la ponctuation) renvoie une collection vide plutôt que422(UI silencieuse pendant la frappe).limit(optionnel, 1..MEDIA_HASHTAG_AUTOCOMPLETE_MAX_LIMIT, défaut 10).
- Réponse
200 OK: collection JSON:APImedia-hashtags.meta.prefixcontient 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:qabsent ou trim vide.503 Search backend unavailable: Meilisearch KO.
GET /api/media/hashtags/trending
Section titled “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.
- 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:APImedia-hashtags.meta.georeflè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, oudistance <= 0.503 Search backend unavailable: Meilisearch KO.
GET /api/media/hashtags/related
Section titled “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.
- Query :
hashtag(obligatoire) : slug d’ancrage. Passé parHashtagNormalizer.limit(optionnel, 1..MEDIA_HASHTAG_RELATED_MAX_LIMIT, défaut 10).
- Réponse
200 OK: collection JSON:APImedia-hashtags.meta.anchorcontient 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:hashtagabsent 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}
Section titled “GET /media/{hex}.{ext}”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édiaext: 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_KEYest défini, le params=<md5>est obligatoire (SignatureFactoryGlide standard, signature calculée sur"<hex>.<ext>" + params triés alphabétiquement). En dev,MEDIA_SIGN_KEYvide → 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-Typecohérent avecext,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_iddoit correspondre au porteur du token). Tout autre cas (token absent, autre utilisateur, média inexistant) →404indifférencié, pour ne jamais confirmer l’existence d’un upload privé.
- Média publié (
-
Erreurs :
400:hexouextinvalide, ou paramètres Glide aberrants.403: signature manquante / invalide (uniquement siMEDIA_SIGN_KEYest 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).