Skip to content

Médias

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ètreDéfautSens
days30longueur 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
}
ChampSens
total / published / rejected / pendingmêmes définitions que la section media de GET /admin/stats (pending = ni publié ni rejeté).
perDay.seriesuploads 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.totalsomme des uploads sur la fenêtre.
topCountries5 premiers pays par nombre de médias (code ISO 3166-1 alpha-2 + name), ordre décroissant.
byCountrytous les pays avec leur nombre de médias, ordre décroissant (topCountries en est la tête).
country / namecode 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).
withoutCountrymédias sans pays (country_id NULL, upload sans GPS) — exclus des buckets pays.

Les axes created_at et country_id sont désormais indexés (migration 2026_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, voir GET /admin/stats/trends.

Exemple

Terminal window
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
"http://hydrogen.dev.com/admin/media/stats?days=90"

GET /admin/media/stats est 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), voir GET /admin/media/stats/countries ci-dessous.


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

  1. ?country=FR,US — liste CSV explicite de codes ISO 3166-1 alpha-2. Un code mal formé → 400.
  2. Aucun → les top ?limit pays par uploads sur la fenêtre.

Paramètres

ParamètreDéfautSens
days30longueur de la fenêtre en jours, bornée 1..366.
limit10nombre de pays quand ?country est absent, borné 1..50 (ignoré si ?country est fourni).
countryCSV 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 }
]
}
]
}
ChampSens
countries[].seriesuploads du pays par jour, zero-fillé sur toute la fenêtre (courbe continue).
countries[].totalsomme des uploads du pays sur la fenêtre.
countries[].namenom 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 (migration 2026_06_21_140000_create_media_country_daily.sql), alimentée par le même worker quotidien que GET /admin/stats/trends. La série pour un pays jamais vu sur la fenêtre est entièrement à 0.

Exemple

Terminal window
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
"http://hydrogen.dev.com/admin/media/stats/countries?country=FR,US,ES&days=90"

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ètreEmplacementDéfautSens
hexpathid du média, 32 hex minuscules (sans tirets).
daysquery30longueur 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 }
]
}
ChampSens
totalscompteurs cumulés à vie (source de vérité hxa.media_stats) — toujours exacts.
seriesengagement par jour, zero-fillé sur toute la fenêtre (courbe continue).

Erreurs

StatusBody
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). Les totals, eux, restent exacts. views/impressions/comments ne sont pas concernés.

Source : table hxa_bo.media_engagement_daily (migration 2026_07_04_120000_create_media_engagement_daily.sql), alimentée par bin/media-engagement-rollup.php (cron quotidien). Le worker refold une fenêtre glissante (MEDIA_ENGAGEMENT_ROLLUP_LOOKBACK_DAYS, def 7) et purge au-delà de MEDIA_ENGAGEMENT_RETENTION_DAYS (def 366, ~1 an) pour garder la table bornée. À lancer après media-counters-flush (qui pose les views/impressions du jour).

Exemple

Terminal window
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
"http://hydrogen.dev.com/admin/media/d26d1600cde54bd095e09f8b68ace05f/trends?days=90" | jq

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ètreEmplacementDéfautDescription
limitquery24nombre 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

Terminal window
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
"http://hydrogen.dev.com/admin/media/recent?limit=40"

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ètreEmplacementDescription
hexpathid 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éBaseTable / sourceType en cas d’absence
userIdhxamedia.user_id (hex à plat — le public expose author.id)
countryId / regionId / subregionIdhxaids géo brutsnull
biomeIdhxamedia.biome_id (biome WWF 1..15, scalaire brut — le public expose le bloc biome {id, name})null
flagshxadécomposition lisible de media.flag (bitmask)[]
impressionsCounthxamedia_stats.impressions0
exifhxa_bomedia_exif (JSON EXIF brut décodé)null
fileMetahxa_bomedia_meta (mime/taille/dimensions source/marque/modèle)null
perceptualHashhxa_bomedia_perceptual_hash (16 hex réassemblés depuis les 4 shards)null
describeQueueworkmedia_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

Terminal window
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" } ] }

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

ChampTypeNotes
namestring ≤255 | nullNom de fichier d’origine. null / "" efface.
shotAtISO-8601 datetimeDate de prise de vue (parsée par Carbon).
titlestring ≤250 | nullTitre humain (media_description). null / "" efface.
metaTitlestring ≤255 | nullSEO. null / "" efface.
metaDescriptionstring ≤500 | nullSEO. null / "" efface.
descriptionstring ≤MEDIA_DESCRIPTION_MAX_LENGTH (1024)Texte libre (NOT NULL, "" autorisé).
hashtagslist<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 si hashtags a changé — le set accepté (post-normalisation/blocklist/cap), dans l’ordre persisté.

Exemple curl

