Le WordPress d'aujourd'hui, décodé pour les développeurs

Headless & API

Des requêtes HTTP Range pour diffuser une vidéo de médiathèque en headless

Permettre à un visiteur d'avancer dans une vidéo sans tout retélécharger suppose qu'un endpoint réponde correctement aux en-têtes Range. Construction pas à pas.

Par WordPress Développement • 30 septembre 2026 • 8 min de lecture • Aucun commentaire
Des requêtes HTTP Range pour diffuser une vidéo de médiathèque en headless
Range: bytes=1048576-2097151

Cet en-tête, envoyé automatiquement par le lecteur vidéo du navigateur dès qu’un visiteur déplace le curseur de lecture, demande précisément un intervalle d’octets du fichier plutôt que son intégralité. Sur un projet headless de médiathèque, où les fichiers vidéo sont stockés en tant que médias WordPress et exposés via une route personnalisée au front, ignorer cet en-tête revient à obliger le lecteur à retélécharger l’intégralité de la vidéo à chaque déplacement du curseur.

Ce comportement, invisible tant que les vidéos restent courtes, devient rapidement problématique sur des contenus de plusieurs dizaines de minutes : chaque avance rapide déclenche un nouveau téléchargement complet, saturant la bande passante et dégradant nettement l’expérience de lecture.

Ce que le protocole HTTP prévoit déjà

L'essentiel à retenir : Le client HTTP envoie un en-tête Range pour demander une portion de fichier ; Une réponse 206 Partial Content doit préciser exactement l'intervalle servi ; Sans ce mécanisme, l'avance rapide recharge inutilement tout le fichier

La prise en charge des requêtes partielles fait partie du protocole HTTP depuis longtemps, bien avant l’apparition du développement headless : un serveur qui la supporte doit annoncer Accept-Ranges: bytes dans ses réponses, puis répondre avec un code 206 Partial Content et un en-tête Content-Range précis lorsqu’une requête contient un en-tête Range. La plupart des serveurs web statiques gèrent ce mécanisme nativement pour un fichier servi directement, mais un endpoint REST personnalisé qui lit le fichier avant de le retourner ne le fait pas automatiquement.

Construire l’endpoint qui répond correctement

Un endpoint qui sert un média protégé, par exemple réservé à des utilisateurs authentifiés, doit implémenter ce comportement explicitement plutôt que de compter sur le serveur web seul :

add_action( 'rest_api_init', function () {
	register_rest_route( 'mediatheque/v1', '/videos/(?P<id>\d+)', array(
		'methods'             => WP_REST_Server::READABLE,
		'callback'            => 'mediatheque_servir_video',
		'permission_callback' => 'mediatheque_verifier_jeton',
		'args'                => array(
			'id' => array( 'type' => 'integer', 'required' => true ),
		),
	) );
} );

function mediatheque_servir_video( WP_REST_Request $requete ) {
	$id     = (int) $requete['id'];
	$chemin = get_attached_file( $id );
	$type   = get_post_mime_type( $id );

	if ( ! $chemin || ! is_readable( $chemin ) || 0 !== strpos( (string) $type, 'video/' ) ) {
		return new WP_Error( 'video_introuvable', __( 'Vidéo introuvable.', 'mediatheque' ), array( 'status' => 404 ) );
	}

	$taille = filesize( $chemin );
	$debut  = 0;
	$fin    = $taille - 1;
	$statut = 200;
	$entete = (string) $requete->get_header( 'range' );

	if ( '' !== $entete && preg_match( '/^bytes=(\d*)-(\d*)$/', trim( $entete ), $m ) && ( '' !== $m[1] || '' !== $m[2] ) ) {
		if ( '' === $m[1] ) {
			// Forme « bytes=-500 » : les 500 derniers octets.
			$debut = max( 0, $taille - (int) $m[2] );
		} else {
			$debut = (int) $m[1];
			if ( '' !== $m[2] ) {
				$fin = min( (int) $m[2], $taille - 1 );
			}
		}

		if ( $debut >= $taille || $debut > $fin ) {
			$refus = new WP_REST_Response( null, 416 );
			$refus->header( 'Content-Range', 'bytes */' . $taille );
			return $refus;
		}
		$statut = 206;
	}

	$reponse = new WP_REST_Response( array( 'flux' => array( $chemin, $debut, $fin ) ), $statut );
	$reponse->header( 'Content-Type', $type );
	$reponse->header( 'Accept-Ranges', 'bytes' );
	$reponse->header( 'Content-Length', (string) ( $fin - $debut + 1 ) );
	if ( 206 === $statut ) {
		$reponse->header( 'Content-Range', sprintf( 'bytes %d-%d/%d', $debut, $fin, $taille ) );
	}

	return $reponse;
}

