0

I'm trying to return an mp4 file from my Grails controller so that it can be played in the browser. The following is the simplest version of what I have:

def file = new File(<path to mp4 file>)
response.outputStream << file.newInputStream()

The strange thing is that this works when hitting it from a desktop (Chrome on my MacBook), works on an Android phone, but does not work on an iPad Air.

The one header that's different in the iOS request is for "range" of "0-1", but it looks like that might not be causing a problem (tested by adding that request on my laptop).

The exception says:

ERROR errors.GrailsExceptionResolver - SocketException occurred when processing request: [GET]

and further down it says

getOutputStream() has already been called for this response.

I've found many others with similar errors, but they talk about webRequest.setRenderView(false), flushing and closing the outputstream, and many other options. I've tried all of those, but nothing seems to work.

The part that really gets me is that it works on everything except iOS.

Any thoughts would be greatly appreciated. Thanks in advance!

UPDATE 1

Per Graeme's answer below, the accept header from Chrome is:

accept -> text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8

And iOS produces multiple requests, which have the following accept headers:

accept -> text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
accept -> */*

The second accept header, */* is the what occurs during the exception.

I have also created a JIRA issue for Grails: http://jira.grails.org/browse/GRAILS-11325

mnd
  • 2,709
  • 3
  • 27
  • 48
  • I am curious, what must be the response in the two different requests made by Safari? I am experiencing a similar/same problem. – Calicoder Sep 19 '17 at 22:37
  • Sorry @Calicoder, this was long enough ago that I don't recall why there were two different requests. It's possible the first request was for the page, and the second request was for the video content, but that is only a guess at this point. – mnd Sep 21 '17 at 21:27

2 Answers2

0

Might be related to the Accept header that gets sent, as Grails has some parsing depending on the Accept header. If you could post an example in a JIRA with steps to reproduce that would help.

http://jira.grails.org/browse/GRAILS

Graeme Rocher
  • 7,985
  • 2
  • 26
  • 37
  • would you be able to point me to the Grails code that does the Accept header parsing? I'm willing to dig into it further, but not sure where to start - thanks. – mnd Apr 16 '14 at 15:58
0

This turned out to be an iOS specific issue. The range header is required to be implemented, and if you try to return the entire file content for the response of a range request, iOS will not make additional requests.

The following is the code I used:

try {
    def rangeValue = request.getHeader("range")
    log.debug("rangeValue: ${rangeValue}")
    if (rangeValue != null) {
        // Get start and end string, substring(6) removes "bytes="
        def (start, end) = rangeValue.substring(6).split("-")
        def startInt = start.toLong()
        def endInt = end.toLong()
        def fileSize = file.length()

        response.reset()
        response.setStatus(206)
        response.setHeader("Accept-Ranges", "bytes")
        // WARNING: Do not sent Content-length, as it appears to prevent videos from working in iOS
        response.setHeader("Content-range", "bytes ${start}-${end}/"+Long.toString(fileSize))
        response.setContentType("video/quicktime")

        def bytes = new byte[endInt-startInt+1]
        def inputStream = file.newInputStream()
        // Skip to the point in the inputStream that the range is requesting
        inputStream.skip(startInt)
        // Read a chunk of the input stream into the bytes array
        inputStream.read(bytes, 0, bytes.length)
        response.outputStream << bytes
    }
    else {
        response.outputStream << file.newInputStream()
    }
} catch (ClientAbortException e) {
    log.error("User aborted download")
}

There are a few important notes:

  1. If the Content-length header is returned in the response, iOS will not play the video. Seems like this could be related to content being gzipped - https://stackoverflow.com/a/2359184/2601060
  2. When using the inputStream.read() function, it will always start reading at the beginning of the stream, so make sure to skip() to the proper position in the file (the startInt)
  3. A response can be reset() to make sure that anything that has already been written is not included (this may not be required, but prevents automatic grails actions from providing default behavior)
Community
  • 1
  • 1
mnd
  • 2,709
  • 3
  • 27
  • 48
  • Sorry, can `Content-length` header actually do any harm? As far as Apple-compatible mp4 "header" is in the END of file, I'd rather say the opposite is true: `Content-length` MUST be there. Otherwise what is the need for `range` requests? – Victor Sergienko Jul 03 '14 at 00:45
  • I also had the experience where iOS device was playing my mp4 files served with `Content-length` (and range requests). – Victor Sergienko Jul 03 '14 at 00:49
  • The problem with `Content-length` in this situation is that it would not be an accurate value. I don't recall the order, but if the content is one size, but `Content-length` specifies it as a different size, it might not play all of the content. – mnd Jul 04 '14 at 13:35
  • Indeed. I just cannot see what API could return an inaccurate file length; filesystem is either working or not. If `File` returns a wrong size, there's something wrong beyond recovery with the file system. – Victor Sergienko Jul 04 '14 at 15:19
  • 1
    There is an issue when "range" header looks like "0-". The array after split has length 1 so the assignment to (start, end) fails. I think the value for end in this case should be file.bytes.length-1. – Uros K Dec 11 '14 at 13:19
  • 1
    When you do "def fileSize = file.bytes.length" you're actually reading the whole file into memory, that defeats the purpose of seeking and reading just the requested range in the file. Using "def fileSize = file.length()" will fix that. – ncerezo Dec 01 '15 at 11:11
  • 1
    By the way, you should use Long instead of Integer (files can be longer than Integer.MAX_VALUE). And you're forgetting to close the input file: it's safer to use file.withInputStream { } – ncerezo Dec 01 '15 at 11:23