Médias
GET /admin/media/stats
Section titled “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)
{ "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_atetcountry_idsont désormais indexés (migration2026_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, voirGET /admin/stats/trends.
Exemple
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \ "http://hydrogen.dev.com/admin/media/stats?days=90"
GET /admin/media/statsest 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), voirGET /admin/media/stats/countriesci-dessous.
GET /admin/media/stats/countries
Section titled “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é) :
?country=FR,US— liste CSV explicite de codes ISO 3166-1 alpha-2. Un code mal formé → 400.- Aucun → les top
?limitpays 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)
{ "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(migration2026_06_21_140000_create_media_country_daily.sql), alimentée par le même worker quotidien queGET /admin/stats/trends. La série pour un pays jamais vu sur la fenêtre est entièrement à0.
Exemple
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
Section titled “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)
{ "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). Lestotals, eux, restent exacts.views/impressions/commentsne sont pas concernés.
Source : table
hxa_bo.media_engagement_daily(migration2026_07_04_120000_create_media_engagement_daily.sql), alimentée parbin/media-engagement-rollup.php(cron quotidien). Le worker refold une fenêtre glissante (MEDIA_ENGAGEMENT_ROLLUP_LOOKBACK_DAYS, def 7) et purge au-delà deMEDIA_ENGAGEMENT_RETENTION_DAYS(def 366, ~1 an) pour garder la table bornée. À lancer aprèsmedia-counters-flush(qui pose les views/impressions du jour).
Exemple
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \ "http://hydrogen.dev.com/admin/media/d26d1600cde54bd095e09f8b68ace05f/trends?days=90" | jqGET /admin/media/recent
Section titled “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)
{ "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
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \ "http://hydrogen.dev.com/admin/media/recent?limit=40"GET /admin/media/{hex}
Section titled “GET /admin/media/{hex}”Fiche 360° d’un media unique en JSON:API 1.1 (cf. Convention de format) : 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 |
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
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \ -H "Accept: application/vnd.api+json" \ "http://hydrogen.dev.com/admin/media/4f3c1a2b5d6e7f8091a2b3c4d5e6f700"{ "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", "describeQueue": { "inQueue": false } } }}// 404 — media inexistant (erreur JSON:API){ "jsonapi": { "version": "1.1" }, "errors": [ { "status": "404", "title": "Media not found" } ] }PUT /admin/media/{hex}
Section titled “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)
{ "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 sihashtagsa changé — le set accepté (post-normalisation/blocklist/cap), dans l’ordre persisté.
Exemple curl
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
Section titled “POST /admin/media/{hex}/reindex”Re-pousse un media unique dans Meilisearch, en relisant la DB (media + description + stats + hashtags) via MediaIndexService::reindex().
À 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 (formatmedia.idBINARY(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
curl -X POST \ -H "Authorization: Bearer $ADMIN_API_TOKEN" \ "http://hydrogen.dev.com/admin/media/d26d1600cde54bd095e09f8b68ace05f/reindex"POST /admin/media/reindex-all
Section titled “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)
{ "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)
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" ] && breakdoneCô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
Section titled “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 :
- Appelle la procédure stockée
geo.locate(latitude, longitude)via GeoLookupService. - Écrase les 4 colonnes avec ce que
locaterenvoie (peut inclure desNULLpartiels — toujours cohérent avec la résolution la plus fraîche). - Depuis les mêmes coordonnées, résout le biome WWF via
geo_v2.get_biome(BiomeLookupService) et écritbiome_iduniquement si le point matche un polygone. Un miss ne remet jamaisbiome_idàNULL(la maintenance biome-seule reste du ressort dePOST /admin/media/backfill-biome). - Bump
updated_at. - Réindexe le média une seule fois (si quelque chose a changé) via MediaIndexService::reindex() 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) etbiomeUpdatedsont donc comptés indépendamment. Une ligne peut êtreskipped(aucun id admin matché) tout en ayant sonbiome_idrenseigné.
Pagination keyset sur la PK BINARY(16), même pattern que reindex-all. Boucle Talend / Postman jusqu’à done = true.
Sélection des candidats (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)
{ "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)
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" ] && breakdoneRemarque :
skippedreste positif tant quegeo_v2n’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
Section titled “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 :
- Appelle la procédure stockée
geo_v2.get_biome(latitude, longitude)via BiomeLookupService. - Écrit le biome WWF retourné (
1..15) viaupdateBiome()+ bumpupdated_at. - Réindexe le média via MediaIndexService::reindex() pour que le facet
biome_idapparaisse 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)
WHERE latitude IS NOT NULL AND longitude IS NOT NULL AND biome_id IS NULLQuery params
| Param | Type | Défaut | Min | Max |
|---|---|---|---|---|
cursor | hex (32 chars) | null (début) | — | — |
batchSize | int | 200 | 1 | 1000 |
Réponse (200)
{ "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)
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" ] && breakdone⚙️ Ops : le facet
biome_iddoit être déclaréfilterabledans l’index Meili — lancerbin/media-meili-apply-settings.phpune fois avant d’exploiter/media/nearby?biome=….
GET /admin/media/without-geo
Section titled “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 — 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)
WHERE latitude IS NULL OR longitude IS NULLQuery params
| Param | Type | Défaut | Min | Max |
|---|---|---|---|---|
cursor | hex (32 chars) | null (début) | — | — |
batchSize | int | 200 | 1 | 1000 |
Réponse (200)
{ "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
Section titled “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 :
UPDATE media SET latitude = ?, longitude = ?, updated_at = NOW().CALL locate(lat, lng)via GeoLookupService pour dériver les 4 ids administratifs, puis les écrit viaupdateGeoIds(). Un miss (point hors polygones connus) met les 4 ids àNULL— la ligne garde ses coordonnées et pourra être re-jouée quandgeo_v2couvrira la zone. 2b.CALL get_biome(lat, lng)via BiomeLookupService pour dériver le biome WWF (1..15) depuis les mêmes coordonnées, persisté viaupdateBiome().NULLquand le point ne matche aucun polygone de biome (océan / zone non cartographiée). Indépendant de la cascade administrative (procéduregeo_v2distincte).MediaIndexService::reindex()(best-effort) — pousse le nouveau point_geo+ les 4 blocs hiérarchiques + le facetbiome_idvers Meili pour qu’ils apparaissent immédiatement sur/media/nearbyet/media/in-bounds.
Body
{ "latitude": 48.8566, "longitude": 2.3522 }| Champ | Type | Contrainte |
|---|---|---|
latitude | number | -90 .. 90 |
longitude | number | -180 .. 180 |
Réponse (200)
{ "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
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 | jqPUT /admin/media/{hex}/published
Section titled “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)
{ "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
# 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/publishedNotes
- Le compteur
notificationsFailedregroupe 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
nonene 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
Section titled “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(default800),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(aliasjpeg). 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)
{ "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
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.
POST /admin/media/describe
Section titled “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)
{ "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)
{ "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
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
flagest indexé dans Meili (filterableAttributes) au même titre queis_rejected→ après le premier déploiement, relancerbin/media-meili-apply-settings.phppour que les nouveaux attributs filtrables soient acceptés par l’index.
POST /admin/media/{hex}/describe-ai
Section titled “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 workerbin/describe_worker.pydans 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)
{ "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é :
{ "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
# 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èlecurl -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_URLchange entre dev (http://localhost:1234) et prod — c’est le seul réglage à basculer pour repointer le serveur IA.
POST /admin/media/{hex}/enrichment
Section titled “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)
{ "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)
{ "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)
Section titled “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 | failedprocessing → published | rejected | failedfailed → 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 depuisis_published/is_rejected+ indexidx_media_status). AucunALTERde colonne —statusexistait déjà.
POST /admin/media/{hex}/claim
Section titled “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)
{ "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 |
curl -s -X POST -H "Authorization: Bearer $ADMIN_API_TOKEN" \ "http://hydrogen.dev.com/admin/media/d26d1600cde54bd095e09f8b68ace05f/claim"POST /admin/media/{hex}/fail
Section titled “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)
{ "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 |
curl -s -X POST -H "Authorization: Bearer $ADMIN_API_TOKEN" \ "http://hydrogen.dev.com/admin/media/d26d1600cde54bd095e09f8b68ace05f/fail"POST /admin/media/{hex}/recompute-stats
Section titled “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←COUNTsurmedia_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)
{ "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
curl -s -X POST -H "Authorization: Bearer $ADMIN_API_TOKEN" \ "http://hydrogen.dev.com/admin/media/01a3471992e44c60a8f08321f713635a/recompute-stats"PUT /admin/media/{hex}/flag
Section titled “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←rejectedsi rejeté, sinonpublished.
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)
{ "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 |
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}
Section titled “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)
{ "status": "deleted", "mediaId": "d26d1600cde54bd095e09f8b68ace05f" }Erreurs
| Status | Body | Sens |
|---|---|---|
404 | { "error": "Media not found." } | hex inconnu / mal formé |
403 | { "error": "..." } | auth KO |
curl -s -X DELETE -H "Authorization: Bearer $ADMIN_API_TOKEN" \ "http://hydrogen.dev.com/admin/media/d26d1600cde54bd095e09f8b68ace05f"GET /admin/media/{hex}/comments
Section titled “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)
{ "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.
# Première pagecurl -s "$BASE/admin/media/9f8e7d6c5b4a39281706f5e4d3c2b1a0/comments?limit=50" -H "$AUTH"# Page suivantecurl -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: [](pas404). Le404ne 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
Section titled “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)
{ "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.
# Toutes les réactions, première pagecurl -s "$BASE/admin/media/9f8e7d6c5b4a39281706f5e4d3c2b1a0/reactions?limit=50" -H "$AUTH"# Uniquement les dislikescurl -s "$BASE/admin/media/9f8e.../reactions?value=dislike" -H "$AUTH"# Page suivantecurl -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 dansmedia_stats. Comme pour les commentaires, un média sans réaction renvoieitems: [](pas404).