Gérer les médias

Attacher des fichiers à un contenu par le registre média, sans dépendre du package @lrvo/module-medias.

Le gestionnaire de médias est distribué comme un module externe, @lrvo/module-medias. Le core ne dépend pas de ce package : il fournit un registre que le module remplit au runtime.

Un module tiers dépose donc des fichiers et attache des médias à son contenu sans déclarer @lrvo/module-medias en dépendance, et sans conditionner ses appels : quand le module est absent, les façades restent appelables et se comportent de façon neutre.

Pour un fichier qui n'est pas un média éditorial (une pièce jointe, un export, un PDF généré), utiliser directement core/storage. Voir Stocker des fichiers.

Installer

npm install @lrvo/module-medias
LRVO_MODULE_MEDIAS=true

Le détail de l'installation, dont la route de fichier à monter dans le projet, est dans Installer le module Médias.

npx lrvo-db init

Une médiathèque apparaît dans l'admin, et la route /lrvo/media/<filename> devient active. Sans le module, la route reste câblée mais retourne 404.

Déposer un fichier depuis du code

La médiathèque n'est pas réservée au formulaire de l'admin. uploadMedia accepte un tampon binaire, avec son nom d'origine, et rend la fiche créée. C'est la voie d'un import, d'une tâche planifiée ou d'un module qui génère un document.

import { uploadMedia } from '@lrvo/core/media-registry';

const result = await uploadMedia({ file: buffer, filename: 'rapport-2026.pdf' });
if (!result.success) return { error: result.error };

const mediaId = result.media.id;

Ne jamais appeler la route d'admin POST /lrvo/api/media en fetch depuis le serveur : elle exige une session. La façade couvre le même travail, validations comprises.

uploadedBy est facultatif. Un dépôt sans session reste sans auteur, ce qui est exact : il n'y a pas d'humain derrière. Fournir un identifiant demande un utilisateur réel, la colonne portant une clé étrangère vers la table des utilisateurs.

Le registre n'est rempli que dans le serveur Next, après le boot des modules. Un script lancé hors de ce contexte obtient le repli neutre, soit { success: false, error: 'media module not enabled' } : une tâche planifiée passe donc par une route ou une server action.

listMedia, updateMedia, replaceMedia et deleteMedia complètent le CRUD, avec les mêmes règles. Voir Registre média.

Attacher un média

Au moment d'enregistrer le contenu, côté serveur.

import { attachMedia } from '@lrvo/core/media-registry';

await attachMedia({
  mediaId: payload.featuredImageId,
  sourceType: '@lrvo/module-blog:post',
  sourceId: post.id,
  field: 'featured_image',
});

Le sourceType porte le nom du package en préfixe. C'est ce qui garantit qu'aucun module ne libère par erreur les références d'un autre.

Détacher

À la suppression du contenu hôte, libérer toutes ses références en une fois.

import { detachAllForSource } from '@lrvo/core/media-registry';

await detachAllForSource({ sourceType: '@lrvo/module-blog:post', sourceId: post.id });

Ne pas l'oublier. Une contrainte ON DELETE RESTRICT empêche de supprimer un média encore référencé : sans ce détachement, le média devient impossible à supprimer depuis la médiathèque, sans explication visible.

Sélectionner côté client

'use client';
import { useState } from 'react';
import { getMediaPicker, isMediaPickerAvailable } from '@lrvo/core/media-registry/client';

export function FeaturedImageField({ value, onChange }) {
  const [open, setOpen] = useState(false);

  if (!isMediaPickerAvailable()) return null;
  const MediaPicker = getMediaPicker();

  return (
    <>
      <button type="button" onClick={() => setOpen(true)}>Choisir une image</button>
      <MediaPicker
        isOpen={open}
        onClose={() => setOpen(false)}
        accept="image/*"
        onSelect={(media) => { onChange(media.id); setOpen(false); }}
      />
    </>
  );
}

Vérifier isMediaPickerAvailable() avant de rendre. getMediaPicker() retourne null sans le module, et rendre null comme composant lève.

