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 aRange
request, which is the intended setup. Script returns206 Partial Content
four times (see screenshot and intended script below). All of them haveContent-Length
set incorrectly as0
. - Load
stream.php?id=9966
directly in browser. CausesGET
to be sent without aRange
request. Script returns200 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 aRange
request. Causes script to return200 OK
header.Content-Length
is correctly returned, as isContent-Range
. - Force
stream.php
to always return200 OK
, even when returning aContent-Range
response. WhenGET
ting with aRange
request, bothContent-Length
andContent-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".
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();
}
Best Answer
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 asinternal
in the nginx configuration. This both enables X-Accel-Redirect and serves 404 errors to anyone who attempts to access the static file directly.