REST chunked video streaming with Jersey2

rest_jersey2_video_stream_thumb

In this tutorial we demonstrate simple chunked MP4 video streaming using a RESTful web service with Jersey2, Tomcat and Maven. We use the Range (with bytes=X..Y) parameter to send or buffer the video in chunks. Therefore, less bandwidth is required if the client requests smaller part of the video. Stepping forward or backward throughout the video is supported. The required Maven project is available on Github.

1. Prerequisites

  1. Install Apache Tomcat (we use version 8.5). If not already installed, please have a look at these 2 tutorials:
  2. Install Apache Maven to build the project
  3. You can just run a “clean install” on the Maven project and deploy the created WAR file to Tomcat directly. Although we recommend to work with an IDE like EclipseEE or IntelliJ to adapt or debug the project
  4. If you import the Maven project to EclipseEE, activate and adapt the Project Facets to use the Dynamic Web Project 3.1, Java 1.8 and JAX-RS 2.0

2. Code step-by-step

This tutorial is based on the Git Repository of Arul Dhesiaseelan, demonstrating video streaming with Jersey. We created a project around it, made some adaptations and provide a client JQuery UI with a media player.

2.1 Dependencies in pom.xml

We use the following dependencies in the pom.xml. Versions are 2.25 for Jersey and 2.5 for the Apache Commons IO.

	<dependencies>
		<!-- JERSEY 2 -->
		<dependency>
			<groupId>org.glassfish.jersey.containers</groupId>
			<artifactId>jersey-container-servlet</artifactId>
			<version>${jersey2.version}</version>
		</dependency>
		<dependency>
			<groupId>org.glassfish.jersey.containers</groupId>
			<artifactId>jersey-container-servlet-core</artifactId>
			<version>${jersey2.version}</version>
		</dependency>
		<!-- Commons IO -->
		<dependency>
			<groupId>commons-io</groupId>
			<artifactId>commons-io</artifactId>
			<version>${apachecommons.version}</version>
		</dependency>
		<!-- Logging -->
		<dependency>
			<groupId>log4j</groupId>
			<artifactId>log4j</artifactId>
			<version>${log4j.version}</version>
		</dependency>
	</dependencies>

2.2. Adaptations in web.xml

