# 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.

- Auteur : WordPress Développement
- Publié le : 2024-01-23
- Mis à jour le : 2026-09-30
- Catégorie : Headless &amp; API
- URL : https://www.wpmoderne.fr/headless/http-range-video-mediatheque-headless/

## L’essentiel

- 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

```
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.
