Implement Byte serving for Spring Boot

I want to implement video Player in Angular using Spring Boot Rest API. I can play the video but I can’t make video seeking. Every time the video starts over and over again when I use Chrome or Edge.

I tried this endpoint:

@RequestMapping(value = "/play_video/{video_id}", method = RequestMethod.GET)
    @ResponseBody public ResponseEntity<byte[]> getPreview1(@PathVariable("video_id") String video_id, HttpServletResponse response) {
        ResponseEntity<byte[]> result = null;
        try {
            String file = "/opt/videos/" + video_id + ".mp4";
            Path path = Paths.get(file);
            byte[] image = Files.readAllBytes(path);

            response.setStatus(HttpStatus.OK.value());
            HttpHeaders headers = new HttpHeaders();
            headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);
            headers.setContentLength(image.length);
            result = new ResponseEntity<byte[]>(image, headers, HttpStatus.OK);
        } catch (java.nio.file.NoSuchFileException e) {
            response.setStatus(HttpStatus.NOT_FOUND.value());
        } catch (Exception e) {
            response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
        }
        return result;
    }

I found this post which gives some ides: How to Implement HTTP byte-range requests in Spring MVC

But currently it’s not working. Video is again playing from the start when I try to shift the position.

I use this player: https://github.com/smnbbrv/ngx-plyr

I have configured it this way:

<div class="media">
        <div
          class="class-video mr-3 mb-1"
          plyr
          [plyrPlaysInline]="true"
          [plyrSources]="gymClass.video"
          (plyrInit)="player = $event"
          (plyrPlay)="played($event)">
        </div>
        <div class="media-body">
          {{ gymClass.description }}
        </div>
      </div>

Do you know how I can fix this issue?

Answer

First solution: Using FileSystemResource

FileSystemResource internally handles byte-range header support, reading and writing the appropriate headers.

Two problems with this approach.

  1. It uses FileInputStream internally for reading files. This is fine for small files, but not for large files served through byte-range requests. FileInputStream will read the file from the beginning and discard the not needed content until it reches the requested start offset. This can cause slowdowns with larger files.

  2. It sets "application/json" as the "Content-Type" response header. So, I provide my own "Content-Type" header. See this thread

import java.nio.file.Path;
import java.nio.file.Paths;
import org.springframework.core.io.FileSystemResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.ResponseBody;

@Controller
public class Stream3 {
    @GetMapping(value = "/play_video/{video_id}")
    @ResponseBody
    public ResponseEntity<FileSystemResource> stream(@PathVariable("video_id") String video_id) {
        String filePathString = "/opt/videos/" + video_id + ".mp4";        
        final HttpHeaders responseHeaders = new HttpHeaders();
        responseHeaders.add("Content-Type", "video/mp4");
        return new ResponseEntity<>(new FileSystemResource(filePathString), responseHeaders, HttpStatus.OK);
    }
}

Second solution: Using HttpServletResponse and RandomAccessFile

With RandomAccessFile you can implement support for byte-range requests. The advantage over FileInputStream, is that you don’t need to read the file from the beginning every time there is a new range request, making this method usable also for larger files. RandomAccessFile has a method called seek(long) which calls the C method fseek(), which directly moves the pointer for the file to the requested offset.

import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.OutputStream;
import java.io.RandomAccessFile;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import javax.servlet.http.HttpServletResponse;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.ResponseBody;

@Controller
public class Stream {
    @GetMapping(value = "/play_video/{video_id}")
    @ResponseBody
    public void stream(        
            @PathVariable("video_id") String video_id,
            @RequestHeader(value = "Range", required = false) String rangeHeader,
            HttpServletResponse response) {

        try {
            OutputStream os = response.getOutputStream();
            long rangeStart = 0;
            long rangeEnd;
            String filePathString = "/opt/videos/" + video_id + ".mp4";
            Path filePath = Paths.get(filePathString);
            Long fileSize = Files.size(filePath);
            byte[] buffer = new byte[1024];
            RandomAccessFile file = new RandomAccessFile(filePathString, "r");
            try (file) {
                if (rangeHeader == null) {
                    response.setHeader("Content-Type", "video/mp4");
                    response.setHeader("Content-Length", fileSize.toString());
                    response.setStatus(HttpStatus.OK.value());
                    long pos = rangeStart;
                    file.seek(pos);
                    while (pos < fileSize - 1) {                        
                        file.read(buffer);
                        os.write(buffer);
                        pos += buffer.length;
                    }
                    os.flush();
                    return;
                }

                String[] ranges = rangeHeader.split("-");
                rangeStart = Long.parseLong(ranges[0].substring(6));
                if (ranges.length > 1) {
                    rangeEnd = Long.parseLong(ranges[1]);
                } else {
                    rangeEnd = fileSize - 1;
                }
                if (fileSize < rangeEnd) {
                    rangeEnd = fileSize - 1;
                }

                String contentLength = String.valueOf((rangeEnd - rangeStart) + 1);
                response.setHeader("Content-Type", "video/mp4");
                response.setHeader("Content-Length", contentLength);
                response.setHeader("Accept-Ranges", "bytes");
                response.setHeader("Content-Range", "bytes" + " " + rangeStart + "-" + rangeEnd + "/" + fileSize);
                response.setStatus(HttpStatus.PARTIAL_CONTENT.value());
                long pos = rangeStart;
                file.seek(pos);
                while (pos < rangeEnd) {                    
                    file.read(buffer);
                    os.write(buffer);
                    pos += buffer.length;
                }
                os.flush();

            }

        } catch (FileNotFoundException e) {
            response.setStatus(HttpStatus.NOT_FOUND.value());
        } catch (IOException e) {
            response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
        }

    }

}