Terminal window
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

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

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 (format media.id BINARY(16) → hex lowercase).

Réponses

StatusBodySens
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

Terminal window
curl -X POST \
-H "Authorization: Bearer $ADMIN_API_TOKEN" \
"http://hydrogen.dev.com/admin/media/d26d1600cde54bd095e09f8b68ace05f/reindex"

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

ParamTypeDéfautMinMax
cursorhex (32 chars)null (début)
batchSizeint20011000

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
}
ChampSens
processedmédias indexés avec succès dans ce batch
removedrows manquants en DB (déjà supprimés) dont le doc Meili stale a été purgé
failedliste des erreurs par-média — n’interrompt pas le batch
lastIddernier id parcouru dans le batch (null si batch vide)
nextCursorà passer en ?cursor= au prochain appel ; null quand done=true
donetrue quand le batch a renvoyé moins de rows que demandé → fin du backfill
totalAllCOUNT(*) media au moment de l’appel — pour reporter une progression côté caller
durationMslatence serveur du batch

Erreurs

StatusBody
400{ "error": "Invalid cursor." }
400{ "error": "Invalid batchSize." }
403{ "error": "..." }

Pattern d’utilisation (Talend / curl boucle)

Terminal window
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" ] && break
done

Côté Talend : un tLoop sur l’appel HTTP, condition de sortie done == true, variable de contexte cursor mise à jour entre itérations.


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 :

  1. Appelle la procédure stockée geo.locate(latitude, longitude) via GeoLookupService.
  2. Écrase les 4 colonnes avec ce que locate renvoie (peut inclure des NULL partiels — toujours cohérent avec la résolution la plus fraîche).
  3. Depuis les mêmes coordonnées, résout le biome WWF via geo_v2.get_biome (BiomeLookupService) et écrit biome_id uniquement si le point matche un polygone. Un miss ne remet jamais biome_id à NULL (la maintenance biome-seule reste du ressort de POST /admin/media/backfill-biome).
  4. Bump updated_at.
  5. 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) et biomeUpdated sont donc comptés indépendamment. Une ligne peut être skipped (aucun id admin matché) tout en ayant son biome_id renseigné.

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

ParamTypeDéfautMinMax
cursorhex (32 chars)null (début)
batchSizeint20011000

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
}
ChampSens
processednombre de rows parcourus dans ce batch
updatedrows dont les 4 ids administratifs ont été ré-écrits avec succès
skippedgeo.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
biomeUpdatedrows dont biome_id a été (ré)écrit (geo_v2.get_biome a matché) — indépendant de updated/skipped
failederreurs par-média (UPDATE / reindex) — n’interrompent pas le batch
lastIddernier id parcouru dans le batch (null si batch vide)
nextCursorà passer en ?cursor= au prochain appel ; null quand done=true
donetrue quand le batch a renvoyé moins de rows que demandé → fin du backfill
totalCandidatessnapshot COUNT(*) des rows encore éligibles au moment de l’appel — décroît au fil de la progression
durationMslatence serveur du batch (inclut les appels Meili)

Erreurs

StatusBody
400{ "error": "Invalid cursor." }
400{ "error": "Invalid batchSize." }
403{ "error": "..." }

Pattern d’utilisation (curl boucle)

Terminal window
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" ] && break
done

Remarque : skipped reste positif tant que geo_v2 n’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.


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 :

  1. Appelle la procédure stockée geo_v2.get_biome(latitude, longitude) via BiomeLookupService.
  2. Écrit le biome WWF retourné (1..15) via updateBiome() + bump updated_at.
  3. Réindexe le média via MediaIndexService::reindex() pour que le facet biome_id apparaisse 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 NULL

Query params

ParamTypeDéfautMinMax
cursorhex (32 chars)null (début)
batchSizeint20011000

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
}
ChampSens
processednombre de rows parcourus dans ce batch
updatedrows dont le biome a été écrit avec succès
skippedget_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
failederreurs par-média (UPDATE / reindex) — n’interrompent pas le batch
lastIddernier id parcouru dans le batch (null si batch vide)
nextCursorà passer en ?cursor= au prochain appel ; null quand done=true
donetrue quand le batch a renvoyé moins de rows que demandé → fin du backfill
totalCandidatessnapshot COUNT(*) des rows encore éligibles au moment de l’appel
durationMslatence serveur du batch (inclut les appels Meili)

Erreurs

StatusBody
400{ "error": "Invalid cursor." }
400{ "error": "Invalid batchSize." }
403{ "error": "..." }

Pattern d’utilisation (curl boucle)

Terminal window
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" ] && break
done

⚙️ Ops : le facet biome_id doit être déclaré filterable dans l’index Meili — lancer bin/media-meili-apply-settings.php une fois avant d’exploiter /media/nearby?biome=….


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 NULL

