Jersey REST support resume/media streaming

14,329

Solution 1

Here is my take based on the solution provided here. It works correctly on different browsers. I am able to seek the music just fine in Safari and other browsers as well. You can find the sample project on my Github repository which has more details. Chrome and Safari nicely leverages the range headers to stream media and you can see it in the request/response trace.

    @GET
    @Produces("audio/mp3")
    public Response streamAudio(@HeaderParam("Range") String range) throws Exception {
        return buildStream(audio, range);
    }

    private Response buildStream(final File asset, final String range) throws Exception {
        // range not requested : Firefox, Opera, IE do not send range headers
        if (range == null) {
            StreamingOutput streamer = new StreamingOutput() {
                @Override
                public void write(final OutputStream output) throws IOException, WebApplicationException {

                    final FileChannel inputChannel = new FileInputStream(asset).getChannel();
                    final WritableByteChannel outputChannel = Channels.newChannel(output);
                    try {
                        inputChannel.transferTo(0, inputChannel.size(), outputChannel);
                    } finally {
                        // closing the channels
                        inputChannel.close();
                        outputChannel.close();
                    }
                }
            };
            return Response.ok(streamer).header(HttpHeaders.CONTENT_LENGTH, asset.length()).build();
        }

        String[] ranges = range.split("=")[1].split("-");
        final int from = Integer.parseInt(ranges[0]);
        /**
         * Chunk media if the range upper bound is unspecified. Chrome sends "bytes=0-"
         */
        int to = chunk_size + from;
        if (to >= asset.length()) {
            to = (int) (asset.length() - 1);
        }
        if (ranges.length == 2) {
            to = Integer.parseInt(ranges[1]);
        }

        final String responseRange = String.format("bytes %d-%d/%d", from, to, asset.length());
        final RandomAccessFile raf = new RandomAccessFile(asset, "r");
        raf.seek(from);

        final int len = to - from + 1;
        final MediaStreamer streamer = new MediaStreamer(len, raf);
        Response.ResponseBuilder res = Response.status(Status.PARTIAL_CONTENT).entity(streamer)
                .header("Accept-Ranges", "bytes")
                .header("Content-Range", responseRange)
                .header(HttpHeaders.CONTENT_LENGTH, streamer.getLenth())
                .header(HttpHeaders.LAST_MODIFIED, new Date(asset.lastModified()));
        return res.build();
    }

Here is the MediaStreamer implementation, which is used to stream the output in your resource method.

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;
    }
}

Solution 2

Since I was facing the same problema, I tried a more general solution[1] with a ContainerResponseFilter which will trigger when a Range header is present in the request and will work seamlessly with any media-type, entity and resource methods.

This is the ContainerResponseFilter which will use encapsulate the output stream in a RangedOutputStream (see below):

public class RangeResponseFilter implements ContainerResponseFilter {

    private static final String RANGE = "Range";

    private static final String ACCEPT_RANGES = "Accept-Ranges";

    @Override
    public void filter(ContainerRequestContext requestContext, ContainerResponseContext responseContext)
            throws IOException {
        if (requestContext.getHeaders().containsKey(RANGE)) {
            String rangeHeader = requestContext.getHeaderString(RANGE);
            String contentType = responseContext.getMediaType().toString();
            OutputStream originOutputStream = responseContext.getEntityStream();
            RangedOutputStream rangedOutputStream = new RangedOutputStream(originOutputStream, rangeHeader, contentType, responseContext.getHeaders());
            responseContext.setStatus(Status.PARTIAL_CONTENT.getStatusCode());
            responseContext.getHeaders().putSingle(ACCEPT_RANGES, rangedOutputStream.getAcceptRanges());
            responseContext.setEntityStream(rangedOutputStream);
        }
    }

}

And here's the RangedOutputStream:

public class RangedOutputStream extends OutputStream {

    public class Range extends OutputStream {

        private ByteArrayOutputStream outputStream;

        private Integer from;

        private Integer to;

        public Range(Integer from, Integer to) {
            this.outputStream = new ByteArrayOutputStream();
            this.from = from;
            this.to = to;
        }

        public boolean contains(Integer i) {
            if (this.to == null) {
                return (this.from <= i);
            }
            return (this.from <= i && i <= this.to);
        }

        public byte[] getBytes() {
            return this.outputStream.toByteArray();
        }

        public Integer getFrom() {
            return this.from;
        }

        public Integer getTo(Integer ifNull) {
            return this.to == null ? ifNull : this.to;
        }

        @Override
        public void write(int b) throws IOException {
            this.outputStream.write(b);
        }

    }

    private final static char[] MULTIPART_CHARS = "-_1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
            .toCharArray();

    private static final String BOUNDARY_LINE_FORMAT = "--%s";

    private static final String CONTENT_TYPE_LINE_FORMAT = "Content-Type: %s";

    private static final String CONTENT_RANGE_FORMAT = "%s %d-%d/%d";

    private static final String CONTENT_RANGE_LINE_FORMAT = "Content-Range: " + CONTENT_RANGE_FORMAT;

    private static final String EMPTY_LINE = "\r\n";

    private OutputStream outputStream;

    private String boundary;

    private String accept;

    private String contentType;

    private boolean multipart;

    private boolean flushed = false;

    private int pos = 0;

    List<Range> ranges;

    MultivaluedMap<String, Object> headers;