<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xmlns="http://xmlns.jcp.org/xml/ns/javaee"
	xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd"
	version="3.1">
	<display-name>jersey2-resume-video-streaming</display-name>
	<welcome-file-list>
		<welcome-file>index.html</welcome-file>
	</welcome-file-list>
	<servlet>
		<servlet-name>jersey2-resume-video-streaming</servlet-name>
		<servlet-class>org.glassfish.jersey.servlet.ServletContainer</servlet-class>
		<init-param>
			<param-name>javax.ws.rs.Application</param-name>
			<param-value>com.tutorialacademy.rest.streaming.StreamingRestService</param-value>
		</init-param>
		<init-param>
			<param-name>com.ws.rs.ext.WriterInterceptor</param-name>
			<param-value>com.tutorialacademy.rest.streaming.ClientAbortExceptionWriterInterceptor</param-value>
		</init-param>
		<load-on-startup>1</load-on-startup>
	</servlet>
	<servlet-mapping>
		<servlet-name>jersey2-resume-video-streaming</servlet-name>
		<url-pattern>/rest/*</url-pattern>
	</servlet-mapping>
</web-app>

The servlet entry pattern is /rest/*. We provide an index.html file with a media player at root level to stream the video later on. Furthermore, we use a WriterInteceptor to avoid ClientAbortExceptions. These occur whenever the client closes the connection before the server is able to send all the requested data in the response (which happens quite a lot).

2.3 The REST web service

@Path("/")
public class StreamingRestService extends ResourceConfig {

	final static Logger logger = Logger.getLogger( StreamingRestService.class );
	
	private static final String FILE_PATH = "/Sample_720x480_30mb.mp4";
    private final int chunk_size = 1024 * 1024 * 2; // 2 MB chunks

    // for clients to check whether the server supports range / partial content requests
    @HEAD
    @Path("/stream")
    @Produces("video/mp4")
    public Response header() {
    	logger.info("@HEAD request received");
    	
        URL url = this.getClass().getResource( FILE_PATH );
        File file = new File( url.getFile() );
        
        return Response.ok()
        		.status( Response.Status.PARTIAL_CONTENT )
        		.header( HttpHeaders.CONTENT_LENGTH, file.length() )
        		.header( "Accept-Ranges", "bytes" )
        		.build();
    }

    @GET
    @Path("/stream")
    @Produces("video/mp4")
    public Response stream( @HeaderParam("Range") String range ) throws Exception {
    	
        URL url = this.getClass().getResource( FILE_PATH );
        File file = new File( url.getFile() );
        
        return buildStream( file, range );
    }

    /**
     * @param asset Media file
     * @param range range header
     * @return Streaming output
     * @throws Exception IOException if an error occurs in streaming.
     */
    private Response buildStream( final File asset, final String range ) throws Exception {
        // range not requested: firefox does not send range headers
        if ( range == null ) {
        	logger.info("Request does not contain a range parameter!");
        	
            StreamingOutput streamer = output -> {
                try ( FileChannel inputChannel = new FileInputStream( asset ).getChannel(); 
                	  WritableByteChannel outputChannel = Channels.newChannel( output ) ) {
                	
                    inputChannel.transferTo( 0, inputChannel.size(), outputChannel );
                }
                catch( IOException io ) {
                	logger.info( io.getMessage() );
                }
            };
            
            return Response.ok( streamer )
            		.status( Response.Status.OK )
            		.header( HttpHeaders.CONTENT_LENGTH, asset.length() )
            		.build();
        }

        logger.info( "Requested Range: " + range );
        
        String[] ranges = range.split( "=" )[1].split( "-" );
        
        int from = Integer.parseInt( ranges[0] );
        
        // Chunk media if the range upper bound is unspecified
        int to = chunk_size + from;
        
        if ( to >= asset.length() ) {
            to = (int) ( asset.length() - 1 );
        }
        
        // uncomment to let the client decide the upper bound
        // we want to send 2 MB chunks all the time
        //if ( ranges.length == 2 ) {
        //    to = Integer.parseInt( ranges[1] );
        //}
        
        final String responseRange = String.format( "bytes %d-%d/%d", from, to, asset.length() );
        
        logger.info( "Response Content-Range: " + responseRange + "\n");
        
        final RandomAccessFile raf = new RandomAccessFile( asset, "r" );
        raf.seek( from );

        final int len = to - from + 1;
        final MediaStreamer mediaStreamer = new MediaStreamer( len, raf );

        return Response.ok( mediaStreamer )
                .status( Response.Status.PARTIAL_CONTENT )
                .header( "Accept-Ranges", "bytes" )
                .header( "Content-Range", responseRange )
                .header( HttpHeaders.CONTENT_LENGTH, mediaStreamer. getLenth() )
                .header( HttpHeaders.LAST_MODIFIED, new Date( asset.lastModified() ) )
                .build();
    }

The web service provides two entry points at “/stream”. The first one is a HEAD request, which some media players use to determine whether ranged/chunked streaming is supported. The response consists of the PARTIAL_CONTENT (206) HTTP status code, the actual content length of the video as well as the accepted format “Accept-Ranges”. A typical client range requests looks like this: “bytes=0-1048576” for the first MB or “bytes=1048577-2097152” for the second MB. Furthermore meta data is requested from the start and the end of the video.

The second entry points provides the streaming functionality and is a GET request. The logic is contained the buildStream method with the video file and the GET range parameter. If there is no range specified (range == null), the full video content is send at once.

