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"

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
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": "",
"blurHash": "",
"latitude": 48.85, "longitude": 2.35,
"openLocationCode": "8FW4V75V+8Q",
"width": 1920, "height": 1080,
"orientation": "landscape",
"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,
"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. Bump updated_at.
  4. Réindexe le média via MediaIndexService::reindex() pour que les 4 blocs hiérarchiques (city/subregion/region/country) apparaissent immédiatement sur les listings publics.

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,
"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 ont été ré-écrits avec succès
skippedlocate(lat,lng) n’a rien matché (point hors polygones connus) — row laissée intacte, sera retentée au prochain run si geo_v2 s’enrichit
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, 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.


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 (héritage de la source — pas d’option de transcodage exposée côté caller, on optimise pour le poids du data URI).
  • Cache : partagé avec /media/{hex}.{ext} public via Glide → les appels suivants avec la même MEDIA_ADMIN_BASE64_MAX_SIZE sont servis depuis disque (sub-100 ms typique).

Path params

  • hex : id du media en 32 hex lowercase.

Réponse (200)

{
"status": "ok",
"mediaId": "01a3471992e44c60a8f08321f713635a",
"maxSize": 800,
"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
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)
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" \
| jq -r .image \
| head -c 80
# data:image/webp;base64,UklGRmgoAQBXRUJQVlA4WAo...

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.

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"