Le callback ne lit pas le fichier : il calcule l’intervalle à servir, choisit le code de réponse et prépare les en-têtes. Trois cas sont traités. Sans en-tête Range, la vidéo est servie en entier avec le code 200. Avec une plage valide, le code est 206 et Content-Range indique exactement l’intervalle servi et la taille totale, sous la forme bytes 1048576-2097151/73400320. Avec une plage qui dépasse le fichier, la réponse est 416 (Range Not Satisfiable) accompagnée de Content-Range: bytes */taille. Les plages multiples (bytes=0-1,5-6) ne sont pas reconnues par l’expression régulière : le serveur a le droit d’ignorer un en-tête Range qu’il ne gère pas, et répond alors par le fichier entier.

Envoyer les octets : le filtre rest_pre_serve_request

Une route REST sérialise normalement sa réponse en JSON. Pour envoyer des octets bruts, il faut intercepter l’étape finale. Le cœur applique le filtre rest_pre_serve_request après avoir émis le code de statut et les en-têtes de la réponse : si le filtre retourne true, WordPress considère la réponse servie et n’écrit plus rien. C’est l’endroit où diffuser le fichier, par lots, sans le charger en mémoire :

add_filter( 'rest_pre_serve_request', 'mediatheque_diffuser_flux', 10, 3 );

function mediatheque_diffuser_flux( $servi, $resultat, $requete ) {
	$donnees = $resultat->get_data();
	if ( ! is_array( $donnees ) || ! isset( $donnees['flux'] ) ) {
		return $servi;
	}

	list( $chemin, $debut, $fin ) = $donnees['flux'];

	if ( 'HEAD' === $requete->get_method() ) {
		return true; // en-têtes seuls, pas de corps
	}

	$fichier = fopen( $chemin, 'rb' );
	if ( false === $fichier ) {
		return true;
	}

	fseek( $fichier, $debut );
	$restant = $fin - $debut + 1;

	while ( $restant > 0 && ! feof( $fichier ) ) {
		$lot = fread( $fichier, min( 65536, $restant ) );
		if ( false === $lot || '' === $lot ) {
			break;
		}
		echo $lot; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
		$restant -= strlen( $lot );
		flush();
	}

	fclose( $fichier );

	return true;
}

Le filtre ne touche que les réponses qui portent la clé flux : les autres routes de l’API ne sont pas affectées. La requête HEAD, que les lecteurs émettent parfois pour connaître la taille, est dirigée vers le callback de la méthode GET dans les versions récentes de WordPress et reçoit les mêmes en-têtes, sans corps. La lecture par blocs de 64 kio garde la consommation de mémoire constante, quelle que soit la taille de la vidéo.

Un jeton signé pour la balise video

Le permission_callback du code précédent pose un vrai problème pour une médiathèque protégée : une balise video ne peut pas envoyer d’en-tête personnalisé. Elle n’enverra ni le nonce X-WP-Nonce ni un jeton d’API, et un cookie de session sans nonce est ignoré par l’API REST, qui traite alors la requête comme anonyme. La solution classique est une adresse signée et de courte durée, produite par le serveur pour un utilisateur autorisé :

function mediatheque_signer_url( $id, $duree = 3600 ) {
	$expire = time() + (int) $duree;
	$jeton  = hash_hmac( 'sha256', $id . '|' . $expire, wp_salt( 'auth' ) );

	return add_query_arg(
		array( 'expire' => $expire, 'jeton' => $jeton ),
		rest_url( 'mediatheque/v1/videos/' . (int) $id )
	);
}

