Skip to content

Instantly share code, notes, and snippets.

@RomanStone
Last active December 20, 2020 20:38
Show Gist options
  • Save RomanStone/4dc74631e81aadcab0568f2466e8a6b7 to your computer and use it in GitHub Desktop.
Save RomanStone/4dc74631e81aadcab0568f2466e8a6b7 to your computer and use it in GitHub Desktop.
Serving media files for A/Video Playback "pseudo chunked" transfer encoding
<?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);
@RomanStone
Copy link
Author

RomanStone commented Apr 10, 2019

Quickstart guide, usage with apache:

Save this file in sub folder, like localhost/watch/index.php
Then add .htacess: localhost/watch/.htacess

<IfModule mod_rewrite.c>
	RewriteEngine On
	RewriteRule ^index\.php$ - [L]
	RewriteCond %{REQUEST_FILENAME} !-f
	RewriteCond %{REQUEST_FILENAME} !-d
	RewriteRule . index.php [L]
</IfModule>

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 vlc

add 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 offset 0-,
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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment