3

I'm serving large audio files on an Apache server using mod_xsendfile (version >= 0.10). The files are served fine when I use header( 'HTTP/1.1 200 OK' ); However, those files are served in full. Because I want to allow visitors to seek in the audio files, I am accepting Range requests from the clients.

This is where I run into problems. When I use header( 'HTTP/1.1 206 Partial Content' );, the PHP script responds with Content-Length: 0. This is odd to me, because in the Content-Range response, the range of the request and the total file size of the file is mentioned correctly. For example:

Content-Length:0
Content-Range:bytes 0-139143856/139143857

The headers used by my PHP script to send these two headers are:

$filesize = filesize( $mix_file );

...

$start = calculated from HTTP_RANGE or 0;
$end   = calculated from HTTP_RANGE or $filesize - 1;

...

header( 'Content-Length: ' . ( ( $end - $start ) + 1 ) );
header( 'Content-Range: bytes ' . $start . '-' . $end . '/' . $filesize );

The Content-Range header is only sent if there was a Range request, otherwise my script omits this header.

Why does Content-Length return 0 when I've explicitly set its value?

Things I've tried for troubleshooting

  • Load stream.php?id=9966 with a Range request, which is the intended setup. Script returns 206 Partial Content four times (see screenshot and intended script below). All of them have Content-Length set incorrectly as 0.
  • Load stream.php?id=9966 directly in browser. Causes GET to be sent without a Range request. Script returns 200 OK with the entire file contents. Content-Length is returned correctly. File starts downloading in browser window.
  • Do not set 206 Partial Content header in script in case of a Range request. Causes script to return 200 OK header. Content-Length is correctly returned, as is Content-Range.
  • Force stream.php to always return 200 OK, even when returning a Content-Range response. When GETting with a Range request, both Content-Length and Content-Range are returned correctly.
  • Previous two setups do not allow seeking to a non-buffered position in the audio, which is a requirement. In both test cases, the file content is sent in its entirety and any attempt to seek to a non-buffered position cause the audio to cut out until the download of the file has "caught up".

206 headers returned

Request

