From 40bcbdba4802082564cfad08e4b5f4184db232ca Mon Sep 17 00:00:00 2001 From: obscuremind Date: Fri, 11 Sep 2026 16:19:50 +0100 Subject: [PATCH] fix(delivery): correct byte ranges for VOD and catch-up seeking - Timeshift TS seeking returned the wrong bytes: the start file was estimated from the average file size, files before it were never skipped (an empty `if` where a `continue` belonged) and the in-file offset came out negative, so every catch-up seek streamed from the archive's first byte; the range end was ignored too. The served range is now mapped exactly onto the minute files (first file from its .offset). - The timeshift throttle never reset its chunk counter (vod.php's copy did), so past vod_limit_perc every chunk paused a whole second. - A shared HttpRange parser (RFC 7233 single ranges) replaces the inline copies: suffix ranges (bytes=-N) died on PHP 8 arithmetic, an unsatisfiable range answered with the resource's range instead of `bytes */size`, and Accept-Ranges said "0-" instead of "bytes". VOD never reads past a bounded range's end. - Direct-proxy VOD reads the source's headers with cURL: get_headers() goes through the https stream wrapper, which does not work under PHP-FPM here, and returned Content-Length as an array after a redirect. The upstream is asked for exactly the requested range, and curl's verbose output no longer goes to the FPM log on every request. - The limiter spares the requesting connection by uuid (VOD/timeshift). Co-Authored-By: Claude Opus 5 --- src/Public/stream/timeshift.php | 128 ++++++++----------- src/Public/stream/vod.php | 181 ++++++++------------------- src/Streaming/Delivery/HttpRange.php | 83 ++++++++++++ tests/Unit/HttpRangeTest.php | 43 +++++++ 4 files changed, 230 insertions(+), 205 deletions(-) create mode 100644 src/Streaming/Delivery/HttpRange.php create mode 100644 tests/Unit/HttpRangeTest.php diff --git a/src/Public/stream/timeshift.php b/src/Public/stream/timeshift.php index 6ece1f8c..efc9bd5a 100644 --- a/src/Public/stream/timeshift.php +++ b/src/Public/stream/timeshift.php @@ -10,6 +10,7 @@ use XcVm\Infrastructure\Redis\RedisManager; use XcVm\Streaming\AsyncFileOperations; use XcVm\Streaming\Auth\StreamAuth; use XcVm\Streaming\Auth\StreamAuthMiddleware; +use XcVm\Streaming\Delivery\HttpRange; use XcVm\Streaming\Lifecycle\ShutdownHandler; /** @@ -135,6 +136,8 @@ if ($rUserInfo) { DatabaseFactory::connect(); } + $rConnection = null; + switch ($rExtension) { case 'm3u8': if ($rSettings['redis_handler']) { @@ -188,7 +191,7 @@ if ($rUserInfo) { generateError('LINE_CREATE_FAIL'); } - StreamAuth::validateConnections($rUserInfo, null, null, $rIP, $rUserAgent); + StreamAuth::validateConnections($rUserInfo, null, null, $rIP, $rUserAgent, $rTokenData['uuid']); if ($rSettings['redis_handler']) { RedisManager::closeInstance(); @@ -285,7 +288,7 @@ if ($rUserInfo) { generateError('LINE_CREATE_FAIL'); } - StreamAuth::validateConnections($rUserInfo, null, null, $rIP, $rUserAgent); + StreamAuth::validateConnections($rUserInfo, null, null, $rIP, $rUserAgent, $rTokenData['uuid']); if ($rSettings['redis_handler']) { RedisManager::closeInstance(); @@ -307,64 +310,17 @@ if ($rUserInfo) { touch(CONS_TMP_PATH . $rTokenData['uuid']); header('Content-Type: video/mp2t'); $rConSpeedFile = DIVERGENCE_TMP_PATH . $rTokenData['uuid']; - $rLength = $rSize = getLength($rQueue) - $rOffset; + // The response is the queued minute files back to back, the first one + // from its .offset (a partial first minute). + $rSize = getLength($rQueue) - $rOffset; $rBitrate = ($rSize * 0.008) / ($rDuration * 60); - header('Accept-Ranges: 0-' . $rLength); - $rStart = 0; - $rEnd = $rSize - 1; - - if (empty($_SERVER['HTTP_RANGE'])) { - } else { - $rRangeStart = $rStart; - $rRangeEnd = $rEnd; - list(, $rRange) = explode('=', $_SERVER['HTTP_RANGE'], 2); - - if (strpos($rRange, ',') === false) { - - - - - if ($rRange == '-') { - $rRangeStart = $rSize - substr($rRange, 1); - } else { - $rRange = explode('-', $rRange); - $rRangeStart = $rRange[0]; - $rRangeEnd = (isset($rRange[1]) && is_numeric($rRange[1]) ? $rRange[1] : $rSize); - } - - $rRangeEnd = ($rEnd < $rRangeEnd ? $rEnd : $rRangeEnd); - - if (!($rRangeEnd < $rRangeStart || $rSize - 1 < $rRangeStart || $rSize <= $rRangeEnd)) { - $rStart = $rRangeStart; - $rEnd = $rRangeEnd; - $rLength = $rEnd - $rStart + 1; - header('HTTP/1.1 206 Partial Content'); - } else { - header('HTTP/1.1 416 Requested Range Not Satisfiable'); - header('Content-Range: bytes ' . $rStart . '-' . $rEnd . '/' . $rSize); - - exit(); - } - } else { - header('HTTP/1.1 416 Requested Range Not Satisfiable'); - header('Content-Range: bytes ' . $rStart . '-' . $rEnd . '/' . $rSize); - - exit(); - } + $rServe = HttpRange::sendHeaders(HttpRange::parse($_SERVER['HTTP_RANGE'] ?? null, $rSize), $rSize); + if ($rServe === null) { + exit(); // 416 already sent } - - header('Content-Range: bytes ' . $rStart . '-' . $rEnd . '/' . $rSize); - header('Content-Length: ' . $rLength); - $rStartFrom = 0; - - if (0 >= $rStart) { - } else { - $rStartFrom = floor($rStart / ($rSize / count($rQueue))); - } - - $rFirstFile = false; - $rSeekTo = 0; - $rSizeToDate = 0; + [$rStart, $rEnd] = $rServe; + $rLength = $rEnd - $rStart + 1; + $rRemaining = $rLength; $rDownloadBytes = $rBitrate * 125; $rDownloadBytes += $rDownloadBytes * $rSettings['vod_bitrate_plus'] * 0.01; $rLastCheck = $rTimeChecked = $rTimeStart = time(); @@ -381,26 +337,40 @@ if ($rUserInfo) { $rApplyLimit = false; - foreach ($rQueue as $rKey => $rItem) { - $rSizeToDate += $rItem['filesize']; - - if ($rFirstFile || 0 >= $rStartFrom) { - } else { - if ($rKey < $rStartFrom) { - } else { - $rFirstFile = true; - $rSeekTo = $rStart - $rSizeToDate; - } + // Map the served byte range onto the files: skip every file that ends + // before $rStart, seek into the one it falls in, stop after $rEnd. (A + // seek used to estimate the start file from the average file size, never + // skip the files before it, and seek to a negative offset — so every + // catch-up seek streamed from the archive's first byte.) + $rPosition = 0; // response offset of the current file's first servable byte + foreach (array_values($rQueue) as $rIndex => $rItem) { + if ($rRemaining <= 0) { + break; + } + $rFileStart = ($rIndex === 0 ? $rOffset : 0); + $rFileBytes = $rItem['filesize'] - $rFileStart; + if ($rFileBytes <= 0) { + continue; + } + if ($rPosition + $rFileBytes <= $rStart) { + $rPosition += $rFileBytes; // wholly before the range + continue; } $rFP = fopen($rItem['filename'], 'rb'); - fseek($rFP, $rSeekTo + $rOffset); - $rOffset = 0; + if (!$rFP) { + break; + } + fseek($rFP, $rFileStart + max(0, $rStart - $rPosition)); + $rPosition += $rFileBytes; - while (!feof($rFP)) { - $rPosition = ftell($rFP); - $rResponse = stream_get_line($rFP, $rBuffer); + while (!feof($rFP) && 0 < $rRemaining) { + $rResponse = stream_get_line($rFP, (int) min($rBuffer, $rRemaining)); + if ($rResponse === false || $rResponse === '') { + break; + } echo $rResponse; + $rRemaining -= strlen($rResponse); $rBytesRead += strlen($rResponse); $i++; @@ -411,8 +381,11 @@ if ($rUserInfo) { } if (0 < $rDownloadBytes && $rApplyLimit && ceil($rDownloadBytes / $rBuffer) <= $i) { - // Use efficient sleep instead of blocking sleep - AsyncFileOperations::efficientSleep(1000000); // 1 second with better CPU usage + // Throttled to the recording's bitrate: one second's worth of + // chunks, then a second's pause. The count restarts after each + // pause — without the reset every later chunk paused a second. + AsyncFileOperations::efficientSleep(1000000); + $i = 0; } if (30 > time() - $rTimeStart) { } else { @@ -466,12 +439,9 @@ if ($rUserInfo) { } } - if (!is_resource($rFP)) { - } else { + if (is_resource($rFP)) { fclose($rFP); } - - $rSeekTo = 0; } } } else { diff --git a/src/Public/stream/vod.php b/src/Public/stream/vod.php index 3a4c399c..94d04810 100644 --- a/src/Public/stream/vod.php +++ b/src/Public/stream/vod.php @@ -9,6 +9,7 @@ use XcVm\Infrastructure\Redis\RedisManager; use XcVm\Streaming\AsyncFileOperations; use XcVm\Streaming\Auth\StreamAuth; use XcVm\Streaming\Auth\StreamAuthMiddleware; +use XcVm\Streaming\Delivery\HttpRange; use XcVm\Streaming\Lifecycle\ShutdownHandler; /** @@ -173,7 +174,7 @@ if ($rChannelInfo) { generateError('LINE_CREATE_FAIL'); } - StreamAuth::validateConnections($rUserInfo, $rIsHMAC, $rIdentifier, $rIP, $rUserAgent); + StreamAuth::validateConnections($rUserInfo, $rIsHMAC, $rIdentifier, $rIP, $rUserAgent, $rTokenData['uuid']); if ($rSettings['redis_handler']) { RedisManager::closeInstance(); @@ -255,54 +256,15 @@ if ($rChannelInfo) { } else { $rFP = @fopen($rRequest, 'rb'); $rSize = filesize($rRequest); - $rLength = $rSize; - $rStart = 0; - $rEnd = $rSize - 1; - header('Accept-Ranges: 0-' . $rLength); - - if (empty($_SERVER['HTTP_RANGE'])) { - } else { - $rRangeStart = $rStart; - $rRangeEnd = $rEnd; - list(, $rRange) = explode('=', $_SERVER['HTTP_RANGE'], 2); - - if (strpos($rRange, ',') === false) { - - - - - if ($rRange == '-') { - $rRangeStart = $rSize - substr($rRange, 1); - } else { - $rRange = explode('-', $rRange); - $rRangeStart = $rRange[0]; - $rRangeEnd = (isset($rRange[1]) && is_numeric($rRange[1]) ? $rRange[1] : $rSize); - } - - $rRangeEnd = ($rEnd < $rRangeEnd ? $rEnd : $rRangeEnd); - - if (!($rRangeEnd < $rRangeStart || $rSize - 1 < $rRangeStart || $rSize <= $rRangeEnd)) { - $rStart = $rRangeStart; - $rEnd = $rRangeEnd; - $rLength = $rEnd - $rStart + 1; - fseek($rFP, $rStart); - header('HTTP/1.1 206 Partial Content'); - } else { - header('HTTP/1.1 416 Requested Range Not Satisfiable'); - header('Content-Range: bytes ' . $rStart . '-' . $rEnd . '/' . $rSize); - - exit(); - } - } else { - header('HTTP/1.1 416 Requested Range Not Satisfiable'); - header('Content-Range: bytes ' . $rStart . '-' . $rEnd . '/' . $rSize); - - exit(); - } + $rServe = HttpRange::sendHeaders(HttpRange::parse($_SERVER['HTTP_RANGE'] ?? null, $rSize), $rSize); + if ($rServe === null) { + exit(); // 416 already sent + } + [$rStart, $rEnd] = $rServe; + $rLength = $rEnd - $rStart + 1; + if (0 < $rStart) { + fseek($rFP, $rStart); } - - header('Content-Range: bytes ' . $rStart . '-' . $rEnd . '/' . $rSize); - header('Content-Length: ' . $rLength); $rLastCheck = $rTimeStart = $rTimeChecked = time(); $rBytesRead = 0; $rBuffer = $rSettings['read_buffer_size']; @@ -318,7 +280,9 @@ if ($rChannelInfo) { $rApplyLimit = false; while (!feof($rFP) && ($p = ftell($rFP)) <= $rEnd) { - $rResponse = stream_get_line($rFP, $rBuffer); + // Never read past the range end: a bounded request (a player probing + // bytes=0-1, or fetching an index near the end) got a whole buffer. + $rResponse = stream_get_line($rFP, (int) min($rBuffer, $rEnd - $p + 1)); $i++; if (!$rApplyLimit && $rLimitAt <= $o * $rBuffer) { @@ -392,88 +356,53 @@ if ($rChannelInfo) { exit(); } } else { - - $opts = array( - 'http' => array( - 'max_redirects' => '20', - 'method' => "GET", - 'timeout' => 122, - 'user_agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.16; rv:101.0) Gecko/20100101 Firefox/101.0' - ) - ); - $context = stream_context_create($opts); - $rHeaders = get_headers($rDirectProxy, 1, $context); - $rContentType = (is_array($rHeaders['Content-Type']) ? $rHeaders['Content-Type'][count($rHeaders['Content-Type']) - 1] : $rHeaders['Content-Type']); - $rSize = $rLength = $rHeaders['Content-Length']; - - if (0 < $rLength && in_array($rContentType, array('video/mp4', 'video/x-matroska', 'video/x-msvideo', 'video/3gpp', 'video/x-flv', 'video/x-ms-wmv', 'video/quicktime', 'video/mp2t', 'video/mpeg', 'application/octet-stream'))) { - if (!$rHeaders['Location']) { - } else { - if (is_array($rHeaders['Location'])) { - $tmp = array_reverse($rHeaders['Location']); - $rDirectProxy = $tmp[0]; - } else { - $rDirectProxy = $rHeaders['Location']; + // Direct-proxy VOD: relay the source. Its size and type are read with cURL + // — get_headers() goes through the https stream wrapper, which does not + // work under PHP-FPM here (every https source failed), and returned + // Content-Length as an array after a redirect. The final response's + // headers are kept; the body is not downloaded. + $rSourceUA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.16; rv:101.0) Gecko/20100101 Firefox/101.0'; + $rHeaders = array(); + $ch = curl_init($rDirectProxy); + curl_setopt_array($ch, array( + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_MAXREDIRS => 20, + CURLOPT_CONNECTTIMEOUT => 10, + CURLOPT_TIMEOUT => 122, + CURLOPT_SSL_VERIFYPEER => 0, + CURLOPT_USERAGENT => $rSourceUA, + CURLOPT_HEADERFUNCTION => static function ($rHandle, $rLine) use (&$rHeaders) { + if (preg_match('#^HTTP/\S+\s+\d+#i', $rLine)) { + $rHeaders = array(); // a redirect hop: only the final response counts + } elseif (strpos($rLine, ':') !== false) { + [$rName, $rValue] = explode(':', $rLine, 2); + $rHeaders[strtolower(trim($rName))] = trim($rValue); } - unset($rHeaders['Location']); - } + return strlen($rLine); + }, + CURLOPT_WRITEFUNCTION => static function () { + return 0; // headers only: stop at the first body byte + }, + )); + curl_exec($ch); + $rDirectProxy = (string) (curl_getinfo($ch, CURLINFO_EFFECTIVE_URL) ?: $rDirectProxy); + curl_close($ch); + $rSize = intval($rHeaders['content-length'] ?? 0); + $rContentType = strtolower(trim(explode(';', (string) ($rHeaders['content-type'] ?? ''))[0])); + + if (0 < $rSize && in_array($rContentType, array('video/mp4', 'video/x-matroska', 'video/x-msvideo', 'video/3gpp', 'video/x-flv', 'video/x-ms-wmv', 'video/quicktime', 'video/mp2t', 'video/mpeg', 'application/octet-stream'), true)) { header('Content-Type: ' . $rContentType); - header('Accept-Ranges: bytes'); - $rStart = 0; - $rEnd = $rSize - 1; - - if (empty($_SERVER['HTTP_RANGE'])) { - } else { - $rRangeStart = $rStart; - $rRangeEnd = $rEnd; - list(, $rRange) = explode('=', $_SERVER['HTTP_RANGE'], 2); - - if (strpos($rRange, ',') === false) { - - - - - if ($rRange == '-') { - $rRangeStart = $rSize - substr($rRange, 1); - } else { - $rRange = explode('-', $rRange); - $rRangeStart = $rRange[0]; - $rRangeEnd = (isset($rRange[1]) && is_numeric($rRange[1]) ? $rRange[1] : $rSize); - } - - $rRangeEnd = ($rEnd < $rRangeEnd ? $rEnd : $rRangeEnd); - - if (!($rRangeEnd < $rRangeStart || $rSize - 1 < $rRangeStart || $rSize <= $rRangeEnd)) { - $rStart = $rRangeStart; - $rEnd = $rRangeEnd; - $rLength = $rEnd - $rStart + 1; - header('HTTP/1.1 206 Partial Content'); - } else { - header('HTTP/1.1 416 Requested Range Not Satisfiable'); - header('Content-Range: bytes ' . $rStart . '-' . $rEnd . '/' . $rSize); - - exit(); - } - } else { - header('HTTP/1.1 416 Requested Range Not Satisfiable'); - header('Content-Range: bytes ' . $rStart . '-' . $rEnd . '/' . $rSize); - - exit(); - } + $rServe = HttpRange::sendHeaders(HttpRange::parse($_SERVER['HTTP_RANGE'] ?? null, $rSize), $rSize); + if ($rServe === null) { + exit(); // 416 already sent } + [$rStart, $rEnd] = $rServe; - header('Content-Range: bytes ' . $rStart . '-' . $rEnd . '/' . $rSize); - header('Content-Length: ' . $rLength); $ch = curl_init(); - - if (!isset($_SERVER['HTTP_RANGE'])) { - } else { - preg_match('/bytes=(\\d+)-(\\d+)?/', $_SERVER['HTTP_RANGE'], $rMatches); - $rOffset = intval($rMatches[1]); - $rLength = $rSize - $rOffset - 1; - $rHeaders = array('Range: bytes=' . $rOffset . '-' . ($rOffset + $rLength)); - curl_setopt($ch, CURLOPT_HTTPHEADER, $rHeaders); + if (0 < $rStart || $rEnd < $rSize - 1) { + // Ask the source for exactly the range this response promises. + curl_setopt($ch, CURLOPT_HTTPHEADER, array('Range: bytes=' . $rStart . '-' . $rEnd)); } if (512 * 1024 * 1024 >= $rSize) { @@ -489,10 +418,10 @@ if ($rChannelInfo) { } curl_setopt($ch, CURLOPT_BUFFERSIZE, 10 * 1024 * 1024); - curl_setopt($ch, CURLOPT_VERBOSE, 1); curl_setopt($ch, CURLOPT_TIMEOUT, 0); curl_setopt($ch, CURLOPT_URL, $rDirectProxy); curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); + curl_setopt($ch, CURLOPT_USERAGENT, $rSourceUA); curl_setopt($ch, CURLOPT_HEADER, false); curl_setopt($ch, CURLOPT_FRESH_CONNECT, true); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, 0); diff --git a/src/Streaming/Delivery/HttpRange.php b/src/Streaming/Delivery/HttpRange.php new file mode 100644 index 00000000..dadd1952 --- /dev/null +++ b/src/Streaming/Delivery/HttpRange.php @@ -0,0 +1,83 @@ += $rSize || $rLast < $rFirst) { + return false; + } + return array($rFirst, $rLast); + } + + /** + * Send the headers for a resolved range: 206 + Content-Range for a range, 200 + * for the whole resource, or 416 + `bytes *\/size` for an unsatisfiable one. + * + * @param array{0:int,1:int}|null|false $rRange What parse() returned. + * @param int $rSize Resource size in bytes. + * @return array{0:int,1:int}|null [first, last] to send, or null after a 416 (send no body). + */ + public static function sendHeaders($rRange, int $rSize): ?array { + header('Accept-Ranges: bytes'); + if ($rRange === false) { + header('HTTP/1.1 416 Requested Range Not Satisfiable'); + header('Content-Range: bytes */' . $rSize); + return null; + } + if ($rRange === null) { + header('Content-Length: ' . $rSize); + return array(0, $rSize - 1); + } + header('HTTP/1.1 206 Partial Content'); + header('Content-Range: bytes ' . $rRange[0] . '-' . $rRange[1] . '/' . $rSize); + header('Content-Length: ' . ($rRange[1] - $rRange[0] + 1)); + return $rRange; + } +} diff --git a/tests/Unit/HttpRangeTest.php b/tests/Unit/HttpRangeTest.php new file mode 100644 index 00000000..d544cc25 --- /dev/null +++ b/tests/Unit/HttpRangeTest.php @@ -0,0 +1,43 @@ +assertNull(HttpRange::parse(null, 1000)); + $this->assertNull(HttpRange::parse('', 1000)); + $this->assertNull(HttpRange::parse('items=0-9', 1000), 'another unit is ignored'); + } + + public function testBoundedAndOpenRanges(): void { + $this->assertSame([0, 99], HttpRange::parse('bytes=0-99', 1000)); + $this->assertSame([0, 1], HttpRange::parse('bytes=0-1', 1000), 'a player probing the first bytes'); + $this->assertSame([500, 999], HttpRange::parse('bytes=500-', 1000)); + $this->assertSame([10, 999], HttpRange::parse('bytes=10-5000', 1000), 'end clamped to the size'); + $this->assertSame([10, 20], HttpRange::parse(' Bytes = 10 - 20 ', 1000)); + } + + public function testSuffixRangeIsTheLastBytes(): void { + $this->assertSame([900, 999], HttpRange::parse('bytes=-100', 1000)); + $this->assertSame([0, 999], HttpRange::parse('bytes=-5000', 1000), 'longer than the resource: all of it'); + } + + public function testUnsatisfiableRanges(): void { + $this->assertFalse(HttpRange::parse('bytes=1000-', 1000), 'starts past the end'); + $this->assertFalse(HttpRange::parse('bytes=5-2', 1000), 'end before start'); + $this->assertFalse(HttpRange::parse('bytes=-0', 1000), 'empty suffix'); + $this->assertFalse(HttpRange::parse('bytes=0-1,5-6', 1000), 'multipart is not served'); + $this->assertFalse(HttpRange::parse('bytes=abc', 1000)); + $this->assertFalse(HttpRange::parse('bytes=-', 1000)); + $this->assertFalse(HttpRange::parse('bytes=0-9', 0), 'nothing to serve'); + } +}