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}
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 |
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": "…", "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" } ] }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). - Bump
updated_at. - 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
| 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, "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 ont été ré-écrits avec succès |
skipped | locate(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 |
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, 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.
PUT /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 (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êmeMEDIA_ADMIN_BASE64_MAX_SIZEsont 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..."}| 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 |
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) |
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" \ | 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.
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.
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"