GET /stream.php?id=9966 HTTP/1.1
Host: next.tjoonz.com
Connection: keep-alive
Pragma: no-cache
Cache-Control: no-cache
Accept-Encoding: identity;q=1, *;q=0
User-Agent: Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/43.0.2357.134 Safari/537.36
Accept: */*
Referer: http://next.tjoonz.com/
Accept-Language: en-US,en;q=0.8,nl;q=0.6
Cookie: __cfduid=d2623ed31d1d855be05395a7fbcf76c311425543933; __uvt=; uvts=3EmUJ804REmm3E0w; wp-settings-1=hidetb%3D1%26editor%3Dhtml%26m10%3Dc%26m8%3Dc%26m5%3Dc%26m0%3Dc%26m9%3Dc%26m6%3Dc%26m3%3Dc%26imgsize%3Dmedium%26align%3Dnone%26m1%3Dc%26m2%3Dc%26m4%3Dc%26m11%3Dc%26m7%3Dc%26wplink%3D1%26urlbutton%3Dfile%26libraryContent%3Dbrowse%26ed_size%3D373%26dfw_width%3D822; wp-settings-time-1=1435585363; _ga=GA1.2.1985480703.1425543937; audio=yes
Range: bytes=0-

Response

HTTP/1.1 206 Partial Content
Date: Tue, 14 Jul 2015 18:39:52 GMT
Server: Apache
Content-Disposition: attachment; filename="sea-monkey-napcast-018.mp3"
Accept-Ranges: bytes
X-SENDFILE: /home/tjoonzvps/audio/sea-monkey-napcast-018.mp3
Set-Cookie: audio=yes; expires=Wed, 15-Jul-2015 18:39:52 GMT; path=/
Content-Length: 0
Content-Range: bytes 0-145036217/145036218
Keep-Alive: timeout=2, max=97
Connection: Keep-Alive
Content-Type: audio/mpeg

PHP script (the relevant part)

if( file_exists( $mix_file ) ) {
    tjnz_increment_plays( $mix_id );

    // get the 'Range' header if one was sent
    if( isset( $_SERVER[ 'HTTP_RANGE' ] ) ) {
        $range = $_SERVER[ 'HTTP_RANGE' ];
    } else {
        $range = false;
    }

    // get the data range requested (if any)
    $filesize = filesize( $mix_file );
    $start = 0;
    $end   = $filesize - 1;
    if( $range ) {
        $partial = true;
        list( $param, $range ) = explode( '=', $range );
        if( strtolower( trim( $param ) ) != 'bytes') {
            header( 'HTTP/1.1 400 Invalid Request' );
            die();
        }
        $range = explode( ',', $range );
        $range = explode( '-', $range[ 0 ] );
        if( count( $range ) != 2 ) {
            header( 'HTTP/1.1 400 Invalid Request' );
            die();
        }
        if ( $range[ 0 ] === '' ) {
            $end   = $filesize - 1;
            $start = $end - intval( $range[ 0 ] );
        } else if( $range[ 1 ] === '' ) {
            $start = intval( $range[ 0 ] );
            $end   = $filesize - 1;
        } else {
            $start = intval( $range[ 0 ] );
            $end   = intval( $range[ 1 ] );
            if( $end >= $filesize || ( !$start && ( !$end || $end == ( $filesize - 1 ) ) ) ) {
                $partial = false;
            }
        }
    } else {
        $partial = false;
    }

    // send standard headers
    header( 'Content-Type: audio/mpeg' );
    header( 'Content-Length: ' . ( ( $end - $start ) + 1 ) );
    header( 'Content-Disposition: attachment; filename="' . $mix_slug . '.mp3"' );
    header( 'Accept-Ranges: bytes' );

    // if requested, send extra headers and part of file...
    if ( $partial ) {
        header( 'HTTP/1.1 206 Partial Content' ); 
        header( 'Content-Range: bytes ' . $start . '-' . $end . '/' . $filesize ); 
        header( 'X-SENDFILE: ' . $mix_file );
    } else {
        header( 'X-SENDFILE: ' . $mix_file );
    }
    die();
}
0
2
+100

A quick perusal of the mod_sendfile source code reveals that it simply does not support sending partial content. If it gets a response other than 200, it does not send anything, thus your Content-Length gets changed to 0 and no response body is returned.

You can try using X-Accel-Redirect with nginx, which works similarly and does support partial content. Just change "X-Sendfile" to "X-Accel-Redirect" in your code and use nginx instead of Apache. Keep in mind that the directory containing the static files must have a location defined as internal in the nginx configuration. This both enables X-Accel-Redirect and serves 404 errors to anyone who attempts to access the static file directly.

location /audio/ {
    internal;
}
7
  • Why does the page claim one of its benefits is Support for ranges? – Marc Dingena Jul 16 '15 at 21:03
  • I have no idea. I didn't read the marketing text. I went straight to the source.. – Michael Hampton Jul 16 '15 at 21:34
  • I can't upvote your answer as it stands, then. I appreciate you took time to review the code but I can't shake the feeling we're overlooking something. A search on the net reveals other people also claim it supports ranges, while others face the same issue. – Marc Dingena Jul 16 '15 at 21:42
  • You could always contact the author if you really want to know. But there's absolutely nothing in the source code to support ranges that I can see, despite that claim. – Michael Hampton Jul 16 '15 at 21:47
  • I've written an email to @nmaier and pray this sort of user tagging works in comments... – Marc Dingena Jul 16 '15 at 22:11
0

mod_xsendfile works perfectly like this. I've just installed it to test.

<?php
header("X-Sendfile: /tmp/xsftest");
header("Content-Type: application/octet-stream");
header("Content-Disposition: attachment; filename=\"xsftest\"");

serves file generated by $ seq -f %03.0f 001 100 > /tmp/xsftest

$ curl -v -H "Range: bytes=100-151" -H  -v http://myserver/xsf-test.php
> GET /xsf-test.php HTTP/1.1
> User-Agent: curl/7.38.0
> Host: myserver
> Accept: */*
> Range: bytes=100-151
>
< HTTP/1.1 206 Partial Content
< Date: Sun, 19 Jul 2015 21:52:43 GMT
* Server Apache is not blacklisted
< Server: Apache
< Content-Disposition: attachment; filename="xsftest"
< Last-Modified: Sun, 19 Jul 2015 21:47:01 GMT
< ETag: "60981c0-190-51b415c7afad3"
< Content-Length: 52
< Content-Range: bytes 100-151/400
< Content-Type: application/octet-stream
<
026
027
028
029
030
031
032
033
034
035
036
037
038
$

