mirror of
https://github.com/Vateron-Media/XC_VM.git
synced 2026-10-31 19:03:43 +01:00
A stream takes the best variant yt-dlp finds, which for a YouTube broadcast is up to 1080p through the stream's proxy. A stream now has a "Maximum Resolution" (Advanced tab): with 720 in it yt-dlp is asked for `best[height<=720]/best`, the platform's own variant of at most that height with no transcoding, and the best one when there is none that small. Empty is `best`, as before. It is option 22 of `streams_arguments` (migration 092), stored per stream like the proxy. Its category is `resolve`, so it is never part of an ffmpeg command. An edit through the admin API keeps it. The last open item of #345. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AneTzAHPPkYWPQjwyZ2td9
156 lines
7.4 KiB
PHP
156 lines
7.4 KiB
PHP
<?php
|
|
|
|
use XcVm\Core\Process\ProcessRunner;
|
|
use XcVm\Core\Util\StreamUtils;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
/**
|
|
* @covers \XcVm\Core\Util\StreamUtils
|
|
*/
|
|
final class StreamUtilsTest extends TestCase {
|
|
|
|
public function testCustomOrderPutsInputArgumentsFirst() {
|
|
$this->assertSame(-1, StreamUtils::customOrder('-i input.ts', 'something'));
|
|
$this->assertSame(1, StreamUtils::customOrder('-c:v libx264', '-i input.ts'));
|
|
}
|
|
|
|
public function testDetectXcVmMatchesKnownStreamPaths() {
|
|
$this->assertTrue(StreamUtils::detectXC_VM('http://host/live/user/123'));
|
|
}
|
|
|
|
public function testDetectXcVmRejectsUnrelatedPaths() {
|
|
$this->assertFalse(StreamUtils::detectXC_VM('http://host/dashboard'));
|
|
}
|
|
|
|
public function testSanitizeSegmentNameStripsSeparators() {
|
|
$this->assertSame('seg_0.ts', StreamUtils::sanitizeSegmentName('seg_0.ts'));
|
|
$this->assertSame('....etcpasswd', StreamUtils::sanitizeSegmentName('../../etc/passwd'));
|
|
// URL-encoded traversal: "%2e%2e%2fpasswd" → "../passwd" → strip "/" → "..passwd".
|
|
$this->assertSame('..passwd', StreamUtils::sanitizeSegmentName('%2e%2e%2fpasswd'));
|
|
}
|
|
|
|
public function testContainerMimeType() {
|
|
$this->assertSame('video/mp4', StreamUtils::containerMimeType('mp4'));
|
|
$this->assertSame('video/x-matroska', StreamUtils::containerMimeType('mkv'));
|
|
$this->assertSame('video/mp2t', StreamUtils::containerMimeType('ts'));
|
|
$this->assertSame('application/octet-stream', StreamUtils::containerMimeType('xyz'));
|
|
$this->assertSame('application/octet-stream', StreamUtils::containerMimeType(''));
|
|
}
|
|
|
|
public function testTimeshiftStartTimestamp() {
|
|
$this->assertSame(1700000000, StreamUtils::timeshiftStartTimestamp('1700000000'));
|
|
$this->assertSame(mktime(13, 0, 0, 1, 1, 2025), StreamUtils::timeshiftStartTimestamp('20250101-13'));
|
|
$this->assertSame(mktime(13, 30, 0, 1, 1, 2025), StreamUtils::timeshiftStartTimestamp('2025-01-01:13-30'));
|
|
}
|
|
|
|
public function testSegmentRetryBudget() {
|
|
$this->assertSame(20, StreamUtils::segmentRetryBudget(10, 5)); // seg_time*2 wins (the fixed bug)
|
|
$this->assertSame(30, StreamUtils::segmentRetryBudget(3, 30)); // configured wins
|
|
$this->assertSame(20, StreamUtils::segmentRetryBudget(3, 0)); // 0 → default floor 20
|
|
}
|
|
|
|
public function testProxyUrlAddsHttpSchemeFfmpegNeeds() {
|
|
$this->assertSame('', StreamUtils::proxyURL(''));
|
|
$this->assertSame('http://1.2.3.4:8080', StreamUtils::proxyURL('1.2.3.4:8080'));
|
|
$this->assertSame('http://u:p@h:1', StreamUtils::proxyURL('http://u:p@h:1'));
|
|
$this->assertSame('socks5://h:1', StreamUtils::proxyURL('socks5://h:1'));
|
|
}
|
|
|
|
public function testStreamProxyArgumentReachesFfmpegWithHttpScheme() {
|
|
$rProxy = ['argument_key' => 'proxy', 'argument_cat' => 'fetch', 'argument_wprotocol' => 'http', 'argument_type' => 'text', 'argument_cmd' => '-http_proxy "%s"'];
|
|
$this->assertSame(['-http_proxy "http://9.9.9.9:3128"'], StreamUtils::getArguments([$rProxy + ['value' => '9.9.9.9:3128']], 'https', 'fetch'));
|
|
$this->assertSame(['-http_proxy "http://u:p@h:1"'], StreamUtils::getArguments([$rProxy + ['value' => 'http://u:p@h:1']], 'https', 'fetch'));
|
|
}
|
|
|
|
public function testParseStreamUrlRunsYtDlpThroughTheStreamProxyWithoutAShell() {
|
|
if (!defined('YOUTUBE_BIN')) {
|
|
define('YOUTUBE_BIN', '/bin/yt-dlp');
|
|
}
|
|
$rCalls = [];
|
|
ProcessRunner::useCapturer(function (array $rArgv) use (&$rCalls) {
|
|
$rCalls[] = $rArgv;
|
|
return [0, "https://media.example/live.m3u8\nhttps://media.example/second\n"];
|
|
});
|
|
try {
|
|
$rPage = 'https://www.youtube.com/watch?v=x';
|
|
$this->assertSame('https://media.example/live.m3u8', StreamUtils::parseStreamURL($rPage, '1.2.3.4:8080'));
|
|
$this->assertSame(['--proxy', 'http://1.2.3.4:8080'], array_slice($rCalls[0], array_search('--proxy', $rCalls[0], true), 2));
|
|
// The page URL is one argument, after --, never an option.
|
|
$this->assertSame(['--', $rPage], array_slice($rCalls[0], -2));
|
|
|
|
StreamUtils::parseStreamURL($rPage);
|
|
$this->assertNotContains('--proxy', $rCalls[1]);
|
|
|
|
// Not a platform page: yt-dlp is not run.
|
|
$this->assertSame('http://iptv.example/live.ts', StreamUtils::parseStreamURL('http://iptv.example/live.ts', '1.2.3.4:8080'));
|
|
$this->assertCount(2, $rCalls);
|
|
} finally {
|
|
ProcessRunner::useCapturer(null);
|
|
}
|
|
}
|
|
|
|
public function testParseStreamUrlFallsBackFromMwebToAndroidVr() {
|
|
if (!defined('YOUTUBE_BIN')) {
|
|
define('YOUTUBE_BIN', '/bin/yt-dlp');
|
|
}
|
|
$rClients = [];
|
|
$rMwebWorks = false;
|
|
ProcessRunner::useCapturer(function (array $rArgv) use (&$rClients, &$rMwebWorks) {
|
|
$rClients[] = $rClient = $rArgv[array_search('--extractor-args', $rArgv, true) + 1];
|
|
return [0, ($rClient === 'youtube:player_client=mweb') === $rMwebWorks ? "https://media.example/{$rClient}.m3u8\n" : ''];
|
|
});
|
|
try {
|
|
$rPage = 'https://www.youtube.com/watch?v=x';
|
|
// No JS runtime: mweb prints nothing, android_vr resolves it.
|
|
$this->assertSame('https://media.example/youtube:player_client=default,android_vr.m3u8', StreamUtils::parseStreamURL($rPage));
|
|
$this->assertSame(['youtube:player_client=mweb', 'youtube:player_client=default,android_vr'], $rClients);
|
|
|
|
// mweb resolves it: android_vr is never asked.
|
|
$rClients = [];
|
|
$rMwebWorks = true;
|
|
$this->assertSame('https://media.example/youtube:player_client=mweb.m3u8', StreamUtils::parseStreamURL($rPage));
|
|
$this->assertSame(['youtube:player_client=mweb'], $rClients);
|
|
} finally {
|
|
ProcessRunner::useCapturer(null);
|
|
}
|
|
}
|
|
|
|
/** A stream's "Maximum Resolution" (#345): yt-dlp is asked for the best variant no taller, and the best of all without one. */
|
|
public function testParseStreamUrlAsksYtDlpForNoMoreThanTheStreamsMaximumResolution() {
|
|
if (!defined('YOUTUBE_BIN')) {
|
|
define('YOUTUBE_BIN', '/bin/yt-dlp');
|
|
}
|
|
$rFormats = [];
|
|
ProcessRunner::useCapturer(function (array $rArgv) use (&$rFormats) {
|
|
$rFormats[] = $rArgv[array_search('-f', $rArgv, true) + 1];
|
|
return [0, "https://media.example/live.m3u8\n"];
|
|
});
|
|
try {
|
|
$rPage = 'https://www.youtube.com/watch?v=x';
|
|
StreamUtils::parseStreamURL($rPage, '', 720);
|
|
StreamUtils::parseStreamURL($rPage);
|
|
StreamUtils::parseStreamURL($rPage, '', -5);
|
|
$this->assertSame(['best[height<=720]/best', 'best', 'best'], $rFormats);
|
|
} finally {
|
|
ProcessRunner::useCapturer(null);
|
|
}
|
|
|
|
// The option is yt-dlp's, never ffmpeg's: its category is neither fetch nor transcode.
|
|
$rOption = ['argument_key' => 'max_height', 'argument_cat' => 'resolve', 'argument_wprotocol' => 'http', 'argument_type' => 'text', 'argument_cmd' => '', 'value' => '720'];
|
|
$this->assertSame([], StreamUtils::getArguments([$rOption], 'https', 'fetch'));
|
|
$this->assertSame([], StreamUtils::getArguments([$rOption], 'https', 'transcode'));
|
|
$rRow = (string) file_get_contents(MAIN_HOME . 'migrations/database/up/092_add_stream_max_height_argument.sql');
|
|
$this->assertStringContainsString("(22, 'resolve', 'Maximum Resolution',", $rRow);
|
|
$this->assertStringContainsString("(22, 'resolve', 'Maximum Resolution',", (string) file_get_contents(MAIN_HOME . 'bin/install/database.sql'));
|
|
|
|
// Whatever starts or probes a stream hands the resolver the stream's own limit.
|
|
foreach (['Domain/Stream/StreamProcess.php' => 3, 'Cli/Commands/ScannerCommand.php' => 1] as $rFile => $rCalls) {
|
|
preg_match_all('/^.*StreamUtils::parseStreamURL\(.*$/m', (string) file_get_contents(MAIN_HOME . $rFile), $rLines);
|
|
$this->assertCount($rCalls, $rLines[0], $rFile);
|
|
foreach ($rLines[0] as $rLine) {
|
|
$this->assertStringContainsString("['max_height']", $rLine, $rFile);
|
|
}
|
|
}
|
|
}
|
|
}
|