Many video players work with the range parameter but request basically the full content length. Since we want to focus on chunked streaming, we only process the “from” range (and ignore the “to” range).

        // Chunk media if the range upper bound is unspecified
        int to = chunk_size + from;
        
        if ( to >= asset.length() ) {
            to = (int) ( asset.length() - 1 );
        }
        
        // uncomment to let the client decide the upper bound
        // we want to send 2 MB chunks all the time
        //if ( ranges.length == 2 ) {
        //    to = Integer.parseInt( ranges[1] );
        //}

Finally we create our response range (“Content-Range”) which looks like this: “bytes 0-2097152/31551484” to inform the client which part of the data is transmitted. The number behind the slash indicates the full video length in bytes. In the example code we use 2 MB chunks which should be increased in production to avoid endless requests.

2.4 MediaStreamer class

We use a helper class from Arul Dhesiaseelan (in the Git repository above) to write to an output stream.

/**
 * Media streaming utility
 *
 * @author Arul Dhesiaseelan (arul@httpmine.org)
 */
public class MediaStreamer implements StreamingOutput {

    private int length;
    private RandomAccessFile raf;
    final byte[] buf = new byte[4096];

    public MediaStreamer( int length, RandomAccessFile raf ) {
        this.length = length;
        this.raf = raf;
    }

    @Override
    public void write( OutputStream outputStream ) throws IOException, WebApplicationException {
        try {
            while( length != 0) {
                int read = raf.read( buf, 0, buf.length > length ? length : buf.length );
                outputStream.write( buf, 0, read );
                length -= read;
            }
        } 
        finally {
            raf.close();
        }
    }

    public int getLenth() {
        return length;
    }
}

2.5 JQuery client

The JQuery mkh player is utilized to display the video. The index.html only contains a logo and the media player.

<!DOCTYPE html>
<html lang="en-us">
<head>
	<title>Tutorial Academy</title>
	<meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="theme-color" content="#157878">
	<link rel="stylesheet" type="text/css" href="css/mkhplayer.default.css"/>
	<link rel="stylesheet" type="text/css" href="css/main.css"/>
</head>
<body>

	<div class="header">
	  <img src="img/tutorial_academy_logo.jpg" alt="tutorial-academy-logo">
	  <h1>Tutorial Academy<br>REST media streaming with Jersey2</h1>
	</div>
	<br>
    <section class="main-content">
		<video id='video' preload="metadata" width="100%">
			<source src="http://localhost:8080/jersey2-resume-video-streaming/rest/stream">
		</video>
		<script type="text/javascript" src="https://code.jquery.com/jquery-1.7.1.min.js"></script>
		<script type="text/javascript" src="js/jquery.mkhplayer.js"></script>
		<script type="text/javascript">
			$(document).ready(function(){
				$('video').mkhPlayer();
			});
		</script>
		<footer class="site-footer">
          	<span class="site-footer-owner">
          		The <a href="https://tutorial-academy.com/rest-jersey2-resume-video-streaming">tutorial</a> can be found here.
          	</span>
        	<span class="site-footer-credits">
        		This code is available at <a href="https://github.com/maltesander/rest-jersey2-resume-video-streaming">GitHub</a>.
        	</span>
      	</footer>
	</section>
</body>
</html>

3. Run the streaming example

In order to build the example code, execute “mvn clean install” to build the war file. Then you can deploy it directly in the Tomcat Webapps folder or use EclipseEE. The resources folder contains an example video.

With the default settings in Tomcat (Port 8080) you can reach the project root (and the index.html) at: http://localhost:8080/jersey2-resume-video-streaming/

The REST streaming web service is located at: http://localhost:8080/jersey2-resume-video-streaming/rest/stream

Running Eclipse, the internal browser will open and should display something like this:

Streaming in eclipse

Calling the address in a browser like Chrome we see the following output:

browser_streaming_jquery_ui

Now you can play or step through the video. You can have a look at the Tomcat output or the Eclipse console to check out what requests are received and which responses are returned:

2017-12-21 15:47:29 INFO  StreamingRestService:89 - Requested Range: bytes=0-707583
2017-12-21 15:47:29 INFO  StreamingRestService:110 - Response Content-Range: bytes 0-2097152/31551484

2017-12-21 15:47:29 INFO  StreamingRestService:89 - Requested Range: bytes=0-31551483
2017-12-21 15:47:29 INFO  StreamingRestService:110 - Response Content-Range: bytes 0-2097152/31551484

2017-12-21 15:47:29 INFO  StreamingRestService:89 - Requested Range: bytes=31195136-
2017-12-21 15:47:29 INFO  StreamingRestService:110 - Response Content-Range: bytes 31195136-31551483/31551484

2017-12-21 15:47:30 INFO  StreamingRestService:89 - Requested Range: bytes=262144-31195135
2017-12-21 15:47:30 INFO  StreamingRestService:110 - Response Content-Range: bytes 262144-2359296/31551484

2017-12-21 15:47:30 INFO  StreamingRestService:89 - Requested Range: bytes=262144-31551483
2017-12-21 15:47:30 INFO  StreamingRestService:110 - Response Content-Range: bytes 262144-2359296/31551484

2017-12-21 15:47:30 INFO  StreamingRestService:89 - Requested Range: bytes=983040-
2017-12-21 15:47:30 INFO  StreamingRestService:110 - Response Content-Range: bytes 983040-3080192/31551484

2017-12-21 15:47:30 INFO  StreamingRestService:89 - Requested Range: bytes=1052454-31551483
2017-12-21 15:47:30 INFO  StreamingRestService:110 - Response Content-Range: bytes 1052454-3149606/31551484

2017-12-21 15:47:30 INFO  StreamingRestService:89 - Requested Range: bytes=1052454-
2017-12-21 15:47:30 INFO  StreamingRestService:110 - Response Content-Range: bytes 1052454-3149606/31551484

2017-12-21 15:47:30 INFO  StreamingRestService:89 - Requested Range: bytes=1187403-31551483
2017-12-21 15:47:30 INFO  StreamingRestService:110 - Response Content-Range: bytes 1187403-3284555/31551484

2017-12-21 15:47:30 INFO  StreamingRestService:89 - Requested Range: bytes=1187403-
2017-12-21 15:47:30 INFO  StreamingRestService:110 - Response Content-Range: bytes 1187403-3284555/31551484

2017-12-21 15:47:30 INFO  StreamingRestService:89 - Requested Range: bytes=1289584-31551483
2017-12-21 15:47:30 INFO  StreamingRestService:110 - Response Content-Range: bytes 1289584-3386736/31551484

2017-12-21 15:47:30 INFO  StreamingRestService:89 - Requested Range: bytes=1289584-31551483
2017-12-21 15:47:30 INFO  StreamingRestService:110 - Response Content-Range: bytes 1289584-3386736/31551484

The media player requests some meta information from the beginning and end. Furthermore it always requests the full content length of the video. Because we ignore the second range parameter (“to”) we continue to send only 2 MB chunks back to the client.

4. Conclusion

The video streaming is straight forward. You do not need much code to make it work. We recommend for production to use bigger chunks (10-50MB) or even switch to a real media server. Doing these kinds of things with REST is not the optimal solution. Since REST is stateless, we do not have a session. Consequently, in the example we open and close the media file on every request, which does not scale at all. There are workarounds with timers and e.g. JWTs to mimic a session and keep the file opened, but we still suggest to stream data like this via a media server.

Thanks goes out to Arul Dhesiaseelan and his provided Git Repository, on which this tutorial is heavily based on.

We tried using the network stream of the VLC media player. This works for exactly one chunk. You can step through the video, but only one chunk (2 MB in the demo case) is displayed and then started from zero again. We have to do some research to get to the bottom of that.

If you have problems or errors, feel free to comment and ask.

Facebooktwitterredditpinterestlinkedinmail

Related posts

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.