斷點續傳靠 HTTP Range 標頭實現:
Range: bytes=1000- 表示從第 1001 byte 開始傳輸。206 Partial Content,附上 Content-Range 與 Content-Length。fseek() 移動檔案指標,從指定位置讀取並輸出。<?php
// 配置
$filePath = __DIR__ . '/downloads/large-video.mp4';
$fileName = 'large-video.mp4';
if (!is_file($filePath)) {
http_response_code(404);
exit('檔案不存在');
}
$fileSize = filesize($filePath);
$start = 0;
$end = $fileSize - 1;
// 解析 Range 標頭
if (isset($_SERVER['HTTP_RANGE'])) {
$range = $_SERVER['HTTP_RANGE'];
// 格式: bytes=start-end 或 bytes=start-
if (preg_match('/^bytes=(\d+)-(\d*)$/', $range, $matches)) {
$start = (int)$matches[1];
$end = $matches[2] !== '' ? (int)$matches[2] : $fileSize - 1;
// 邊界檢查
if ($start >= $fileSize || $end >= $fileSize || $start > $end) {
header('HTTP/1.1 416 Range Not Satisfiable');
header("Content-Range: bytes */$fileSize");
exit;
}
}
}
// 設定回應標頭
header('Content-Type: video/mp4');
header('Accept-Ranges: bytes');
header('Content-Length: ' . ($end - $start + 1));
if ($start > 0 || $end < $fileSize - 1) {
// 部分內容
header('HTTP/1.1 206 Partial Content');
header("Content-Range: bytes $start-$end/$fileSize");
} else {
// 完整內容
header('HTTP/1.1 200 OK');
}
// 快取控制(可選)
header('Cache-Control: public, max-age=3600');
header("Content-Disposition: inline; filename=\"$fileName\"");
// 開啟檔案並輸出
$fp = fopen($filePath, 'rb');
fseek($fp, $start);
$bufferSize = 1024 * 8; // 8KB
$remaining = $end - $start + 1;
while (!feof($fp) && $remaining > 0) {
$readSize = min($bufferSize, $remaining);
echo fread($fp, $readSize);
$remaining -= $readSize;
// 釋放輸出緩衝,避免大檔案佔用記憶體
if (ob_get_level()) ob_flush();
flush();
}
fclose($fp);
class ResumableDownload {
private $filePath;
private $fileName;
private $mimeType;
private $bufferSize = 8192;
public function __construct(string $filePath, string $fileName = '', string $mimeType = '') {
$this->filePath = $filePath;
$this->fileName = $fileName ?: basename($filePath);
$this->mimeType = $mimeType ?: $this->guessMimeType();
}
public function send(): void {
if (!is_file($this->filePath)) {
$this->error(404, 'File not found');
}
$fileSize = filesize($this->filePath);
$etag = md5($this->filePath . $fileSize . filemtime($this->filePath));
// If-None-Match / If-Modified-Since 快取驗證
if (isset($_SERVER['HTTP_IF_NONE_MATCH']) && $_SERVER['HTTP_IF_NONE_MATCH'] === $etag) {
$this->error(304, 'Not Modified');
}
if (isset($_SERVER['HTTP_IF_MODIFIED_SINCE'])) {
$since = strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']);
if ($since >= filemtime($this->filePath)) {
$this->error(304, 'Not Modified');
}
}
// 處理 Range
$ranges = $this->parseRangeHeader($fileSize);
// 設定通用標頭
header("ETag: $etag");
header('Last-Modified: ' . gmdate('D, d M Y H:i:s', filemtime($this->filePath)) . ' GMT');
header('Accept-Ranges: bytes');
header('Content-Type: ' . $this->mimeType);
header("Content-Disposition: inline; filename=\"{$this->fileName}\"");
if ($ranges === null) {
// 無 Range:傳送完整檔案
header('HTTP/1.1 200 OK');
header("Content-Length: $fileSize");
$this->outputFile(0, $fileSize - 1);
} elseif (count($ranges) === 1) {
// 單一 Range
[$start, $end] = $ranges[0];
header('HTTP/1.1 206 Partial Content');
header("Content-Range: bytes $start-$end/$fileSize");
header("Content-Length: " . ($end - $start + 1));
$this->outputFile($start, $end);
} else {
// 多重 Range (multipart/byteranges) - 略,實務較少用
$this->sendMultipartRanges($ranges, $fileSize);
}
}
private function parseRangeHeader(int $fileSize): ?array {
if (empty($_SERVER['HTTP_RANGE'])) return null;
$rangeHeader = $_SERVER['HTTP_RANGE'];
if (!preg_match('/^bytes=(.+)$/', $rangeHeader, $m)) return null;
$ranges = [];
foreach (explode(',', $m[1]) as $part) {
$part = trim($part);
if (preg_match('/^(\d*)-(\d*)$/', $part, $m2)) {
$start = $m2[1] !== '' ? (int)$m2[1] : 0;
$end = $m2[2] !== '' ? (int)$m2[2] : $fileSize - 1;
// suffix-byte-range: bytes=-500 (最後 500 bytes)
if ($m2[1] === '' && $m2[2] !== '') {
$start = $fileSize - $end;
$end = $fileSize - 1;
}
if ($start < 0) $start = 0;
if ($end >= $fileSize) $end = $fileSize - 1;
if ($start <= $end) {
$ranges[] = [$start, $end];
}
}
}
return $ranges ?: null;
}
private function outputFile(int $start, int $end): void {
$fp = fopen($this->filePath, 'rb');
fseek($fp, $start);
$remaining = $end - $start + 1;
while (!feof($fp) && $remaining > 0) {
$read = min($this->bufferSize, $remaining);
echo fread($fp, $read);
$remaining -= $read;
if (ob_get_level()) ob_flush();
flush();
}
fclose($fp);
}
private function guessMimeType(): string {
$ext = strtolower(pathinfo($this->filePath, PATHINFO_EXTENSION));
$map = [
'mp4' => 'video/mp4', 'mkv' => 'video/x-matroska',
'mp3' => 'audio/mpeg', 'wav' => 'audio/wav',
'pdf' => 'application/pdf', 'zip' => 'application/zip',
'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg',
'png' => 'image/png', 'gif' => 'image/gif',
];
return $map[$ext] ?? 'application/octet-stream';
}
private function error(int $code, string $msg): void {
http_response_code($code);
exit($msg);
}
}
// 使用方式
$downloader = new ResumableDownload('/data/videos/course01.mp4');
$downloader->send();
瀏覽器原生 `
| 問題 | 原因 | 解決 |
|---|---|---|
| 拖曳進度條無效/重新載入 | Server 未回應 206 或缺少 Accept-Ranges | 確認回應標頭正確,啟用 Accept-Ranges: bytes |
| 大檔案記憶體耗盡 | 一次讀取整個檔案或輸出緩衝未釋放 | 分塊讀取 + ob_flush()/flush(),或用 X-Sendfile/NGINX X-Accel-Redirect |
| Nginx 代理切斷傳輸 | proxy_buffering / proxy_max_temp_file_size 限制 | 設定 proxy_buffering off; 或調大快取參數 |
| 多執行緒下載工具失敗 | 不支援多 Range 或合併邏輯錯誤 | 實作 multipart/byteranges 或引導使用單線程 |