(i've stripped sensitive content)

So your application not seeking in the unbuffered files might be because your app either does not support it, or the file format does not support it.

Or, as it seems, you're using wrong browser. Unfortunatelly. As per this bug report Chrome has issues seeking in mp3 files, which do not have the info tag. I have tried to use the above mentioned php script to serve an mp3 file downloaded from your site and following html (ugly, but does the job).

<!html5>

<audio src="xsf-test.php" controls autoplay loop>
<p>Your browser does not support the <code>audio</code> element </p>
</audio>

Firefox 39 and IE 11 play and seek flawlesly. Chrome 43 does not.

4
  • Thanks for your answer. The app (jPlayer) supports ranges. The file format (mp3) supports ranges. I'm using Chrome 43 indeed. I have made this work using nginx's X-Accel-Redirect, and the 206 response contains byte range and content length correctly, but on apache the content length is 0. Do you think it's because of the apache configuration? I'm using Dreamhost VPS if that matters. – Marc Dingena Jul 20 '15 at 8:56
  • If x-accel-redirect works for you, then stick with it. I tried on your next.tjoonz.com, and there is no seeking in the audio in my Chrome (FF seeks fine). So it really seems to be an issue with Chrome and mp3. Tested the setup mentioned in the answer and seeking works as well, except for Chrome. You are getting wrong headers because you use 206 in your response. As you mention in your question, if you return 200 (as I do in the answer), Ranges work as expected. – Fox Jul 20 '15 at 12:31
  • I'm working on that staging site every day, so there is no guarantee that your experience is because of the browser lacking support or just me working on the source code. Admittedly, I make a lot of mistakes and if my free time runs out, I just leave the site broken overnight. For me, seeking in Chrome with mp3 works. When I jump to a new position in the audio, the audio stops, a new 206 response is received with a new byte-range and audio starts playing again from that position. Is that not how it's supposed to work? If you want, we can take this to chat. – Marc Dingena Jul 20 '15 at 12:43
  • For me the problem arise when I'm not specifying second number in range header. For example "Range: bytes=100-" or "Range: bytes=0-". But with your header like "Range: bytes=100-123" it works fine. – Nikita Aug 3 '15 at 15:00
0

FYI, I ran into this same issue and found this page in a Google search.

X-SendFile relies on Apache to handle the partial content requests. There is no need to set the headers and response code in php.

The issue (which I shared) is that you are setting the HTTP response code as well as the Content-Length and Content-Range headers in the php code. The solution is to take all of that out. It breaks the xsendfile module for some reason and it ends up with the described behavior (sending a 0 content length).

With all of that removed, Apache reads the correct file content and generates these headers and the response code (200 or 206) correctly based on the Range header (or lack thereof) supplied in the client request.

3
  • 1
    To be able to support seeking to unbuffered positions, I needed to return 206 and Content-Length for Byte-Range requests. Otherwise Chrome would simply stop audio playback at the selected position until the buffering caught up to the position and resume audio playback. – Marc Dingena Jun 8 '16 at 20:33
  • You were not understanding my comment. 206 and Content-Length ARE returned for range requests by x-sendfile. It's just that the x-sendfile module needs to handle setting those itself. When you set them in the php interpreter before x-sendfile picks up the processing (after your script ends and the module detects the X-Sendfile header) then it breaks and gives you the behavior that you saw (no content returned and a content-length of 0). The issue is that you assumed you needed to do more than you really did while using this module. I made the same mistake. – jreitnauer Jun 8 '16 at 21:29
  • To further clarify, I had the exact same requirements as you. I needed to serve full files and partial files. To do that, I was doing something very similar to your above code, parsing the Range header and then setting 200 or 206 as well as Content-Length and Content-Range before setting the X-SendFile header and my script ending. The solution is to not set any of that. Just setting X-SendFile is enough. The module picks up the processing, looks at the Range header, and sets all of that correctly as well as reading out the correct partial file content. It won't work when setting those in php. – jreitnauer Jun 8 '16 at 21:37

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.