dark mode

Handle HTTP range requests with status code 206 Partial Content

Handle HTTP range requests with status code 206 Partial Content

We can split and stream our audio or video content. Thanks to the Media Source Extensions API, we can download and add to buffer only part of the media or load bits by bits the whole file.

Why we need to make multiple calls for a media file that is not a live stream?

There are many use cases:

  • Lazy load, large audio and videos or as YouTube you can buffer only part of the video to save traffic from your servers.

  • If you want to prevent a regular user from downloading your content. Using range requests, you can’t expose the download button on the audio tag and with token and server permissions most users won’t steal your content.

Note that you can never protect for content on the front-end, but you can at least make it hard.

Solution

In the code snippet at the end we use React for showcase, but the logic is not bind to any Framework.

If we had a regular server request, we will have return value as URL or the whole media as a blob. But in our case we have a range request. This means that our request will be for a part of the file and we need to provide its start and end. Then we make an additional request with a start after the previous end and a new end and so on until the end of the file.

First, we need to add the HTML and in the return we can find our audio or video tag with source as variable.

In our useEffect hook we need to set our media source and clean all our event listeners after we unload the component.

In setMediaSource we set our source variable as contentSrc and when ready an event listener will call sourceOpen method.

In sourceOpen we initialize the buffer in which we will fill all the parts of our media with appendBuffer method. Because adding to the buffer is an async call, we need to wait until it's done and after that add a new chunk otherwise we will get an exception error. To do that, we add a new event listener.

The appendBuffer method will call rangeContent to fetch the parts of our media. Because we can add infinite numbers chunks to our buffer, our HTML tag is representing as streaming without end time and seek option. To have them, we need to close our source when we fetch the entire file with endOfStream().

All other logic in the appendBuffer is to calculate the end and start of each chunk. Initially, we don't know the length of the file in bytes, that's why we have the constant FIRST_CHUNK_SIZE. Don't worry if the initial value overflow it won't fetch more or give an exception error.

After the first request we can get the file size from the Content-Range header. Its value for example is like bytes 200-1000/67589 and with a little JavaScript we can get the size after the /. Now when knowing how large the file is, we can split in equal chunks like in our case 5 (NUM_CHUNKS) or implement whatever logic you need.

One way to find when we fetched the entire media file is to check if the size chuck is smaller than expected. The isFileLoaded variable is doing this.

Our last method rangeContent fetch data from the server. Using Fetch API we provide the resource destination (DEFAULT_URL) and Headers. In headers we add Range with start and end and optionally authorization token. From our response object we get and return the file chunk and the Content-Range header.

Issues

If we don't set our CORS settings properly, we might get an exception:

Refused to get unsafe header "Content-Range"

If you get this error, we need to expose the header in the CORS policy on the server, for example:

CorsPolicy.ExposedHeaders.Add("Content-Range");

Final code

import React, { useEffect, useState } from 'react';

const NUM_CHUNKS = 5;
const FIRST_CHUNK_SIZE = 50000;
const DEFAULT_URL = 'https://example.com/file';

const PartialContent = () => {
  const [isFileLoaded, setIsFileLoaded] = useState(false);
  const [loadedBytes, setLoadedBytes] = useState(0);
  const [chunkSize, setChunkSize] = useState(FIRST_CHUNK_SIZE);
  const [contentSrc, setContentSrc] = useState(null);

  let sourceBuffer = null;
  let mediaSource = null;

  useEffect(() => {
    setMediaSource();

    return () => {
      removeEventListeners();
    };
  }, [setMediaSource, removeEventListeners]);

  const setMediaSource = () => {
    mediaSource = new MediaSource();
    mediaSource.addEventListener('sourceopen', sourceOpen);

    setContentSrc(URL.createObjectURL(mediaSource));
  };

  const removeEventListeners = () => {
    mediaSource && mediaSource.removeEventListener('sourceopen', sourceOpen);
    sourceBuffer && sourceBuffer.removeEventListener('updateend', appendBuffer);
  };

  const sourceOpen = () => {
    sourceBuffer = mediaSource.addSourceBuffer('audio/mpeg');
    appendBuffer();
    sourceBuffer.addEventListener('updateend', appendBuffer);
  };

  const appendBuffer = () => {
    const start = loadedBytes ? loadedBytes + 1 : loadedBytes;
    const end = loadedBytes ? loadedBytes + chunkSize : chunkSize;

    if (!isFileLoaded) {
      rangeContent(DEFAULT_URL, start, end)
        .then(([data, contentRange]) => {
          sourceBuffer.appendBuffer(data);
          setLoadedBytes(end);
          setIsFileLoaded(data.byteLength < chunkSize);

          if (chunkSize === FIRST_CHUNK_SIZE) {
            // Content-Range example: bytes 200-1000/67589
            setChunkSize(Math.ceil(contentRange.substring(contentRange.indexOf('/') + 1) / NUM_CHUNKS));
          }
        })
        .catch((err) => console.log(err));
    } else {
      removeEventListeners();
      mediaSource.endOfStream();
    }
  };

  const rangeContent = (url = DEFAULT_URL, start = 0, end = null) => {
    const authHeaderValue = `Bearer ${token}`;

    const fetchSettings = {
      headers: new Headers({
        Authorization: authHeaderValue,
        Range: `bytes=${start}-${end ? end : ''}`
      })
    };

    const fetchMethod = fetch(url, fetchSettings);
    const data = fetchMethod.then((res) => res.arrayBuffer());
    const header = fetchMethod.then((res) => res.headers.get('Content-Range'));

    return Promise.all([data, header]).then(([data, contentRange]) => ({
      data,
      contentRange
    }));
  };

  return <audio src={contentSrc}></audio>;
  // return <video src={contentSrc}></video>;
};

export default PartialContent;

Related articles

© 2021 All rights reserved.