Query params

ParamTypeDéfautMinMax
cursorhex (32 chars)null (début)
batchSizeint20011000

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
}
ChampSens
itemsbatch de médias sans GPS, projection légère (cf. ci-dessus)
lastIddernier id parcouru dans le batch (null si batch vide)
nextCursorà passer en ?cursor= au prochain appel ; null quand done=true
donetrue quand le batch a renvoyé moins de rows que demandé → fin
totalsnapshot COUNT(*) des médias sans coordonnées au moment de l’appel
durationMslatence serveur du batch

Erreurs

StatusBody
400{ "error": "Invalid cursor." }
400{ "error": "Invalid batchSize." }
403{ "error": "..." }

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 :

  1. UPDATE media SET latitude = ?, longitude = ?, updated_at = NOW().
  2. CALL locate(lat, lng) via GeoLookupService pour dériver les 4 ids administratifs, puis les écrit via updateGeoIds(). Un miss (point hors polygones connus) met les 4 ids à NULL — la ligne garde ses coordonnées et pourra être re-jouée quand geo_v2 couvrira 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é via updateBiome(). NULL quand le point ne matche aucun polygone de biome (océan / zone non cartographiée). Indépendant de la cascade administrative (procédure geo_v2 distincte).
  3. MediaIndexService::reindex() (best-effort) — pousse le nouveau point _geo + les 4 blocs hiérarchiques + le facet biome_id vers Meili pour qu’ils apparaissent immédiatement sur /media/nearby et /media/in-bounds.

Body

{ "latitude": 48.8566, "longitude": 2.3522 }
ChampTypeContrainte
latitudenumber-90 .. 90
longitudenumber-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
}
ChampSens
geoResolvedfalse si locate() n’a matché aucun polygone (les 4 ids sont alors null)
cityIdUUID dashé de la ville (geo.city est keyé UUID) ou null
subregionId / regionIdISO 3166-2 ou null
countryIdISO 3166-1 alpha-2 ou null
biomeIdbiome WWF 1..15 (indépendant de geoResolved) ou null si le point ne matche aucun polygone de biome

Erreurs

StatusBody
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

Terminal window
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 | jq

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

TransitionUPDATE mediaDELETE work.media_to_describeNotif followersReindex Meili
none (déjà à l’état demandé)nonnonnon (jamais de fake “X a publié” sur un republish toggle)non
publish (0 → 1)ouiouioui (media.published à tous les followers du créateur)oui
unpublish (1 → 0)ouinon (la description reste, pas un retour en arrière du pipeline)nonoui

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

StatusBodySens
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

Terminal window
# 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/published

Notes

  • Le compteur notificationsFailed regroupe 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 none ne 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.

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 (default 800), 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 (alias jpeg). 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..."
}
ChampSens
mediaIdecho du hex demandé
maxSizevaleur 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
formatformat effectivement encodé (webp ou jpeg) — echo du ?format= normalisé
imagedata URI complet (data:<mime>;base64,<…>) directement utilisable dans <img src=…> ou un attribut JSON tiers

Erreurs

StatusBodySens
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

Terminal window
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.

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 }
]
}
ChampSens / destination
idUUID dashé du média (pas le hex 32). 404 si la row n’existe pas.
flagMasque 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.
titlemedia_description.title (nullable).
meta_titlemedia_description.meta_title (nullable).
meta_descriptionmedia_description.meta_description (nullable).
descriptionmedia_description.description (chaîne ; "" accepté).
focusListe 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.
objectsListe {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
}
ChampSens
mediaIdhex 32 du média enrichi
flagecho du masque appliqué
isRejecteddé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
focusMatchednoms de focus résolus en ids (liés)
focusUnknownnoms de focus absents de la table focus (ignorés)
objectsStorednombre d’objets écrits dans media_object

Erreurs

StatusBodySens
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

Terminal window
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

  • flag est indexé dans Meili (filterableAttributes) au même titre que is_rejected → après le premier déploiement, relancer bin/media-meili-apply-settings.php pour que les nouveaux attributs filtrables soient acceptés par l’index.

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 worker bin/describe_worker.py dans 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

ParamDéfautRôle
modepreviewpreview = renvoie la proposition sans rien écrire (dry-run) ; save = applique en base (transaction hxa unique) + réindex Meili best-effort.
modelAI_DESCRIBE_MODEL (mistralai/ministral-3-3b)Override du modèle pour un appel.

Mapping IA → domaine

Champ IADestination
title / description / meta_title / meta_descriptionmedia_description (upsert).
themes (liste de slugs EN)résolus en focus.namemedia_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_countmedia.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/publishedlogique 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

StatusBodySens
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

Terminal window
# 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èle
curl -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_URL change entre dev (http://localhost:1234) et prod — c’est le seul réglage à basculer pour repointer le serveur IA.

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
}
ChampDestination
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_countmedia.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).


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.

