Last active
December 20, 2020 20:38
-
-
Save RomanStone/4dc74631e81aadcab0568f2466e8a6b7 to your computer and use it in GitHub Desktop.
Serving media files for A/Video Playback "pseudo chunked" transfer encoding
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
ini_set('display_errors', 0); | |
ini_set('display_startup_errors', 0); | |
ini_set('html_errors', 0); | |
ini_set('output_buffering', 0); | |
ini_set('zlib.output_compression', 0); | |
ini_set('implicit_flush', 1); | |
ini_set('ignore_user_abort', 1); | |
ini_set('max_execution_time', 0); | |
if (!ini_get('date.timezone')) { | |
date_default_timezone_set('UTC'); | |
} | |
// SET FOLDER PATH HERE | |
$folder_path = strtr(realpath(dirname(__FILE__) . '/../storage'), '\\', '/'); | |
// FORMAT EXT:MIME (not all tested) | |
$known_extension = array( | |
'3gp' => 'video/3gpp', 'aac' => 'audio/x-aac', | |
'aiff' => 'audio/aiff', 'asf' => 'video/x-ms-asf', | |
'avs' => 'video/avs-video', 'f4v' => 'video/x-f4v', | |
'flv' => 'video/x-flv', 'h261' => 'video/h261', | |
'h263' => 'video/h263', 'h264' => 'video/h264', | |
'm1v' => 'video/mpeg', 'm2a' => 'audio/mpeg', | |
'm2v' => 'video/mpeg', 'm3a' => 'audio/mpeg', | |
'm4a' => 'audio/mp4', 'm4v' => 'video/x-m4v', | |
'mk3d' => 'video/x-matroska', 'mka' => 'audio/x-matroska', | |
'mks' => 'video/x-matroska', 'mkv' => 'video/x-matroska', | |
'mov' => 'video/quicktime', 'mp2' => 'audio/mpeg', | |
'mp2a' => 'audio/mpeg', 'mp3' => 'audio/mpeg3', | |
'mp4' => 'video/mp4', 'mp4a' => 'audio/mp4', | |
'mp4v' => 'video/mp4', 'mpa' => 'audio/mpeg', | |
'mpe' => 'video/mpeg', 'mpeg' => 'video/mpeg', | |
'mpg' => 'audio/mpeg', 'mpg4' => 'video/mp4', | |
'mpga' => 'audio/mpeg', 'oga' => 'audio/ogg', | |
'ogg' => 'audio/ogg', 'ogv' => 'video/ogg', | |
'qt' => 'video/quicktime', 'ra' => 'audio/x-pn-realaudio', | |
'ram' => 'audio/x-pn-realaudio', 'ts' => 'video/MP2T', | |
'vob' => 'video/x-ms-vob', 'wav' => 'audio/wav', | |
'weba' => 'audio/webm', 'webm' => 'video/webm', | |
'wma' => 'audio/x-ms-wma', 'wmv' => 'video/x-ms-wmv', | |
); | |
$time_now = time(); | |
$http_proto = filter_input(INPUT_SERVER, 'SERVER_PROTOCOL'); | |
if (!$http_proto || !in_array($http_proto, array('HTTP/1.0', 'HTTP/1.1'))) { | |
$http_proto = 'HTTP/1.1'; | |
} | |
$req_uri = filter_input(INPUT_SERVER, 'REQUEST_URI'); | |
// VALIDATE | |
if (!$req_uri) { | |
// PANIC, quit | |
header("{$http_proto} 400 Bad Request"); | |
trigger_error("Unknown Error. Bad REQUEST_URI"); | |
exit(1); | |
} | |
$info = array_merge(array('path' => ''), parse_url($req_uri)); | |
// VALIDATE | |
if (!$info['path']) { | |
// BAD REQUEST, quit | |
header("{$http_proto} 400 Bad Request"); | |
trigger_error("Bad request, uri={$req_uri}"); | |
exit(1); | |
} | |
$info = array_merge(array('basename' => '', 'filename' => '', 'extension' => ''), pathinfo($info['path']), $info); | |
foreach($info as $k => $v) { | |
if ($v !== '' && (($dec = rawurldecode($v)) !== $v)) { | |
$info[$k] = $dec; | |
} | |
} | |
// VALIDATE | |
if (!$info['filename']) { | |
// BAD REQUEST, quit | |
header("{$http_proto} 400 Bad Request"); | |
trigger_error("No file requested, path={$info['path']}"); | |
exit(1); | |
} | |
// VALIDATE | |
if (!$info['extension'] || !in_array(strtolower($ext = $info['extension']), array_keys($known_extension))) { | |
// WRONG TYPE, quit | |
header("{$http_proto} 400 Bad Request"); | |
trigger_error("File type mismach, file={$info['basename']}"); | |
exit(1); | |
} | |
//$folder_path = strtr($folder_path, '\\', '/'); | |
// VALIDATE | |
if (!is_dir($folder_path)) { | |
// FOLDER NOT EXIST, quit | |
header("{$http_proto} 404 Not Found"); | |
trigger_error("Folder not found {$folder_path}"); | |
exit(1); | |
} | |
$file_name = $info['basename']; | |
$file_path = "{$folder_path}/{$file_name}"; | |
// VALIDATE | |
if (!file_exists($file_path)) { | |
// FILE NOT FOUND, quit | |
header("{$http_proto} 404 Not Found"); | |
trigger_error("File not found {$file_name}"); | |
exit(1); | |
} | |
$file_size = (int)round(filesize($file_path), 0, PHP_ROUND_HALF_UP); | |
$file_time = filemtime($file_path); | |
// VALIDATE | |
if (!$file_size) { | |
header("{$http_proto} 500 Internal Server Error"); | |
trigger_error("Bad file {$file_name}"); | |
exit(1); | |
} | |
// DEFAULTS | |
$resp_max_age = '2592000'; | |
$resp_status = "{$http_proto} 206 Partial Content"; | |
$resp_start = 0; | |
$file_size = $file_size; | |
$resp_end = $file_size; | |
$resp_total = $file_size; | |
$resp_len = $file_size; | |
$resp_mime = $known_extension[$ext]; | |
$start_requested = 0; | |
$end_requested = 0; | |
$client = new \stdclass(); | |
$client->is_vlc = 0; | |
$client->is_win = 0; | |
$client->is_mac = 0; | |
$client->is_ipad = 0; | |
$client->is_iphone = 0; | |
$client->is_android = 0; | |
$client->is_linux = 0; | |
$client->ip = filter_input(INPUT_SERVER, 'REMOTE_ADDR'); | |
$client->ua = filter_input(INPUT_SERVER, 'HTTP_USER_AGENT'); | |
if ($client->ua) { | |
if (strtoupper(substr($client->ua, 0, 4)) == 'VLC/') { | |
$client->is_vlc = 1; | |
} | |
$regex = '/(?:' | |
. '(?P<linux>Linux)|' | |
. '(?P<windows>Windows)|' | |
. '(?P<android>Android)|' | |
. '(?P<mac>Macintosh)|' | |
. '(?P<ipad>iPad)|' | |
. '(?P<iphone>iPhone)' | |
.')/'; | |
if (preg_match($regex, $client->ua, $m)) { | |
$m = array_merge(array('android' => '','windows' => '','ipad' => '','iphone' => '','mac' => ''), $m); | |
if ($m['linux']) { $client->is_linux = 1; } | |
if ($m['windows']) { $client->is_win = 1; } | |
if ($m['mac']) { $client->is_mac = 1; } | |
if ($m['android']) { $client->is_android = 1; } | |
if ($m['ipad']) { $client->is_ipad = 1; } | |
if ($m['iphone']) { $client->is_iphone = 1; } | |
} | |
} | |
$req_range = filter_input(INPUT_SERVER, 'HTTP_RANGE'); | |
$errno = 0; | |
if ($req_range) { | |
$req_range = preg_replace('/\s+/', '', $req_range); | |
$regex = '/bytes\=(?:' | |
. '(?P<multi>[0-9\.]+\-[0-9\.]+,[0-9\.].+?)|' | |
. '(?P<nostart>\-[0-9\.]+)|' | |
. '(?P<both>[0-9\.]+\-[0-9\.]+)|' | |
. '(?P<start>[0-9\.]+\-)' | |
. ')$/'; | |
if (preg_match($regex, $req_range, $m)) { | |
$m = array_merge(array('multi' => '','nostart' => '','both' => '','start' => ''), $m); | |
if ($m['multi'] || $m['nostart']) { | |
// BAD RANGE, quit | |
header("{$http_proto} 416 Requested Range Not Satisfiable"); | |
header('Accept-Ranges: bytes'); | |
exit(1); | |
} | |
if ($m['both']) { | |
$tmp = explode('-', $m['both'], 2); | |
$m['start'] = $tmp[0]; | |
$m['end'] = $tmp[1]; | |
} | |
if ($m['start']) { | |
$m['end'] = ''; | |
} | |
$resp_start = (int)round($m['start'], 0, PHP_ROUND_HALF_UP); | |
$start_requested = 1; | |
if (strlen($m['end']) > 0 && is_numeric($m['end'])) { | |
$resp_end = (int)round($m['end'], 0, PHP_ROUND_HALF_UP); | |
$end_requested = 1; | |
} | |
} | |
else { | |
$errstr = "Range[{$req_range}] request malformed"; | |
trigger_error(sprintf('"%s" "%s" "%s" "%s"', $client->ip, $file_name, $errstr, $client->ua)); | |
$errno = 1; | |
} | |
} | |
if ($start_requested) { | |
if ($resp_start > 0) { | |
if ($resp_start > $resp_end) { | |
$errstr = "Range[{$req_range}] start({$resp_start}) bigger than end({$resp_end})"; | |
trigger_error(sprintf('"%s" "%s" "%s" "%s"', $client->ip, $file_name, $errstr, $client->ua)); | |
$errno = 3; | |
} | |
else { | |
if ($resp_start == $resp_end) { | |
$errstr = "Range[{$req_range}] start({$resp_start}) equal end({$resp_end})"; | |
trigger_error(sprintf('"%s" "%s" "%s" "%s"', $client->ip, $file_name, $errstr, $client->ua)); | |
$errno = 2; | |
} | |
} | |
} | |
} | |
if ($end_requested) { | |
if ($resp_end > $file_size) { | |
$errstr = "Range[{$req_range}] end({$resp_end}) bigger than file size({$file_size})"; | |
trigger_error(sprintf('"%s" "%s" "%s" "%s"', $client->ip, $file_name, $errstr, $client->ua)); | |
$errno = 4; | |
} | |
} | |
if ($errno) { | |
if ($errno == 1) { | |
$errno = 0; | |
$start_requested = 1; | |
$end_requested = 0; | |
$resp_start = 0; | |
$resp_end = $file_size; | |
} | |
else { | |
if (in_array($errno, array(2, 3))) { | |
// BAD RANGE, quit | |
header("{$http_proto} 416 Requested Range Not Satisfiable"); | |
header('Accept-Ranges: bytes'); | |
header("Content-Range: bytes */{$resp_total}"); | |
exit(1); | |
} | |
else { | |
if ($errno == 4) { | |
$errno = 0; | |
$start_requested = 1; | |
$end_requested = 1; | |
$resp_end = $file_size - 1; | |
} | |
} | |
} | |
} | |
if (!$errno) { | |
if ($start_requested) { | |
if ($end_requested) { | |
$resp_end += 1; | |
$resp_len = (int)round($resp_end - $resp_start, 0, PHP_ROUND_HALF_UP); | |
} | |
else { | |
$megabyte = 1024 * 1024; | |
$mbytes = (int)round($megabyte * 2); | |
$sample_size = (int)round($resp_start + $mbytes, 0, PHP_ROUND_HALF_UP); | |
if ($file_size > $sample_size) { | |
$resp_end = $sample_size; | |
$resp_len = $sample_size; | |
} | |
else { | |
$resp_len = (int)round($resp_end - $resp_start, 0, PHP_ROUND_HALF_UP); | |
} | |
} | |
} | |
} | |
$end = $resp_end; | |
$resp_end -= 1; | |
$resp_headers = array( | |
'Accept-Ranges' => 'bytes', | |
'Cache-Control' => "public, max-age={$resp_max_age}", | |
'Content-Range' => "bytes {$resp_start}-{$resp_end}/{$resp_total}", | |
'Content-Length' => $resp_len, | |
'Content-Type' => $resp_mime, | |
'Expires' => gmdate('D, d M Y H:i:s \G\M\T', $time_now + $resp_max_age), | |
'Transfer-Encoding' => 'identity' | |
); | |
if ($file_time) { | |
$resp_headers['Last-Modified'] = gmdate('D, d M Y H:i:s \G\M\T', $file_time); | |
} | |
if (!headers_sent()) { | |
header($resp_status); | |
ksort($resp_headers); | |
foreach($resp_headers as $k => $v) { | |
if ($k !== 'status') { | |
header("{$k}: {$v}"); | |
} | |
} | |
} | |
$offset = 0; | |
$chunk_size = 8192; | |
$fp = fopen($file_path, 'rb'); | |
if (!is_resource($fp)) { | |
// I/O ERROR, quit | |
header("{$http_proto} 500 Internal Server Error"); | |
trigger_error("fopen({$file_path}) failed"); | |
exit(1); | |
} | |
if ($resp_start > 0) { | |
fseek($fp, $resp_start); | |
$offset = ftell($fp); | |
$file_size -= $offset; | |
} | |
set_time_limit(0); | |
while (!feof($fp)) { | |
$buffer = fread($fp, $chunk_size); | |
$len = strlen($buffer); | |
$offset += $len; | |
$file_size -= $len; | |
if ($offset > $end) { | |
$diff = $chunk_size - ($offset - $end); | |
$buffer = substr($buffer, 0, $diff); | |
echo $buffer; break; | |
} | |
echo $buffer; | |
$errno = 0; $errstr = ''; | |
if ($file_size <= 0) { | |
$errstr = 'End of file'; | |
$errno = 1; | |
} | |
if ($buffer === FALSE) { | |
$errstr = "fread({$file_name}) error"; | |
trigger_error(sprintf('"%s" "%s" "%s" "%s"', $client->ip, $file_name, $errstr, $client->ua)); | |
$errno = 2; | |
} | |
if (($status = connection_status()) != CONNECTION_NORMAL) { | |
$errstr = "connection_status({$status})"; | |
trigger_error(sprintf('"%s" "%s" "%s" "%s"', $client->ip, $file_name, $errstr, $client->ua)); | |
$errno = 3; | |
} | |
if (($status = connection_aborted()) != 0) { | |
$errstr = "connection_aborted({$status})"; | |
trigger_error(sprintf('"%s" "%s" "%s" "%s"', $client->ip, $file_name, $errstr, $client->ua)); | |
$errno = 4; | |
} | |
if ($errno) { break; } | |
} | |
fclose($fp); | |
exit(-1); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Quickstart guide, usage with apache:
Save this file in sub folder, like
localhost/watch/index.php
Then add .htacess:
localhost/watch/.htacess
Then create folder
http://localhost/storage/
and add video file like so
http://localhost/storage/videoplayback.mp4
Open
http://localhost/watch/videoplayback.mp4
in chrome or vlcadd more files to
/storage/*.mp4
and stream them from
/watch/*.mp4
The idea is to serve file in small parts (2mb/request) when,
client-player requests
Range: 0-
header with undefined end offset0-
,then client requests for more chunks/parts when player's seek position changed (like lazy loading)
this should save a lot of bandwidth when streaming bigger movies/files
because client can close stream any time and throw away MBs or GBs of downloaded video-data
tested in Chrome, FF (default player) and VLC windows and linux
not tested on Mac & iPad/iPhone