    public RangedOutputStream(OutputStream outputStream, String ranges, String contentType, MultivaluedMap<String, Object> headers) {
        this.outputStream = outputStream;
        this.ranges = new ArrayList<>();
        String[] acceptRanges = ranges.split("=");
        this.accept = acceptRanges[0];
        for (String range : acceptRanges[1].split(",")) {
            String[] bounds = range.split("-");
            this.ranges.add(new Range(Integer.valueOf(bounds[0]), bounds.length == 2 ? Integer.valueOf(bounds[1]) : null ));
        }
        this.headers = headers;
        this.contentType = contentType;
        this.multipart = this.ranges.size() > 1;
        this.boundary = this.generateBoundary();
    }

    private String generateBoundary() {
        StringBuilder buffer = new StringBuilder();
        Random rand = new Random();
        int count = rand.nextInt(11) + 30;
        for (int i = 0; i < count; i++) {
            buffer.append(MULTIPART_CHARS[rand.nextInt(MULTIPART_CHARS.length)]);
        }
        return buffer.toString();
    }

    public boolean isMultipart() {
        return this.multipart;
    }

    public String getBoundary() {
        return this.boundary;
    }

    public String getAcceptRanges() {
        return this.accept;
    }

    public String getContentRange(int index) {
        Range range = this.ranges.get(index);
        return String.format(CONTENT_RANGE_LINE_FORMAT, this.accept, range.getFrom(), range.getTo(this.pos), this.pos);
    }

    @Override
    public void write(int b) throws IOException {
        for (Range range : this.ranges) {
            if (range.contains(this.pos)) {
                range.write(b);
            }
        }
        this.pos++;
    }

    @Override
    public void flush() throws IOException {
        if (this.flushed) {
            return;
        }
        if (this.multipart) {
            this.headers.putSingle(HttpHeaders.CONTENT_TYPE, String.format("multipart/byteranges; boundary=%s", this.boundary));
            for (Range range : this.ranges) {
                this.outputStream.write(String.format(BOUNDARY_LINE_FORMAT + EMPTY_LINE, this.boundary).getBytes());
                this.outputStream.write(String.format(CONTENT_TYPE_LINE_FORMAT + EMPTY_LINE, this.contentType).getBytes());
                this.outputStream.write(
                        String.format(CONTENT_RANGE_LINE_FORMAT + EMPTY_LINE, this.accept, range.getFrom(), range.getTo(this.pos), this.pos)
                                .getBytes());
                this.outputStream.write(EMPTY_LINE.getBytes());
                this.outputStream.write(range.getBytes());
                this.outputStream.write(EMPTY_LINE.getBytes());
            }
            this.outputStream.write(String.format(BOUNDARY_LINE_FORMAT, this.boundary + "--").getBytes());
        } else {
            Range range = this.ranges.get(0);
            this.headers.putSingle("Content-Range", String.format(CONTENT_RANGE_FORMAT, this.accept, range.getFrom(), range.getTo(this.pos), this.pos));
            this.outputStream.write(range.getBytes());
        }
        this.flushed = true;
    }

}

[1] https://github.com/heruan/jersey-range-filter

Share:
14,329

Related videos on Youtube

Dima
Author by

Dima

Here is the About Me Here is the Github

Updated on October 25, 2020

Comments

  • Dima
    Dima over 3 years

    I need to support resume on Jersey REST, I'm trying to do it this way:

    @Path("/helloworld")
    public class RestServer {
    
    @GET
    
    @Path("say")
    @Produces("audio/mp3")
    public Response getMessage(@HeaderParam("Range") String r ) throws IOException{
        String str="/Users/dima/Music/crazy_town_-_butterfly.mp3";
    
        System.out.println(r);
        RandomAccessFile f=new RandomAccessFile(str, "r");
    
        int off=0;
        int to=(int)f.length();
        byte[] data ;
        if(r!=null){
            String from=r.split("=")[1].split("-")[0];
            String t=r.split("=")[1].split("-")[1];
            off=Integer.parseInt(from);
            to=Integer.parseInt(t);
    
        }
        data= new byte[to-off];
        f.readFully(data, off, to-off);
    
        ResponseBuilder res=Response.ok(data)
                .header("Accept-Ranges","bytes")
                .header("Content-Range:", "bytes "+off+"-"+to+"/"+data.length)
                .header("Pragma", "no-cache");;
    
                if(r==null){
                    res=res.header("Content-Length", data.length);
                }
                f.close();
    
                Response ans=res.build();
    
                return ans;
    
    
    }
    }
    

    I want to be able stream mp3 so the browser can seek the music, but in safari it still not working. any ideas?

  • Dima
    Dima over 11 years
    Thank you so much! exactly what i needed, works like a charm!
  • Dima
    Dima over 11 years
    BTW, what value should i set to chunk_size?
  • Arul Dhesiaseelan
    Arul Dhesiaseelan over 11 years
    I used 1MB chunks in my sample here github.com/aruld/jersey-streaming/blob/master/src/main/java/‌​com/… It is used when the client does not sent range upper bound, so we decide to chunk the media in to several bits.
  • thouliha
    thouliha almost 9 years
    God I wish I had this for jetty. I can't figure out how to convert it.
  • Arul Dhesiaseelan
    Arul Dhesiaseelan almost 9 years
    @thouliha Checkout jersey2 branch that uses Jetty.
  • carlspring
    carlspring almost 9 years
    @ArulDhesiaseelan: I have a similar question here: stackoverflow.com/questions/31070137/…? . It would be great, if I could hear your thoughts about it, as I'm also using Jersey.