1

I spent a week to find right answer on this question. 'Right' I mean absolutely conform to existing web-standards, reliable and performance effective. Finally, I've found the solution.

All what I've found on StackOverflow (Downloading large files reliably in PHP, How to download large files through PHP script) is not works for me:

  1. Both solutions are not support of range requests. It makes them not working for video and audio streaming and download resuming;

  2. All examples have nothing about caching and performance;

PHP 7.0 code tested with desktop versions of Chrome, Safari, Opera and Firefox. Vivaldi test was not successful.

Oleg Uryutin
  • 413
  • 5
  • 15
  • If you want a right answer explain that what do you mean by large? 100MB, 1GB, 10GB, 100GB, 1TB, 1PB? – I am the Most Stupid Person Dec 15 '17 at 07:40
  • Large means more than PHP memory size. I've tested it with 800Mb max. – Oleg Uryutin Dec 15 '17 at 07:42
  • Possible duplicate of [How to download large files through PHP script](https://stackoverflow.com/questions/6527811/how-to-download-large-files-through-php-script) – I am the Most Stupid Person Dec 15 '17 at 07:44
  • It's not a duplicate. I tried to use code from your reference. It's not works for audio and video. – Oleg Uryutin Dec 15 '17 at 07:53
  • I don't really get this question. You're asking for a method to send files in PHP that is not described in the 19 answers you've linked? What'd be the point? Why don't you, instead, share the code you have and describe the specific problem you're facing so you can get it fixed? – Álvaro González Dec 15 '17 at 09:00
  • "absolutely conform to existing web-standards, reliable" - and then you use **error suppression**. Oh the irony. This is poorly-written code that should not be posted. – svgrafov Dec 15 '17 at 09:26
  • Sorry, but my goal was to show simple solution that works. It works. I hope my example will be useful for people who (like me) read all previous 19 answers but didn't get working code. I did remove all error suppression from code (see my comment below) but don't ask me to change spaces to tabs, please ;) – Oleg Uryutin Dec 17 '17 at 07:03

1 Answers1

1
const STDOUT_CHUNK_SIZE = 128 * 1024; // Buffer size to send data to browser. MUST be less then 1/3 of PHP memory size
const CACHE_EXP_SEC = 1800;  // Cache expire time is 30 min.

$fileName = "large_video.mp4";
$contentSize = filesize($fileName);
$isAttachment = false;  // false allows to use a file as inline element of web page 

// Parse range request. Browser asks for part of file
if (isset($_SERVER["HTTP_RANGE"])) {
  list($units, $range) = explode("=", $_SERVER["HTTP_RANGE"], 2);
  if ($units !== "bytes") {
    http_response_code(416); // Requested Range Not Satisfiable
    exit;
  }
  $range = explode(",", $range, 2)[0]; // Get only first range. You can improve this ;)
  list($from, $to) = explode("-", $range, 2);
  $to = empty($to) ? ($contentSize - 1) : min(abs((int)$to), ($contentSize - 1));
  $from = (empty($from) || $to < abs((int)$from)) ? 0 : max(abs((int)$from), 0);
}
else {
  // Request for whole content
  $from = 0;
  $to = $contentSize - 1;
}

// Set response headers
if ($from > 0 || $to < ($contentSize - 1))
{
  http_response_code(206); // Partial Content
  header("Content-Type: video/mp4"));
  header("Content-Range: bytes $from-$to/$contentSize");
  header("Content-Length: " . ($from - $to + 1));
}
else {
  $etag = md5($file);  // Content is immutable but file name can be changed
  if (isset($_SERVER["HTTP_IF_NONE_MATCH"]) && trim($_SERVER["HTTP_IF_NONE_MATCH"]) === $etag) {
    http_response_code(304); // Not Modified
    setCacheHeaders($etag);
    exit;
  }

  http_response_code(200);  // Ok
  header("Content-Type: video/mp4"));
  header("Content-Length: $contentSize");
  if ($isAttachment) header("Content-Disposition: attachment; filename=\"$fileName\"");
  else header("Content-Disposition: inline");

  header("Accept-Ranges: bytes");
  setCacheHeaders($etag);
}

// Send response to client
if ($file = fopen($fileName, "rb")) {
  fseek($file, $from);
  $counter = $from;
  set_time_limit(0);
  while (!feof($file) && $counter <= $to) {
    $bytesToRead = STDOUT_CHUNK_SIZE;
    if ($counter + $bytesToRead > $to) $bytesToRead = $to - $counter + 1;
    $data = fread($file, $bytesToRead);
    $counter += $bytesToRead;
    echo $data;
    flush();
  }
fclose($file);

function setCacheHeaders(string $etag, bool $cacheEnabled = true, bool $public = true)
{
  if ($cacheEnabled) {
    header("ETag: $etag");
    $scope = $public ? "public" : "private";
    $sec = CACHE_EXP_SEC;
    $age = ($sec >= 0) ? ", max-age=$sec, s-maxage=$sec" : "";
    header("Cache-Control: $scope$age, no-transform");
  }
  else header("Cache-Control: no-cache, no-store, must-revalidate");
}
Oleg Uryutin
  • 413
  • 5
  • 15
  • Why all the @'s? – TimBrownlaw Dec 15 '17 at 07:43
  • Just my style ))) You can omit it. – Oleg Uryutin Dec 15 '17 at 07:45
  • I've added some more comments. – Oleg Uryutin Dec 15 '17 at 07:55
  • 4
    Using `@` isn't simply a case of style, it's a case of hiding errors – Mark Baker Dec 15 '17 at 08:17
  • 2
    Ok. I see my joke about style was a little bit irrelevant. I did remove all '@'s from code. Unfortunately, PHP is not better tool to build really reliable code. Some standard functions have production even differ from documentation. In my case I just tried to prevent any unpredictable behavior destroying output. Chrome browser is very tolerant for any unexpected responses from server, bur Safari not. I agree, @s are not good solution. It's better to use try-catches or custom error handlers but my goal was to show simple working code. – Oleg Uryutin Dec 17 '17 at 06:50