statusintPosé parSens
pending0uploadfichier stocké + mis en file work.media_to_describe, en attente du worker IA
processing1POST /admin/media/{hex}/claimle worker a pris le média et l’analyse
published2POST /admin/media/describe (verdict propre)terminal succès, mis en ligne
rejected3POST /admin/media/describe (flag rejetant)terminal refus de modération
failed4POST /admin/media/{hex}/faille worker a abandonné (erreur/timeout), retryable

Transitions autorisées (gardées par MediaStatus::canTransitionTo(), sinon 409) :

pending → processing | published | rejected | failed
processing → published | rejected | failed
failed → 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 depuis is_published/is_rejected + index idx_media_status). Aucun ALTER de colonne — status existait déjà.


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 paramshex : id du média en 32 hex lowercase.

Réponse (200)

{ "status": "ok", "mediaId": "<hex>", "state": "processing", "transition": "claim" }
StatusBodySens
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
Terminal window
curl -s -X POST -H "Authorization: Bearer $ADMIN_API_TOKEN" \
"http://hydrogen.dev.com/admin/media/d26d1600cde54bd095e09f8b68ace05f/claim"

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 paramshex : id du média en 32 hex lowercase.

Réponse (200)

{ "status": "ok", "mediaId": "<hex>", "state": "failed", "transition": "fail" }
StatusBodySens
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
Terminal window
curl -s -X POST -H "Authorization: Bearer $ADMIN_API_TOKEN" \
"http://hydrogen.dev.com/admin/media/d26d1600cde54bd095e09f8b68ace05f/fail"

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_countCOUNT sur media_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
}
ChampSens
before / aftersnapshot des 5 compteurs avant / après recalcul (views/impressions reportés à l’identique)
changedtrue si l’un des 3 compteurs dérivables a bougé (réparation effective)

Erreurs

StatusBodySens
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

Terminal window
curl -s -X POST -H "Authorization: Bearer $ADMIN_API_TOKEN" \
"http://hydrogen.dev.com/admin/media/01a3471992e44c60a8f08321f713635a/recompute-stats"

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,
  • statusrejected si rejeté, sinon published.

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 paramshex : id du média en 32 hex lowercase.

Body

ChampTypeRequisSens
flagint ≥ 0ouinouveau bitmask de modération (0 = valide)

Réponse (200)

{
"status": "ok",
"mediaId": "d26d1600cde54bd095e09f8b68ace05f",
"flag": 4,
"isRejected": true,
"isPublished": false,
"mediaStatus": "rejected"
}

Erreurs

StatusBodySens
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
Terminal window
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"

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 paramshex : id du média en 32 hex lowercase.

Réponse (200)

{ "status": "deleted", "mediaId": "d26d1600cde54bd095e09f8b68ace05f" }

Erreurs

StatusBodySens
404{ "error": "Media not found." }hex inconnu / mal formé
403{ "error": "..." }auth KO
Terminal window
curl -s -X DELETE -H "Authorization: Bearer $ADMIN_API_TOKEN" \
"http://hydrogen.dev.com/admin/media/d26d1600cde54bd095e09f8b68ace05f"

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)

ParamDéfautSens
cursorAtISO-8601, created_at de la dernière ligne de la page
cursorIdhex 32, id de cette même ligne (tiebreaker)
limit50borné 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.

Terminal window
# Première page
curl -s "$BASE/admin/media/9f8e7d6c5b4a39281706f5e4d3c2b1a0/comments?limit=50" -H "$AUTH"
# Page suivante
curl -s "$BASE/admin/media/9f8e.../comments?cursorAt=2026-06-20T14:03:00%2B00:00&cursorId=0a1b..." -H "$AUTH"

Erreurs

StatusBodySens
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: [] (pas 404). Le 404 ne 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).


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)

ParamDéfautSens
valuelike | dislike — restreint à un seul type
cursorAtISO-8601, created_at de la dernière ligne de la page
cursorIdhex 32, user_id de cette même ligne (tiebreaker)
limit50borné 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.

Terminal window
# Toutes les réactions, première page
curl -s "$BASE/admin/media/9f8e7d6c5b4a39281706f5e4d3c2b1a0/reactions?limit=50" -H "$AUTH"
# Uniquement les dislikes
curl -s "$BASE/admin/media/9f8e.../reactions?value=dislike" -H "$AUTH"
# Page suivante
curl -s "$BASE/admin/media/9f8e.../reactions?cursorAt=2026-06-20T14:03:00%2B00:00&cursorId=1122..." -H "$AUTH"

Erreurs

StatusBodySens
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 dans media_stats. Comme pour les commentaires, un média sans réaction renvoie items: [] (pas 404).