Third solution: Also using RandomAccessFile, but StreamingResponseBody instead of HttpServletResponse

import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;

@Controller
public class Stream2 {
    @GetMapping(value = "/play_video/{video_id}")
    @ResponseBody
    public ResponseEntity<StreamingResponseBody> stream(
            @PathVariable("video_id") String video_id,
            @RequestHeader(value = "Range", required = false) String rangeHeader) {        
        try {
            StreamingResponseBody responseStream;
            String filePathString = "/opt/videos/" + video_id + ".mp4";
            Path filePath = Paths.get(filePathString);
            Long fileSize = Files.size(filePath);
            byte[] buffer = new byte[1024];      
            final HttpHeaders responseHeaders = new HttpHeaders();

            if (rangeHeader == null) {
                responseHeaders.add("Content-Type", "video/mp4");
                responseHeaders.add("Content-Length", fileSize.toString());
                responseStream = os -> {
                    RandomAccessFile file = new RandomAccessFile(filePathString, "r");
                    try (file) {
                        long pos = 0;
                        file.seek(pos);
                        while (pos < fileSize - 1) {                            
                            file.read(buffer);
                            os.write(buffer);
                            pos += buffer.length;
                        }
                        os.flush();
                    } catch (Exception e) {}
                };
                return new ResponseEntity<>(responseStream, responseHeaders, HttpStatus.OK);
            }

            String[] ranges = rangeHeader.split("-");
            Long rangeStart = Long.parseLong(ranges[0].substring(6));
            Long rangeEnd;
            if (ranges.length > 1) {
                rangeEnd = Long.parseLong(ranges[1]);
            } else {
                rangeEnd = fileSize - 1;
            }
            if (fileSize < rangeEnd) {
                rangeEnd = fileSize - 1;
            }

            String contentLength = String.valueOf((rangeEnd - rangeStart) + 1);
            responseHeaders.add("Content-Type", "video/mp4");
            responseHeaders.add("Content-Length", contentLength);
            responseHeaders.add("Accept-Ranges", "bytes");
            responseHeaders.add("Content-Range", "bytes" + " " + rangeStart + "-" + rangeEnd + "/" + fileSize);
            final Long _rangeEnd = rangeEnd;
            responseStream = os -> {
                RandomAccessFile file = new RandomAccessFile(filePathString, "r");
                try (file) {
                    long pos = rangeStart;
                    file.seek(pos);
                    while (pos < _rangeEnd) {                        
                        file.read(buffer);
                        os.write(buffer);
                        pos += buffer.length;
                    }
                    os.flush();
                } catch (Exception e) {}
            };
            return new ResponseEntity<>(responseStream, responseHeaders, HttpStatus.PARTIAL_CONTENT);

        } catch (FileNotFoundException e) {
            return new ResponseEntity<>(HttpStatus.NOT_FOUND);
        } catch (IOException e) {
            return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
        }
    }
}

In your component.ts:

You can change the currently displaying video with playVideoFile()

export class AppComponent implements OnInit {
  videoSources: Plyr.Source[];
  ngOnInit(): void {
    const fileName = 'sample';
    this.playVideoFile(fileName);
  }

  playVideoFile(fileName: string) {
    this.videoSources = [
      {
        src: `http://localhost:8080/play_video/${fileName}`,
      },
    ];
  }
}

And the html:

<div
  #plyr
  plyr
  [plyrPlaysInline]="false"
  [plyrSources]="videoSources"
></div>

Leave a Reply

Your email address will not be published. Required fields are marked *