function mediatheque_verifier_jeton( WP_REST_Request $requete ) {
	$id     = (int) $requete['id'];
	$expire = (int) $requete->get_param( 'expire' );
	$jeton  = (string) $requete->get_param( 'jeton' );

	if ( $expire < time() ) {
		return new WP_Error( 'jeton_expire', __( 'Lien expiré.', 'mediatheque' ), array( 'status' => 403 ) );
	}

	$attendu = hash_hmac( 'sha256', $id . '|' . $expire, wp_salt( 'auth' ) );

	if ( ! hash_equals( $attendu, $jeton ) ) {
		return new WP_Error( 'jeton_invalide', __( 'Lien invalide.', 'mediatheque' ), array( 'status' => 403 ) );
	}

	return true;
}

La signature lie l’identifiant de la vidéo à l’instant d’expiration : modifier l’un ou l’autre invalide le lien. La comparaison passe par hash_equals(), qui s’exécute en temps constant. L’adresse est fournie au front par une route authentifiée, qui vérifie le droit de voir la vidéo avant d’appeler mediatheque_signer_url().

Vérifier le comportement avec curl

Avant d’en juger par le lecteur vidéo, testez la route en ligne de commande, qui montre exactement ce que le serveur répond :

curl -s -o /dev/null -D - -H "Range: bytes=0-99" "$URL_SIGNEE"
curl -s -o /dev/null -D - -H "Range: bytes=999999999999-" "$URL_SIGNEE"
curl -s -I "$URL_SIGNEE"

La première commande doit afficher HTTP/2 206, Content-Length: 100 et Content-Range: bytes 0-99/…. La deuxième doit répondre 416. La troisième, une requête HEAD, doit afficher Accept-Ranges: bytes et la taille complète, sans corps.

Pièges et cas où s’en passer

  • Un serveur web qui compresse ou met en mémoire tampon. Une compression à la volée sur un flux vidéo fausse les longueurs annoncées ; désactivez-la pour cette route, ainsi que la mise en tampon de sortie de PHP.
  • Un délai d’exécution trop court. La diffusion d’une longue plage dépasse facilement la limite par défaut de PHP ; surveillez la durée et, le cas échéant, relevez-la pour cette route.
  • Un cache intermédiaire. Une réponse partielle ne doit pas être prise pour le fichier complet : ajoutez un Cache-Control: private tant que l’accès est protégé.
  • Les en-têtes exposés au navigateur. Si le front lit Content-Range avec fetch() depuis un autre domaine, l’en-tête Access-Control-Expose-Headers doit le mentionner ; la balise video, elle, n’en a pas besoin.

Quand ne pas l’utiliser ? Pour une vidéo publique, laissez le serveur web la servir directement : Apache et nginx gèrent nativement les requêtes partielles, bien plus efficacement que PHP. Pour un contenu protégé mais volumineux, une redirection interne, comme X-Accel-Redirect avec nginx ou X-Sendfile avec Apache, garde la vérification d’accès en PHP et laisse au serveur web l’envoi des octets et la gestion de Range.

Servir un fichier par PHP, c’est accepter d’en refaire à la main tout le protocole : ne le faites que si le contrôle d’accès l’exige.

Conclusion

Répondre correctement à Range tient en quatre règles : annoncer Accept-Ranges: bytes, répondre 206 avec un Content-Range exact, refuser par 416 ce qui dépasse le fichier, et ignorer proprement ce que l’on ne sait pas traiter. Ajoutez une adresse signée pour que la balise video puisse s’authentifier, et une lecture par blocs pour ne pas saturer la mémoire. Le visiteur qui avance dans la vidéo ne retélécharge plus rien d’inutile, et votre serveur non plus.

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Partager :

À propos de l'auteur

WordPress Développement

Développeur WordPress, passionné par Elementor, le FSE et l’automatisation par IA.

Voir tous ses articles

Dans la même veine

À lire aussi