Stocker l'identifiant du média, pas son URL. L'identifiant est immuable ; le filename change au renommage.

Afficher une image

Le module n'exporte aucun composant d'affichage. Le rendu se fait avec next/image, sur une URL reconstruite à partir du média.

Depuis un champ, résoudre le média puis construire son URL :

import { getMediaById, buildMediaUrl } from '@lrvo/core/media-registry';

const media = await getMediaById(post.thumbnail);
const src = buildMediaUrl(media);   // /lrvo/media/mon-image.jpg, ou null

Depuis un bloc BlockEditor, le nom de fichier est posé sur le bloc. Deux clés peuvent le porter : _resolvedFilename, écrit par enrichBlocksWithMedia côté serveur, et _mediaFilename, capté par l'éditeur au moment de l'insertion. Lire la première en priorité, comme le fait le bloc image du core :

const filename = block._resolvedFilename ?? block._mediaFilename ?? '';
const src = block.src || (filename ? `/lrvo/media/${encodeURIComponent(filename)}` : null);

Le block.src en tête couvre les images pointant une URL externe, que le bloc conserve telles quelles.

Dans les deux cas, src peut être null : média supprimé, module absent, référence orpheline. Ne pas rendre d'image plutôt que rendre une image cassée.

Tous les médias sont publics, /_next/image peut donc les optimiser sans risque de fuite.

Contenu BlockEditor

Un module qui héberge du contenu BlockEditor avec des images appelle trois helpers.

import {
  normalizeImageBlocks, enrichBlocksWithMedia, syncBlockImageReferences,
} from '@lrvo/core/media-registry/block-editor';

À l'enregistrement

const normalized = await normalizeImageBlocks(blocks);
await persistDocument(documentId, normalized);
await syncBlockImageReferences({
  sourceType: 'post',
  sourceId: documentId,
  blocks: normalized,
});

normalizeImageBlocks fait trois choses :

  • Montée de version paresseuse : si mediaId manque mais que src matche /lrvo/media/<filename>, résout le filename en média et persiste l'identifiant.
  • Remplissage arrière : si le média n'a pas encore d'alt_text ou de caption mais que le bloc en porte, remonte la valeur côté média.
  • Nettoyage strict : retire src, alt et caption du bloc dès qu'un mediaId est résolu, puisque le média devient la source de vérité.

syncBlockImageReferences synchronise les références avec field = 'block_image'.

Au rendu

const enriched = await enrichBlocksWithMedia(await loadDocument(slug));

Une seule requête groupée pour tous les mediaId du document, qui attache _resolvedAlt, _resolvedCaption et _resolvedFilename. Le composant de rendu reconstruit l'URL publique à partir du filename résolu.

Blocs dans les colonnes

Ces helpers parcourent l'arbre par flattenBlocks et mapBlocksDeep. Un code maison qui itérerait le tableau plat manquerait les images imbriquées dans des colonnes.

Quand le module est absent

AppelComportement
attachMedia, detachMedia, detachAllForSourceRien n'est écrit, et l'appel retourne { success: false, error: 'media module not enabled' }.
getMediaById, getMediaByFilename, buildMediaUrlnull.
getMediaByIdsListe vide.
getMediaPicker, getMediaDetailsModalnull.
normalizeImageBlocks, enrichBlocksWithMediaPassthrough : les blocs à mediaId restent non résolus, les instantanés _media* posés par le client suffisent à un rendu approximatif.
syncBlockImageReferences{ success: true, count: 0 }.
/lrvo/media/<filename>404.

Le code consommateur reste donc naïf : il n'a pas à tester la présence du module avant chaque appel. isMediaEnabled() sert à masquer une interface, pas à protéger un appel.

Pièges

Stocker l'identifiant, pas le filename. Un renommage dans la médiathèque se répercute alors partout, sans toucher aux contenus.

Ne pas oublier detachAllForSource à la suppression du contenu hôte.

Le sourceType doit être unique par module. Le préfixer du nom du package.

Sur cette page