Streaming Media (and download resuming) with PHP

Written by: NetworkError, on 14-05-2008 21:05
Last update: 15-10-2008 21:30
Published in: Public, Technical Wootness
Views: 10618

When you download a file or stream a media file from a web server, the good ones will allow you to request portions of the file at a time. This gives you the ability to scan forward and backward through your media file or pause/resume a file download. You can do the same thing streaming a file through a PHP script.

You're probably asking why I want to do this in the first place. Well... Let's say I want to build a jukebox on my web server. All my friends come and login. They browser the music, make their selection, and the site gives them an m3u playlist file. They open the file in WinAmp or VLC and rock out for hours to my excellent collection.

Sounds great, yeah? But I don't want to give the world access to my music. So I keep my music in a non-web-accessible directory on my web server (or NAS). To get my music from the server to the client, I'll write a script that will act as a middle man. My m3u files will contain URLs with file ids and tokens associated with your login. When you pass a file id and a token, I'll stream your file. If you exceed your quota or your token expires, I stream you a 404 mp3 asking you to renew your session. (I could allow the user to re-activate their token, or just require them to re-download an m3u file with a new token.)

The URLs in my m3u files will look something like this:
#EXTM3U
#EXTINF:232,Bob Marley And The Wailers - Is This Love
http://networkerror.org/play_that_funky_music.php?file_id=1&token=1234
#EXTINF:429,Bob Marley And The Wailers - No Woman No Cry (Live)
http://networkerror.org/play_that_funky_music.php?file_id=2&token=1234

With all that out of the way, let's talk about how to go about streaming these files through PHP.

 

Note:  You'll have to have the PHP function "http_request_headers" available for this to work.

 

First, we need to understand the HTTP headers that go into a streaming media request.

When VLC requests a media file from a web server, the request headers look something like this (minus the requested file header):

Connection: Close
Host: networkerror.org
Icy-MetaData: 1
Range: bytes=0-
User-Agent: VLC media player - version 0.8.6d Janus - (c) 1996-2007 the VideoLAN team

Let's break it down one header at a time and talk about what it all means.

Connection: Close - No HTTP 1.1 pipelining here, baby. Just give them the goods and close the connection.

Host: networkerror.org - That's me. :)

Icy-MetaData: 1 - No bloody idea what this is for.

Range: bytes=0- - They're requesting a byte range starting with 0. They didn't specify an end byte so we give them the entire file.

User-Agent: VLC media player - version 0.8.6d Janus - (c) 1996-2007 the VideoLAN team - The client's user agent string.

The response headers from the server look something like this:

HTTP/1.1 206 Partial Content
Accept-Ranges: bytes
Content-Type: audio/mpeg
Content-Length: 12345
Content-Range: bytes 0-12345

Let's break that down one header at a time.

HTTP/1.1 206 Partial Content - This differs from the usual 200 Found response. This indicates that we're able to send bits and pieces of the requested file.

Accept-Ranges: bytes - This tells the client how to tell us what chunks of the file it wants. i.e. Give us a byte range and we'll give you the appropriate bytes.

Content-Type: audio/mpeg - Mime type. Because web clients don't care about file extenssions. They use this to determie file types. You can find this by running 'file -i [filename]' on your server, assuming it's a Linux box. It might be better to just keep a list of mime types for supported file extensions.

Content-Length: 12345 - The length of the file in bytes. (Or, if we're not sending the whole file, the length of the Content-Range we're sending.) This tells the client how long the file (song/movie) is.

Content-Range: bytes 0-12345 - The range of bytes we're returning in this response.

OK. Nothing too hard there. So now we just need to write a script to read the byte range header, read the file, set the response headers, and return the requested file's byte range.

At a high level, your class for handling this business should maybe look something like this (proof of concept):

<?

// This class will open a stream and allow scanning.
class STREAM
{

// File details.
var $filename = false; // File name (and path)
var $file = false; // File handle.
var $size = 0; // File size.
var $range = array(); // Range of bytes to return.
var $type; // File mime type.

// 404 File?
var $error_file = false; // Do you want a 404 mp3? :)

// Other stuff.
var $headers; // Apache headers.

// Pass the file you want streamed.
function STREAM($filename = false)
{
$this->headers = apache_request_headers();

if($filename !== false) {
$this->play($filename);
}
}

// What file do you want to play?
function play($filename)
{
$this->filename = $filename;
$this->_openFile();
// Does the file exist? React accordingly.
if($this->file !== false) {
$this->_seek();
$this->_setResponseHeaders();
$this->_streamFile();
} elseif($this->error_file !== false && file_exists($this->error_file)) {
$this->play($this->error_file);
} else {
$this->_setResponseHeaders(true);
}
}

// Set up file handle, size, and mime type.
function _openFile()
{
// Open file for reading in binary mode.
$this->file = fopen($this->filename, 'rb');

if($this->file !== false) {
// Determine size.
$this->size = sprintf('%u', filesize($this->filename));

// Determine mime type. This only works on linux.
$type = exec('file -i '.escapeshellarg($this->filename));
$type = explode(':', $type);
$this->type = trim($type[1]);

return true;
} else {
return false;
}
}

// Look for seek header and seek accordingly.
function _seek()
{
// Are they seeking or seekable?
if(!empty($this->headers['Range'])) {

$range = explode('=', $this->headers['Range']);

// Are they using the right seek method?
// If so, figure out what range to stream.
if(strtolower($range[0]) == 'bytes') {

$range = explode('-', $range[1]);

$range[0] = trim($range[0]);
$range[1] = trim($range[1]);

if(empty($range[0]) === true) {
$range[0] = 0;
}
$range[0] = (int)$range[0]; // Sanitize.

if(empty($range[1]) === true) {
$range[1] = $this->size;
}
$range[1] = (int)$range[1]; // Sanitize.

} else {
$range[0] = 0;
$range[1] = $this->size;
}
} else {
$range[0] = 0;
$range[1] = $this->size;
}
$this->range = $range;
}

// Set up all the nifty response headers.
function _setResponseHeaders($error = false)
{
if($error == false) {
header('HTTP/1.1 206 Partial Content'); // Allows scanning in a stream.
header('Accept-Ranges: bytes'); // Allows scanning in a stream based on byte count.
header('Content-Type: '.$this->type); // Launches the correct player.
header('Content-Length: '.($this->range[1] - $this->range[0])); // This allows the player to know the song length or remaining time.
header('Content-Range: bytes '.$this->range[0].'-'.$this->range[1]); // This tells the player what byte we're starting with.
} else {
header('HTTP/1.1 404 Not Found'); // Couldn't find the file.
}
}

// Stream the actual file segment requested.
function _streamFile()
{
// Seek to the correct possition in the file and output the stream.
if($this->range[0] != 0) {
fseek($this->file, $this->range[0], SEEK_SET);
}
while(ftell($this->file) < $this->range[1] && !feof($this->file)) {
echo fread($this->file, ($this->range[1] - ftell($this->file)));
}
}

}

// Set script timeout. Just in case this is a long file. (This should be like... 2 hours or something.)
set_time_limit(0);
$player = new STREAM();
$player->play('test.mp3');

(Remember to leave the end '?>' off. It's better to let the PHP parser hit the end of the file. It prevents whitespace output bugs.)

Nifty, eh? Want to see the proof-of-concept in action?

Read more... User comments (1)   |   Print   |   Send to friend

Print