Sync all projects

This commit is contained in:
github-actions[bot]
2026-07-13 15:46:17 +00:00
parent d873c0ccbf
commit f35bb526eb
22 changed files with 6318 additions and 3679 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1182
View File
@@ -0,0 +1,1182 @@
<?php
/*
首发地址:https://t.me/iptvofficalgroup
转发或转化本代码,务必注明出处,否则生个孩子没屁眼
使用方法:
M3U列表: /ppv.php?m3u=1
TXT列表: /ppv.php?txt=1
*/
const PPV_STREAMS = 'https://api.ppv.st/api/streams';
const EMBED_FETCH = 'https://embedindia.st/fetch';
const EMBED_ORIGIN = 'https://embedindia.st';
const BROWSER_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36';
const SEC_CH_UA = '"Google Chrome";v="148", "Chromium";v="148", "Not)A;Brand";v="24"';
const SEC_CH_UA_PLATFORM = '"Windows"';
const DEFAULT_ID = 'rally-tv';
const ROOM_TTL = 600;
const SOURCE_TTL = 300;
const TOKEN_MARGIN = 90;
const PLAYLIST_TTL = 600;
function starts_with(string $s, string $prefix): bool
{
return strncmp($s, $prefix, strlen($prefix)) === 0;
}
function ends_with(string $s, string $suffix): bool
{
$n = strlen($suffix);
return $n === 0 || substr($s, -$n) === $suffix;
}
function has_text(string $s, string $needle): bool
{
return strpos($s, $needle) !== false;
}
function path_slug(string $slug): string
{
$parts = [];
foreach (explode('/', trim($slug, '/')) as $part) {
if ($part !== '') {
$parts[] = rawurlencode($part);
}
}
return implode('/', $parts);
}
function embed_url(string $slug): string
{
return EMBED_ORIGIN . '/embed/' . path_slug($slug);
}
function normalize_embed_slug(string $slug): string
{
$slug = trim($slug);
if ($slug === '') {
return '';
}
if (preg_match('~^[a-z][a-z0-9+.-]*://~i', $slug)) {
$path = parse_url($slug, PHP_URL_PATH);
if (!is_string($path)) {
return '';
}
$slug = $path;
} else {
$cut = strcspn($slug, '?#');
$slug = substr($slug, 0, $cut);
}
$slug = trim(urldecode($slug), '/');
if (starts_with($slug, 'embed/')) {
$slug = substr($slug, 6);
}
return trim($slug, '/');
}
function input_slug(string $id): ?string
{
$id = trim($id);
if ($id === '') {
return null;
}
$path = parse_url($id, PHP_URL_PATH);
if (is_string($path) && preg_match('~/embed/(.+)$~', $path, $m)) {
return normalize_embed_slug($m[1]);
}
if (strpos($id, '/') !== false && !preg_match('~^[a-z][a-z0-9+.-]*://~i', $id)) {
return normalize_embed_slug($id);
}
return null;
}
function stream_slug(array $item): string
{
$slug = normalize_embed_slug((string)($item['uri_name'] ?? ''));
if ($slug !== '') {
return $slug;
}
$iframe = input_slug((string)($item['iframe'] ?? ''));
return is_string($iframe) ? $iframe : '';
}
function obj(array $pairs): stdClass
{
$o = new stdClass();
foreach ($pairs as $k => $v) {
$o->{(string)$k} = $v;
}
return $o;
}
function indians_data(): array
{
return [
'timezone' => 'Asia/Shanghai',
'timezoneOffset' => -480,
'userAgent' => BROWSER_UA,
'screenSize' => '1920x1080',
'pixelDepth' => 24,
'touch' => false,
'deviceMemory' => 8,
'platform' => 'Win32',
'touchPoints' => 0,
'hardwareConcurrency' => 8,
'intlDisplayNames' => 'supported',
'mimeTypes' => ['application/pdf', 'text/pdf'],
'plugins' => ['PDF Viewer', 'Chrome PDF Viewer', 'Chromium PDF Viewer', 'Microsoft Edge PDF Viewer', 'WebKit built-in PDF'],
'webgl' => [
'vendor' => 'Google Inc. (Intel)',
'renderer' => 'ANGLE (Intel, Intel(R) UHD Graphics Direct3D11 vs_5_0 ps_5_0, D3D11)',
'params' => obj([
3379 => 16384,
3410 => 8,
3412 => 8,
3413 => 8,
3414 => 24,
3415 => 0,
7936 => 'WebKit',
7937 => 'WebKit WebGL',
7938 => 'WebGL 1.0 (OpenGL ES 2.0 Chromium)',
33901 => obj([0 => 1, 1 => 511]),
33902 => obj([0 => 1, 1 => 1]),
34024 => 16384,
34076 => 16384,
34921 => 16,
34930 => 16,
35660 => 16,
35661 => 32,
35724 => 'WebGL GLSL ES 1.0 (OpenGL ES GLSL ES 1.0 Chromium)',
36347 => 1024,
36348 => 30,
36349 => 1024,
]),
'exts' => [
'ANGLE_instanced_arrays',
'EXT_blend_minmax',
'EXT_clip_control',
'EXT_color_buffer_half_float',
'EXT_depth_clamp',
'EXT_disjoint_timer_query',
'EXT_float_blend',
'EXT_frag_depth',
'EXT_polygon_offset_clamp',
'EXT_shader_texture_lod',
'EXT_texture_compression_bptc',
'EXT_texture_compression_rgtc',
'EXT_texture_filter_anisotropic',
'EXT_texture_mirror_clamp_to_edge',
'EXT_sRGB',
'KHR_parallel_shader_compile',
'OES_element_index_uint',
'OES_fbo_render_mipmap',
'OES_standard_derivatives',
'OES_texture_float',
'OES_texture_float_linear',
'OES_texture_half_float',
'OES_texture_half_float_linear',
'OES_vertex_array_object',
'WEBGL_blend_func_extended',
'WEBGL_color_buffer_float',
'WEBGL_compressed_texture_s3tc',
'WEBGL_compressed_texture_s3tc_srgb',
'WEBGL_debug_renderer_info',
'WEBGL_debug_shaders',
'WEBGL_depth_texture',
'WEBGL_draw_buffers',
'WEBGL_lose_context',
'WEBGL_multi_draw',
],
],
];
}
function indians_hash(): string
{
static $hash = null;
if ($hash !== null) {
return $hash;
}
$json = json_encode(indians_data(), JSON_UNESCAPED_SLASHES);
if (!is_string($json)) {
throw new RuntimeException('bad gid json');
}
$hash = hash('sha256', $json);
return $hash;
}
function embed_headers(string $slug, bool $token): array
{
$headers = [
'Origin: ' . EMBED_ORIGIN,
'Referer: ' . embed_url($slug),
'Accept: */*',
'User-Agent: ' . BROWSER_UA,
'sec-ch-ua: ' . SEC_CH_UA,
'sec-ch-ua-mobile: ?0',
'sec-ch-ua-platform: ' . SEC_CH_UA_PLATFORM,
];
if ($token) {
$headers[] = 'indians: ' . indians_hash();
}
return $headers;
}
function die_soft(int $code, string $text): void
{
http_response_code($code);
header('Content-Type: text/plain; charset=utf-8');
echo $text;
exit;
}
function http_call(string $url, string $method = 'GET', ?string $body = null, array $headers = []): array
{
$ch = curl_init($url);
$bag = [];
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_CONNECTTIMEOUT => 8,
CURLOPT_TIMEOUT => 18,
CURLOPT_USERAGENT => BROWSER_UA,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_SSL_VERIFYHOST => false,
CURLOPT_HEADERFUNCTION => static function ($ch, string $line) use (&$bag): int {
$p = strpos($line, ':');
if ($p !== false) {
$bag[strtolower(trim(substr($line, 0, (int)$p)))] = trim(substr($line, (int)$p + 1));
}
return strlen($line);
},
]);
if ($method !== 'GET') {
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($ch, CURLOPT_POSTFIELDS, $body ?? '');
}
if ($headers) {
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
}
$raw = curl_exec($ch);
$err = curl_error($ch);
$code = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($raw === false || $code < 200 || $code >= 300) {
throw new RuntimeException($err !== '' ? $err : "http {$code}");
}
return [$raw, $bag, $code];
}
function stream_segment(string $url): void
{
@set_time_limit(0);
while (ob_get_level() > 0) {
@ob_end_flush();
}
header('Content-Type: video/MP2T');
header('Cache-Control: public, max-age=3600');
header('X-Accel-Buffering: no');
header('Connection: close');
flush();
$buf = '';
$started = false;
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => false,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_CONNECTTIMEOUT => 8,
CURLOPT_TIMEOUT => 0,
CURLOPT_USERAGENT => BROWSER_UA,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_SSL_VERIFYHOST => false,
CURLOPT_HTTPHEADER => [
'Accept: */*',
'Origin: ' . EMBED_ORIGIN,
'Referer: ' . EMBED_ORIGIN . '/',
'User-Agent: ' . BROWSER_UA,
],
CURLOPT_WRITEFUNCTION => static function ($ch, string $chunk) use (&$buf, &$started): int {
if ($started) {
echo $chunk;
flush();
return strlen($chunk);
}
$buf .= $chunk;
$off = mpegts_offset($buf);
if ($off > 0 || strlen($buf) >= 4096) {
$started = true;
echo substr($buf, $off);
$buf = '';
flush();
}
return strlen($chunk);
},
]);
$ok = curl_exec($ch);
$err = curl_error($ch);
$code = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if (!$started && $buf !== '') {
$off = mpegts_offset($buf);
echo substr($buf, $off);
flush();
}
if ($ok === false || $code < 200 || $code >= 300) {
throw new RuntimeException($err !== '' ? $err : "segment http {$code}");
}
}
function cache_dir(): string
{
$dir = sys_get_temp_dir() . '/ppv_fetch_cache';
if (!is_dir($dir)) {
mkdir($dir, 0775, true);
}
return $dir;
}
function cache_path(string $kind, string $key): string
{
return cache_dir() . '/' . $kind . '-' . sha1($key) . '.json';
}
function cache_get(string $kind, string $key)
{
$file = cache_path($kind, $key);
if (!is_file($file)) {
return null;
}
$raw = file_get_contents($file);
$box = is_string($raw) ? json_decode($raw, true) : null;
if (!is_array($box) || ($box['until'] ?? 0) <= time()) {
@unlink($file);
return null;
}
return $box['data'] ?? null;
}
function cache_set(string $kind, string $key, $data, int $until): void
{
if ($until <= time()) {
return;
}
$file = cache_path($kind, $key);
$tmp = $file . '.' . getmypid() . '.tmp';
file_put_contents($tmp, json_encode(['until' => $until, 'data' => $data], JSON_UNESCAPED_SLASHES), LOCK_EX);
rename($tmp, $file);
}
function enc_varint(int $n): string
{
$s = '';
while (true) {
$b = $n & 0x7f;
$n >>= 7;
if ($n === 0) {
return $s . chr($b);
}
$s .= chr($b | 0x80);
}
}
function get_varint(string $s, int &$i): int
{
$n = 0;
$shift = 0;
$len = strlen($s);
while ($i < $len) {
$b = ord($s[$i++]);
$n |= ($b & 0x7f) << $shift;
if ($b < 0x80) {
return $n;
}
$shift += 7;
if ($shift > 63) {
throw new RuntimeException('bad varint');
}
}
throw new RuntimeException('short varint');
}
function pb_put(int $field, string $value): string
{
return enc_varint(($field << 3) | 2) . enc_varint(strlen($value)) . $value;
}
function pb_read(string $s): array
{
$i = 0;
$out = [];
$len = strlen($s);
while ($i < $len) {
$tag = get_varint($s, $i);
$field = $tag >> 3;
$wire = $tag & 7;
if ($wire !== 2) {
throw new RuntimeException('bad wire');
}
$n = get_varint($s, $i);
if ($i + $n > $len) {
throw new RuntimeException('short field');
}
$out[$field] = substr($s, (int)$i, (int)$n);
$i += $n;
}
return $out;
}
function shift_payload(string $s): string
{
$s = trim($s);
$o = '';
$len = strlen($s);
for ($i = 0; $i < $len; $i++) {
$c = ord($s[$i]);
$o .= ($c >= 33 && $c <= 126) ? chr((($c - 33 + 71) % 94) + 33) : $s[$i];
}
return $o;
}
function u32(int $n): int
{
return $n & 0xffffffff;
}
function le32(string $s, int $i): int
{
return ord($s[$i]) | (ord($s[$i + 1]) << 8) | (ord($s[$i + 2]) << 16) | (ord($s[$i + 3]) << 24);
}
function put32(int $n): string
{
return pack('V', u32($n));
}
function rotl32(int $n, int $bits): int
{
$n = u32($n);
return u32(($n << $bits) | ($n >> (32 - $bits)));
}
function chacha_qr(array &$x, int $a, int $b, int $c, int $d): void
{
$x[$a] = u32($x[$a] + $x[$b]);
$x[$d] = rotl32($x[$d] ^ $x[$a], 16);
$x[$c] = u32($x[$c] + $x[$d]);
$x[$b] = rotl32($x[$b] ^ $x[$c], 12);
$x[$a] = u32($x[$a] + $x[$b]);
$x[$d] = rotl32($x[$d] ^ $x[$a], 8);
$x[$c] = u32($x[$c] + $x[$d]);
$x[$b] = rotl32($x[$b] ^ $x[$c], 7);
}
function chacha_block(string $key, string $nonce, int $counter): string
{
$c = 'expand 32-byte k';
$s = [
le32($c, 0), le32($c, 4), le32($c, 8), le32($c, 12),
le32($key, 0), le32($key, 4), le32($key, 8), le32($key, 12),
le32($key, 16), le32($key, 20), le32($key, 24), le32($key, 28),
u32($counter), le32($nonce, 0), le32($nonce, 4), le32($nonce, 8),
];
$x = $s;
for ($i = 0; $i < 10; $i++) {
chacha_qr($x, 0, 4, 8, 12);
chacha_qr($x, 1, 5, 9, 13);
chacha_qr($x, 2, 6, 10, 14);
chacha_qr($x, 3, 7, 11, 15);
chacha_qr($x, 0, 5, 10, 15);
chacha_qr($x, 1, 6, 11, 12);
chacha_qr($x, 2, 7, 8, 13);
chacha_qr($x, 3, 4, 9, 14);
}
$out = '';
for ($i = 0; $i < 16; $i++) {
$out .= put32($x[$i] + $s[$i]);
}
return $out;
}
function chacha_stream(string $key, string $nonce, int $len, int $counter): string
{
$out = '';
while (strlen($out) < $len) {
$out .= chacha_block($key, $nonce, $counter);
$counter = u32($counter + 1);
}
return substr($out, 0, $len);
}
function pad16(string $s): string
{
$n = strlen($s) % 16;
return $n === 0 ? '' : str_repeat("\0", 16 - $n);
}
function le64(int $n): string
{
$lo = $n & 0xffffffff;
$hi = intdiv($n, 4294967296);
return pack('V2', $lo, $hi);
}
function poly1305_mac(string $msg, string $key): string
{
if (PHP_INT_SIZE < 8) {
throw new RuntimeException('need 64-bit php');
}
$t0 = le32($key, 0);
$t1 = le32($key, 4);
$t2 = le32($key, 8);
$t3 = le32($key, 12);
$r0 = $t0 & 0x3ffffff;
$r1 = (($t0 >> 26) | ($t1 << 6)) & 0x3ffff03;
$r2 = (($t1 >> 20) | ($t2 << 12)) & 0x3ffc0ff;
$r3 = (($t2 >> 14) | ($t3 << 18)) & 0x3f03fff;
$r4 = ($t3 >> 8) & 0x00fffff;
$s1 = $r1 * 5;
$s2 = $r2 * 5;
$s3 = $r3 * 5;
$s4 = $r4 * 5;
$h0 = 0;
$h1 = 0;
$h2 = 0;
$h3 = 0;
$h4 = 0;
$mask = 0x3ffffff;
$len = strlen($msg);
for ($pos = 0; $pos < $len; $pos += 16) {
$n = min(16, $len - $pos);
$block = substr($msg, $pos, $n);
$hibit = 1 << 24;
if ($n < 16) {
$block .= "\x01" . str_repeat("\0", 15 - $n);
$hibit = 0;
}
$t0 = le32($block, 0);
$t1 = le32($block, 4);
$t2 = le32($block, 8);
$t3 = le32($block, 12);
$h0 += $t0 & $mask;
$h1 += (($t0 >> 26) | ($t1 << 6)) & $mask;
$h2 += (($t1 >> 20) | ($t2 << 12)) & $mask;
$h3 += (($t2 >> 14) | ($t3 << 18)) & $mask;
$h4 += (($t3 >> 8) & 0x00ffffff) | $hibit;
$d0 = ($h0 * $r0) + ($h1 * $s4) + ($h2 * $s3) + ($h3 * $s2) + ($h4 * $s1);
$d1 = ($h0 * $r1) + ($h1 * $r0) + ($h2 * $s4) + ($h3 * $s3) + ($h4 * $s2);
$d2 = ($h0 * $r2) + ($h1 * $r1) + ($h2 * $r0) + ($h3 * $s4) + ($h4 * $s3);
$d3 = ($h0 * $r3) + ($h1 * $r2) + ($h2 * $r1) + ($h3 * $r0) + ($h4 * $s4);
$d4 = ($h0 * $r4) + ($h1 * $r3) + ($h2 * $r2) + ($h3 * $r1) + ($h4 * $r0);
$c = $d0 >> 26;
$h0 = $d0 & $mask;
$d1 += $c;
$c = $d1 >> 26;
$h1 = $d1 & $mask;
$d2 += $c;
$c = $d2 >> 26;
$h2 = $d2 & $mask;
$d3 += $c;
$c = $d3 >> 26;
$h3 = $d3 & $mask;
$d4 += $c;
$c = $d4 >> 26;
$h4 = $d4 & $mask;
$h0 += $c * 5;
$c = $h0 >> 26;
$h0 &= $mask;
$h1 += $c;
}
$c = $h1 >> 26;
$h1 &= $mask;
$h2 += $c;
$c = $h2 >> 26;
$h2 &= $mask;
$h3 += $c;
$c = $h3 >> 26;
$h3 &= $mask;
$h4 += $c;
$c = $h4 >> 26;
$h4 &= $mask;
$h0 += $c * 5;
$c = $h0 >> 26;
$h0 &= $mask;
$h1 += $c;
$g0 = $h0 + 5;
$c = $g0 >> 26;
$g0 &= $mask;
$g1 = $h1 + $c;
$c = $g1 >> 26;
$g1 &= $mask;
$g2 = $h2 + $c;
$c = $g2 >> 26;
$g2 &= $mask;
$g3 = $h3 + $c;
$c = $g3 >> 26;
$g3 &= $mask;
$g4 = $h4 + $c - (1 << 26);
if ($g4 >= 0) {
$h0 = $g0;
$h1 = $g1;
$h2 = $g2;
$h3 = $g3;
$h4 = $g4;
}
$f0 = (($h0 | ($h1 << 26)) & 0xffffffff) + le32($key, 16);
$w0 = $f0 & 0xffffffff;
$f1 = ((($h1 >> 6) | ($h2 << 20)) & 0xffffffff) + le32($key, 20) + ($f0 >> 32);
$w1 = $f1 & 0xffffffff;
$f2 = ((($h2 >> 12) | ($h3 << 14)) & 0xffffffff) + le32($key, 24) + ($f1 >> 32);
$w2 = $f2 & 0xffffffff;
$f3 = ((($h3 >> 18) | ($h4 << 8)) & 0xffffffff) + le32($key, 28) + ($f2 >> 32);
$w3 = $f3 & 0xffffffff;
return put32($w0) . put32($w1) . put32($w2) . put32($w3);
}
function aead_input(string $aad, string $ciphertext): string
{
return $aad . pad16($aad) . $ciphertext . pad16($ciphertext) . le64(strlen($aad)) . le64(strlen($ciphertext));
}
function bxor_stream(string $a, string $b): string
{
$out = '';
$len = strlen($a);
for ($i = 0; $i < $len; $i++) {
$out .= chr(ord($a[$i]) ^ ord($b[$i]));
}
return $out;
}
function open_payload(string $payload, string $island): string
{
$packed = base64_decode(shift_payload($payload), true);
if ($packed === false || strlen($packed) < 28) {
throw new RuntimeException('bad payload');
}
$nonce = substr($packed, 0, 12);
$box = substr($packed, 12);
$key = substr($island, 0, 32);
$key = str_pad($key, 32, "\0");
$tag = substr($box, -16);
$ciphertext = substr($box, 0, -16);
$poly_key = chacha_stream($key, $nonce, 32, 0);
$got = poly1305_mac(aead_input('', $ciphertext), $poly_key);
if (!hash_equals($got, $tag)) {
throw new RuntimeException('bad tag');
}
return bxor_stream($ciphertext, chacha_stream($key, $nonce, strlen($ciphertext), 1));
}
function room_slug(string $id): string
{
$direct = input_slug($id);
if (is_string($direct) && $direct !== '') {
return $direct;
}
$hit = cache_get('room', $id);
if (is_string($hit) && $hit !== '') {
return $hit;
}
[$json] = http_call(PPV_STREAMS);
$data = json_decode($json, true);
if (!is_array($data)) {
throw new RuntimeException('bad streams json');
}
$want = preg_replace('/^ppv-/', '', trim($id));
foreach (($data['streams'] ?? []) as $cat) {
foreach (($cat['streams'] ?? []) as $item) {
$num = isset($item['id']) ? (string)$item['id'] : '';
$slug = stream_slug($item);
if ($num === $want || ('ppv-' . $num) === $id || $slug === $id) {
if ($slug !== '') {
cache_set('room', $id, $slug, time() + ROOM_TTL);
return $slug;
}
}
// 检查子流
foreach (($item['substreams'] ?? []) as $sub) {
$subNum = isset($sub['id']) ? (string)$sub['id'] : '';
$subSlug = stream_slug($sub);
if ($subNum === $want || ('ppv-' . $subNum) === $id || $subSlug === $id) {
if ($subSlug !== '') {
cache_set('room', $id, $subSlug, time() + ROOM_TTL);
return $subSlug;
}
}
}
}
}
throw new RuntimeException('room not found');
}
function fresh_url(string $slug): string
{
$body = pb_put(1, $slug);
[$bin, $headers] = http_call(EMBED_FETCH, 'POST', $body, array_merge(['Content-Type: application/octet-stream'], embed_headers($slug, true)));
$island = $headers['island'] ?? '';
if ($island === '') {
throw new RuntimeException('no island');
}
$fields = pb_read($bin);
if (!isset($fields[1])) {
throw new RuntimeException('no payload');
}
return open_payload($fields[1], $island);
}
function hls_get(string $url, string $slug): string
{
[$text] = http_call($url, 'GET', null, embed_headers($slug, false));
if (!starts_with(ltrim($text), '#EXTM3U')) {
throw new RuntimeException('not m3u8');
}
return $text;
}
function dot_path(string $path): string
{
$lead = starts_with($path, '/');
$tail = ends_with($path, '/');
$stack = [];
foreach (explode('/', $path) as $part) {
if ($part === '' || $part === '.') {
continue;
}
if ($part === '..') {
array_pop($stack);
continue;
}
$stack[] = $part;
}
$out = ($lead ? '/' : '') . implode('/', $stack);
return $tail && $out !== '/' ? $out . '/' : ($out === '' ? ($lead ? '/' : '') : $out);
}
function url_join(string $base, string $ref): string
{
$ref = trim($ref);
if ($ref === '' || preg_match('~^[a-z][a-z0-9+.-]*:~i', $ref)) {
return $ref;
}
$b = parse_url($base);
if (!$b || empty($b['scheme']) || empty($b['host'])) {
throw new RuntimeException('bad base url');
}
if (starts_with($ref, '//')) {
return $b['scheme'] . ':' . $ref;
}
$scheme = $b['scheme'];
$host = $b['host'];
$port = isset($b['port']) ? ':' . $b['port'] : '';
if (starts_with($ref, '/')) {
return "{$scheme}://{$host}{$port}" . dot_path($ref);
}
$dir = isset($b['path']) ? preg_replace('~/[^/]*$~', '/', $b['path']) : '/';
return "{$scheme}://{$host}{$port}" . dot_path($dir . $ref);
}
function quote_uris(string $line, string $base): string
{
return preg_replace_callback('/URI="([^"]+)"/', static function (array $m) use ($base): string {
return 'URI="' . url_join($base, $m[1]) . '"';
}, $line);
}
function maybe_m3u8(string $uri): bool
{
$path = parse_url($uri, PHP_URL_PATH);
return stripos($path !== false && $path !== null ? $path : $uri, '.m3u8') !== false;
}
function stream_score(string $line): int
{
$score = 0;
if (preg_match('/BANDWIDTH=(\d+)/', $line, $m)) {
$score += (int)$m[1];
}
if (preg_match('/RESOLUTION=(\d+)x(\d+)/', $line, $m)) {
$score += ((int)$m[1] * (int)$m[2]) * 10;
}
return $score;
}
function m3u8_refs(string $text, string $base): array
{
$refs = [];
$pending = 0;
foreach (explode("\n", str_replace(["\r\n", "\r"], "\n", $text)) as $line) {
$trim = trim($line);
if ($trim === '') {
continue;
}
if (starts_with($trim, '#')) {
if (starts_with($trim, '#EXT-X-STREAM-INF')) {
$pending = stream_score($trim);
}
if (has_text($line, 'URI="')) {
preg_match_all('/URI="([^"]+)"/', $line, $hits);
foreach ($hits[1] ?? [] as $uri) {
if (maybe_m3u8($uri)) {
$refs[] = [
'url' => url_join($base, $uri),
'rank' => has_text($trim, 'I-FRAME') ? 0 : 1,
'score' => stream_score($trim),
];
}
}
}
continue;
}
if (maybe_m3u8($trim)) {
$refs[] = [
'url' => url_join($base, $trim),
'rank' => $pending > 0 ? 3 : 2,
'score' => $pending,
];
}
$pending = 0;
}
usort($refs, static function (array $a, array $b): int {
return ($b['rank'] <=> $a['rank']) ?: ($b['score'] <=> $a['score']);
});
return $refs;
}
function final_m3u8(string $url, string $slug): array
{
$seen = [];
for ($i = 0; $i < 8; $i++) {
if (isset($seen[$url])) {
throw new RuntimeException('m3u8 loop');
}
$seen[$url] = true;
$text = hls_get($url, $slug);
$refs = m3u8_refs($text, $url);
if (!$refs) {
return [$text, $url];
}
$url = $refs[0]['url'];
}
throw new RuntimeException('m3u8 too deep');
}
function secure_until(string $url): ?int
{
$path = parse_url($url, PHP_URL_PATH);
if (!is_string($path)) {
return null;
}
$parts = explode('/', $path);
$n = count($parts);
for ($i = 0; $i < $n; $i++) {
if ($parts[$i] === 'secure' && isset($parts[$i + 3]) && ctype_digit($parts[$i + 3])) {
return ((int)$parts[$i + 3]) - TOKEN_MARGIN;
}
}
return null;
}
function source_until(string $source, string $final): int
{
$until = time() + SOURCE_TTL;
foreach ([$source, $final] as $url) {
$end = secure_until($url);
if ($end !== null) {
$until = min($until, $end);
}
}
return max(time() + 20, $until);
}
function live_m3u8(string $slug): array
{
$hit = cache_get('source', $slug);
if (is_array($hit) && isset($hit['final']) && is_string($hit['final'])) {
try {
$text = hls_get($hit['final'], $slug);
if (!m3u8_refs($text, $hit['final'])) {
return [$text, $hit['final'], 'HIT'];
}
} catch (Throwable $e) {
}
}
$source = fresh_url($slug);
[$text, $final] = final_m3u8($source, $slug);
cache_set('source', $slug, ['source' => $source, 'final' => $final], source_until($source, $final));
return [$text, $final, 'MISS'];
}
function abs_m3u8(string $text, string $base): string
{
$text = str_replace(["\r\n", "\r"], "\n", $text);
$out = [];
foreach (explode("\n", $text) as $line) {
$trim = trim($line);
if ($trim === '') {
$out[] = $line;
} elseif (starts_with($trim, '#')) {
$out[] = has_text($line, 'URI="') ? quote_uris($line, $base) : $line;
} else {
$url = url_join($base, $trim);
$out[] = segment_needs_proxy($url) ? segment_proxy_url($url) : $url;
}
}
return rtrim(implode("\n", $out)) . "\n";
}
function segment_needs_proxy(string $url): bool
{
$path = parse_url($url, PHP_URL_PATH);
if (!is_string($path)) {
return false;
}
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
return $ext === '';
}
function segment_proxy_url(string $url): string
{
return self_url() . '?seg=' . rawurlencode($url) . '&ext=.ts';
}
function mpegts_offset(string $bin): int
{
$len = strlen($bin);
$max = min(4096, $len - 188 * 5);
for ($off = 0; $off <= $max; $off++) {
$ok = true;
for ($i = 0; $i < 5; $i++) {
if (ord($bin[$off + $i * 188]) !== 0x47) {
$ok = false;
break;
}
}
if ($ok) {
return $off;
}
}
return 0;
}
function output_segment(string $url): void
{
if (!preg_match('~^https?://~i', $url)) {
die_soft(400, 'bad segment url');
}
stream_segment($url);
}
// ==================== 列表功能 ====================
function self_url(): string
{
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
$uri = $_SERVER['REQUEST_URI'] ?? '/';
$path = parse_url($uri, PHP_URL_PATH) ?? '/';
return "{$scheme}://{$host}{$path}";
}
function fetch_streams(): array
{
$hit = cache_get('streams_list', 'all');
if (is_array($hit)) {
return $hit;
}
[$json] = http_call(PPV_STREAMS);
$data = json_decode($json, true);
if (!is_array($data) || !isset($data['streams'])) {
throw new RuntimeException('bad streams json');
}
cache_set('streams_list', 'all', $data['streams'], time() + 300);
return $data['streams'];
}
// 将Unix时间戳转换为北京时间字符串 (格式: m/d H:i)
function format_beijing_time(int $timestamp): string
{
if ($timestamp <= 0) {
return '';
}
$dt = new DateTime('@' . $timestamp);
$dt->setTimezone(new DateTimeZone('Asia/Shanghai'));
return $dt->format('n/j H:i');
}
function build_channels(array $streamsData): array
{
$channels = [];
foreach ($streamsData as $cat) {
$category = $cat['category'] ?? 'Unknown';
foreach (($cat['streams'] ?? []) as $item) {
$uri = stream_slug($item);
if ($uri === '') continue;
$startTime = format_beijing_time($item['starts_at'] ?? 0);
$channels[] = [
'id' => 'ppv-' . ($item['id'] ?? ''),
'name' => $item['name'] ?? 'Unknown',
'category' => $category,
'uri_name' => $uri,
'logo' => $item['poster'] ?? '',
'tag' => $item['tag'] ?? '',
'always_live' => ($item['always_live'] ?? 0) === 1,
'start_time' => $startTime, // 比赛开始时间
];
// 子流
foreach (($item['substreams'] ?? []) as $sub) {
$subUri = stream_slug($sub);
if ($subUri === '') continue;
// 子流使用主流的开始时间
$channels[] = [
'id' => 'ppv-' . ($sub['id'] ?? ''),
'name' => ($sub['name'] ?? 'Unknown') . ' [' . ($sub['tag'] ?? 'Sub') . ']',
'category' => $category,
'uri_name' => $subUri,
'logo' => $sub['poster'] ?? ($item['poster'] ?? ''),
'tag' => $sub['tag'] ?? '',
'always_live' => ($item['always_live'] ?? 0) === 1,
'start_time' => $startTime,
];
}
}
}
return $channels;
}
function output_json(array $channels): void
{
header('Content-Type: application/json; charset=utf-8');
$base = self_url();
$out = [];
foreach ($channels as $c) {
$out[] = [
'id' => $c['id'],
'name' => $c['name'],
'category' => $c['category'],
'uri_name' => $c['uri_name'],
'logo' => $c['logo'],
'start_time' => $c['start_time'], // 包含开始时间
'play_url' => $base . '?id=' . rawurlencode($c['uri_name']),
];
}
echo json_encode($out, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
function output_m3u(array $channels): void
{
header('Content-Type: application/vnd.apple.mpegurl; charset=utf-8');
header('Content-Disposition: attachment; filename="ppv.m3u"');
$base = self_url();
$key = $base . '|m3u';
$hit = cache_get('playlist_m3u', $key);
if (is_string($hit)) {
header('X-PPV-Playlist-Cache: HIT');
echo $hit;
return;
}
$text = "#EXTM3U\n";
foreach ($channels as $c) {
$logo = $c['logo'] ? ' tvg-logo="' . htmlspecialchars($c['logo']) . '"' : '';
$group = htmlspecialchars($c['category']);
// 在频道名称前加上比赛开始时间 [m/d H:i]
$timePrefix = $c['start_time'] ? '[' . $c['start_time'] . '] ' : '';
$name = htmlspecialchars($timePrefix . $c['name']);
$text .= "#EXTINF:-1{$logo} group-title=\"{$group}\",{$name}\n";
$text .= $base . '?id=' . rawurlencode($c['uri_name']) . "\n";
}
cache_set('playlist_m3u', $key, $text, time() + PLAYLIST_TTL);
header('X-PPV-Playlist-Cache: MISS');
echo $text;
}
function output_txt(array $channels): void
{
header('Content-Type: text/plain; charset=utf-8');
$base = self_url();
$key = $base . '|txt';
$hit = cache_get('playlist_txt', $key);
if (is_string($hit)) {
header('X-PPV-Playlist-Cache: HIT');
echo $hit;
return;
}
$currentCat = '';
$text = '';
foreach ($channels as $c) {
if ($c['category'] !== $currentCat) {
$currentCat = $c['category'];
$text .= "\n{$currentCat},#genre#\n";
}
$timePrefix = $c['start_time'] ? '[' . $c['start_time'] . '] ' : '';
$name = str_replace(',', '', $timePrefix . $c['name']);
$text .= "{$name},{$base}?id=" . rawurlencode($c['uri_name']) . "\n";
}
cache_set('playlist_txt', $key, $text, time() + PLAYLIST_TTL);
header('X-PPV-Playlist-Cache: MISS');
echo $text;
}
// ==================== 主逻辑 ====================
if (isset($_GET['seg'])) {
try {
output_segment(trim((string)$_GET['seg']));
exit;
} catch (Throwable $e) {
die_soft(502, $e->getMessage());
}
}
// 列表模式
if (isset($_GET['list'])) {
try {
$streams = fetch_streams();
$channels = build_channels($streams);
output_json($channels);
exit;
} catch (Throwable $e) {
die_soft(502, $e->getMessage());
}
}
if (isset($_GET['m3u'])) {
try {
$streams = fetch_streams();
$channels = build_channels($streams);
output_m3u($channels);
exit;
} catch (Throwable $e) {
die_soft(502, $e->getMessage());
}
}
if (isset($_GET['txt'])) {
try {
$streams = fetch_streams();
$channels = build_channels($streams);
output_txt($channels);
exit;
} catch (Throwable $e) {
die_soft(502, $e->getMessage());
}
}
// 播放单频道
$id = isset($_GET['id']) ? trim((string)$_GET['id']) : DEFAULT_ID;
if ($id === '') {
$id = DEFAULT_ID;
}
try {
$slug = room_slug($id);
[$m3u8, $base, $cache] = live_m3u8($slug);
header('Content-Type: application/vnd.apple.mpegurl; charset=utf-8');
header('Cache-Control: no-store');
header('X-PPV-Cache: ' . $cache);
echo abs_m3u8($m3u8, $base);
} catch (Throwable $e) {
die_soft(502, $e->getMessage());
}
+723
View File
@@ -0,0 +1,723 @@
#coding=utf-8
#!/usr/bin/python
import re
import sys
import json
import html
import time
from urllib.parse import quote, unquote, urljoin
import requests
from base.spider import Spider
sys.path.append('..')
DEBUG_LOG = '/sdcard/Download/ytblive_debug.log'
LIVE_CLASSES = [
{'type_id': 'live', 'type_name': '正在直播'},
{'type_id': 'news live', 'type_name': '新闻直播'},
{'type_id': 'music live', 'type_name': '音乐直播'},
{'type_id': 'lofi live', 'type_name': 'Lofi直播'},
{'type_id': 'space live', 'type_name': '太空直播'},
{'type_id': 'nature live', 'type_name': '自然直播'},
{'type_id': 'game live', 'type_name': '游戏直播'},
{'type_id': 'sports live', 'type_name': '体育直播'},
]
def debug_log(message, data=None):
try:
line = f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {message}"
if data is not None:
if isinstance(data, (dict, list)):
line += ' ' + json.dumps(data, ensure_ascii=False, default=str)
else:
line += ' ' + str(data)
with open(DEBUG_LOG, 'a', encoding='utf-8') as f:
f.write(line + '\n')
except Exception:
pass
class YouTubeLiveLite:
def __init__(self, session, headers=None, config=None):
self.session = session
self.headers = headers or {}
self.config = config or {}
self.cache = {}
self.cache_ttl = int(self.config.get('live_cache_ttl') or 45)
@staticmethod
def extract_video_id(text):
text = str(text or '').strip()
for pattern in [
r'(?:v=|/v/|/embed/|/shorts/|youtu\.be/)([0-9A-Za-z_-]{11})',
r'^([0-9A-Za-z_-]{11})$',
]:
match = re.search(pattern, text)
if match:
return match.group(1)
raise Exception('无法识别 YouTube 视频 ID')
def extract_live(self, url_or_id):
video_id = self.extract_video_id(url_or_id)
now = time.time()
cached = self.cache.get(video_id)
if cached and cached.get('expires', 0) > now:
debug_log('live cache hit', {'video_id': video_id, 'ttl': int(cached.get('expires', 0) - now)})
return cached.get('data')
watch_url = f'https://www.youtube.com/watch?v={video_id}'
debug_log('live extract start', {'input': url_or_id, 'video_id': video_id})
response = self._get(watch_url)
page = response.text
player_response = self._extract_initial_player_response(page) or {}
ytcfg = self._extract_ytcfg(page) or {}
api_key = ytcfg.get('INNERTUBE_API_KEY') or self._search(r'"INNERTUBE_API_KEY":"([^"]+)"', page)
visitor_data = self._extract_visitor_data(ytcfg, player_response)
status_obj = player_response.get('playabilityStatus') or {}
streaming = player_response.get('streamingData') or {}
details = player_response.get('videoDetails') or {}
debug_log('live page parsed', {
'status': status_obj.get('status'),
'reason': status_obj.get('reason'),
'is_live': details.get('isLiveContent'),
'has_hls': bool(streaming.get('hlsManifestUrl')),
'has_api_key': bool(api_key),
'has_visitor': bool(visitor_data),
})
page_hls_url = streaming.get('hlsManifestUrl') or ''
hls_source = 'page' if page_hls_url else ''
api_data = None
if api_key:
api_data = self._call_player_api(video_id, api_key, ytcfg, watch_url, visitor_data)
if api_data:
api_streaming = api_data.get('streamingData') or {}
api_details = api_data.get('videoDetails') or {}
api_hls_url = api_streaming.get('hlsManifestUrl') or ''
if api_hls_url:
streaming = api_streaming
hls_source = api_data.get('_client_name') or 'api'
elif not page_hls_url and api_streaming:
streaming = api_streaming
hls_source = api_data.get('_client_name') or 'api_no_hls'
if api_details:
details = api_details
status_obj = api_data.get('playabilityStatus') or status_obj
if not (streaming.get('hlsManifestUrl') or '') and page_hls_url:
streaming = dict(streaming or {})
streaming['hlsManifestUrl'] = page_hls_url
hls_source = 'page_fallback'
hls_url = streaming.get('hlsManifestUrl') or ''
is_live = bool(details.get('isLiveContent') or hls_url)
status = status_obj.get('status') or ''
reason = status_obj.get('reason') or ''
title = details.get('title') or video_id
data = {
'id': video_id,
'title': title,
'is_live': is_live,
'status': status,
'reason': reason,
'hls_url': hls_url,
'duration': int(details.get('lengthSeconds') or 0),
}
debug_log('live extract result', {
'video_id': video_id,
'status': status,
'is_live': is_live,
'has_hls': bool(hls_url),
'hls_source': hls_source,
'duration': data.get('duration'),
})
self.cache[video_id] = {'data': data, 'expires': time.time() + self.cache_ttl}
return data
def _get(self, url, **kwargs):
headers = self.headers.copy()
headers.update(kwargs.pop('headers', {}) or {})
response = self.session.get(url, headers=headers, timeout=kwargs.pop('timeout', 15), **kwargs)
response.raise_for_status()
return response
def _post_json(self, url, payload, headers=None):
final_headers = self.headers.copy()
final_headers.update({'Content-Type': 'application/json', 'Origin': 'https://www.youtube.com'})
if headers:
final_headers.update({k: v for k, v in headers.items() if v})
response = self.session.post(url, json=payload, headers=final_headers, timeout=15)
response.raise_for_status()
return response.json()
def _call_player_api(self, video_id, api_key, ytcfg, referer, visitor_data=None):
context = ytcfg.get('INNERTUBE_CONTEXT') or {
'client': {'clientName': 'WEB', 'clientVersion': '2.20240310.01.00', 'hl': 'en', 'gl': 'US'}
}
clients = [
{'client': {'clientName': 'ANDROID', 'clientVersion': '21.02.35', 'androidSdkVersion': 30, 'userAgent': 'com.google.android.youtube/21.02.35 (Linux; U; Android 11) gzip', 'osName': 'Android', 'osVersion': '11', 'hl': 'en', 'gl': 'US'}},
{'client': {'clientName': 'IOS', 'clientVersion': '21.02.3', 'deviceMake': 'Apple', 'deviceModel': 'iPhone16,2', 'userAgent': 'com.google.ios.youtube/21.02.3 (iPhone16,2; U; CPU iOS 18_3_2 like Mac OS X;)', 'osName': 'iPhone', 'osVersion': '18.3.2.22D82', 'hl': 'en', 'gl': 'US'}},
{'client': {'clientName': 'MWEB', 'clientVersion': '2.20260115.01.00', 'userAgent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1', 'hl': 'en', 'gl': 'US'}},
context,
]
for ctx in clients:
client = ctx.get('client') or {}
client_name = client.get('clientName') or 'WEB'
try:
url = f'https://www.youtube.com/youtubei/v1/player?key={quote(api_key)}&prettyPrint=false'
headers = {
'Referer': referer,
'X-YouTube-Client-Name': str(self._client_name_id(client_name)),
'X-YouTube-Client-Version': client.get('clientVersion') or '',
}
if visitor_data:
headers['X-Goog-Visitor-Id'] = visitor_data
if client.get('userAgent'):
headers['User-Agent'] = client.get('userAgent')
payload = {
'context': ctx,
'videoId': video_id,
'contentCheckOk': True,
'racyCheckOk': True,
}
data = self._post_json(url, payload, headers=headers)
streaming = data.get('streamingData') or {}
status = (data.get('playabilityStatus') or {}).get('status')
debug_log('live api client', {
'client': client_name,
'status': status,
'has_hls': bool(streaming.get('hlsManifestUrl')),
'has_streaming': bool(streaming),
})
if streaming.get('hlsManifestUrl'):
data['_client_name'] = client_name
return data
except Exception as e:
debug_log('live api client error', {'client': client_name, 'error': repr(e)})
return None
def _extract_visitor_data(self, ytcfg, player_response):
return (
self.config.get('visitor_data')
or ytcfg.get('VISITOR_DATA')
or (((ytcfg.get('INNERTUBE_CONTEXT') or {}).get('client') or {}).get('visitorData'))
or ((player_response.get('responseContext') or {}).get('visitorData'))
)
def _extract_ytcfg(self, text):
match = re.search(r'ytcfg\.set\s*\(\s*({.+?})\s*\)\s*;', text or '', re.S)
if not match:
return None
try:
return json.loads(match.group(1))
except Exception:
return None
def _extract_initial_player_response(self, text):
return self._extract_json_after(text, 'ytInitialPlayerResponse')
def _extract_json_after(self, text, marker):
pos = (text or '').find(marker)
if pos < 0:
return None
start = text.find('{', pos)
if start < 0:
return None
depth = 0
in_str = None
escape = False
for index in range(start, len(text)):
char = text[index]
if escape:
escape = False
continue
if char == '\\':
escape = True
continue
if in_str:
if char == in_str:
in_str = None
continue
if char in ('"', "'"):
in_str = char
continue
if char == '{':
depth += 1
elif char == '}':
depth -= 1
if depth == 0:
try:
return json.loads(text[start:index + 1])
except Exception:
return None
return None
@staticmethod
def _search(pattern, text, default=None):
match = re.search(pattern, text or '', re.S)
return match.group(1) if match else default
def _client_name_id(self, client_name):
return {
'WEB': 1,
'MWEB': 2,
'ANDROID': 3,
'IOS': 5,
'TVHTML5': 7,
'ANDROID_VR': 28,
'WEB_EMBEDDED_PLAYER': 56,
'WEB_REMIX': 67,
}.get(client_name, 1)
class Spider(Spider):
def getName(self):
return 'YouTube直播'
def init(self, extend):
try:
self.extendDict = json.loads(extend) if extend else {}
except Exception:
self.extendDict = {}
self.session = requests.Session()
self.proxy_str = None
proxy_val = self.extendDict.get('proxy')
if proxy_val:
if isinstance(proxy_val, dict):
self.session.proxies = proxy_val
self.proxy_str = (proxy_val.get('http') or proxy_val.get('https') or '').replace('http://', '').replace('https://', '')
elif isinstance(proxy_val, str):
self.proxy_str = proxy_val.replace('http://', '').replace('https://', '')
proxy_url = f'http://{self.proxy_str}'
self.session.proxies = {'http': proxy_url, 'https': proxy_url}
self.header = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Referer': 'https://www.youtube.com/'
}
self.session.headers.update(self.header)
self.yt = YouTubeLiveLite(self.session, self.header, self.extendDict)
self.search_page_cache = {}
self.hls_url_cache = {}
self.hls_proxy_enabled = self.extendDict.get('hls_proxy', True) is not False
debug_log('spider init', {'has_proxy': bool(self.proxy_str or self.session.proxies), 'hls_proxy': self.hls_proxy_enabled})
def homeContent(self, filter):
return {'class': LIVE_CLASSES}
def homeVideoContent(self):
return {'list': []}
def categoryContent(self, cid, page, filter, ext):
page = int(page or 1)
keyword = self._build_live_keyword(cid, ext if isinstance(ext, dict) else {})
videos, has_more = self._search_youtube_page(keyword, page)
return {'list': videos, 'page': page, 'pagecount': page + 1 if has_more else page, 'limit': len(videos), 'total': len(videos)}
def searchContent(self, key, quick, pg=1):
page = int(pg or 1)
keyword = str(key or '').strip()
if 'live' not in keyword.lower() and '直播' not in keyword:
keyword = f'{keyword} live'
videos, has_more = self._search_youtube_page(keyword, page)
return {'list': videos, 'page': page, 'pagecount': page + 1 if has_more else page, 'limit': len(videos), 'total': len(videos)}
def detailContent(self, did):
video_id = did[0]
title = self._get_video_title(video_id)
status = '直播'
try:
data = self.yt.extract_live(video_id)
title = data.get('title') or title
if data.get('hls_url'):
status = '直播中'
elif data.get('status') == 'LIVE_STREAM_OFFLINE':
status = data.get('reason') or '未开播'
elif data.get('status') and data.get('status') != 'OK':
status = data.get('reason') or data.get('status')
else:
status = '无HLS'
debug_log('detail live status', {'video_id': video_id, 'status': status, 'has_hls': bool(data.get('hls_url'))})
except Exception as e:
debug_log('detail live error', {'video_id': video_id, 'error': repr(e)})
safe_title = self._safe_title(title)
vod = {
'vod_id': video_id,
'vod_name': title,
'vod_pic': f'https://img.youtube.com/vi/{video_id}/hqdefault.jpg',
'vod_remarks': status,
'vod_play_from': '直播',
'vod_play_url': f'{safe_title}${video_id}@live'
}
return {'list': [vod]}
def playerContent(self, flag, pid, vipFlags):
raw_pid = pid.split('$')[-1]
video_id = raw_pid.rsplit('@', 1)[0] if '@' in raw_pid else raw_pid
debug_log('player live', {'flag': flag, 'pid': pid, 'video_id': video_id})
try:
data = self.yt.extract_live(video_id)
hls_url = data.get('hls_url') or ''
if not hls_url:
status = data.get('status') or 'NO_HLS'
reason = data.get('reason') or '未获取到直播 HLS 地址'
debug_log('player live no hls', {'video_id': video_id, 'status': status, 'reason': reason})
raise Exception(reason)
if self.extendDict.get('hls_probe'):
self._probe_hls(video_id, hls_url)
play_url = hls_url
if self.hls_proxy_enabled:
play_url = self._cache_hls_url(hls_url, video_id, 'master')
debug_log('return live hls', {'video_id': video_id, 'url_len': len(hls_url), 'status': data.get('status'), 'proxy': self.hls_proxy_enabled})
return {
'parse': 0,
'jx': 0,
'url': play_url,
'header': self.header,
'format': 'application/x-mpegURL'
}
except Exception as e:
debug_log('player live error', {'video_id': video_id, 'error': repr(e)})
return {'parse': 1, 'jx': 1, 'url': pid}
def _probe_hls(self, video_id, hls_url):
try:
response = self.session.get(hls_url, headers=self.header, timeout=10)
full_text = response.text or ''
text = full_text[:5000]
lines = [line.strip() for line in full_text.splitlines() if line.strip()][:12]
variant_url = self._pick_variant_playlist(hls_url, full_text)
debug_log('hls master probe', {
'video_id': video_id,
'status': response.status_code,
'content_type': response.headers.get('content-type'),
'length': len(response.text or ''),
'has_extm3u': text.startswith('#EXTM3U'),
'has_stream_inf': '#EXT-X-STREAM-INF' in text,
'has_media_sequence': '#EXT-X-MEDIA-SEQUENCE' in text,
'variant': bool(variant_url),
'sample': lines,
})
if variant_url:
child = self.session.get(variant_url, headers=self.header, timeout=10)
child_text = child.text[:5000] if child.text else ''
child_lines = [line.strip() for line in child_text.splitlines() if line.strip()][:12]
debug_log('hls variant probe', {
'video_id': video_id,
'status': child.status_code,
'content_type': child.headers.get('content-type'),
'length': len(child.text or ''),
'has_extm3u': child_text.startswith('#EXTM3U'),
'has_media_sequence': '#EXT-X-MEDIA-SEQUENCE' in child_text,
'has_segments': bool(re.search(r'^[^#].+', child_text, re.M)),
'sample': child_lines,
})
except Exception as e:
debug_log('hls probe error', {'video_id': video_id, 'error': repr(e)})
def _pick_variant_playlist(self, base_url, text):
lines = [line.strip() for line in (text or '').splitlines()]
best_score = -1
best_url = ''
for index, line in enumerate(lines):
if not line.startswith('#EXT-X-STREAM-INF'):
continue
score = 0
bandwidth = re.search(r'BANDWIDTH=(\d+)', line)
resolution = re.search(r'RESOLUTION=(\d+)x(\d+)', line)
if bandwidth:
score += int(bandwidth.group(1))
if resolution:
score += int(resolution.group(1)) * int(resolution.group(2))
for next_line in lines[index + 1:]:
if not next_line or next_line.startswith('#'):
continue
if score > best_score:
best_score = score
best_url = urljoin(base_url, next_line)
break
return best_url
# 不同类型缓存的存活时间:master/playlist 会被播放器长期反复复用(直播刷新同一
# 播放列表 URL),需要长 TTL;media 分段一次性消费,用短 TTL 即可。
HLS_TTL = {'master': 6 * 3600, 'playlist': 6 * 3600, 'media': 120, 'media_retry': 120}
def _hls_ttl(self, kind):
return self.HLS_TTL.get(kind, 180)
def _prune_hls_cache(self):
# 清理过期项,避免长时间直播时 media key 无限膨胀导致内存泄漏。
now = time.time()
expired = [k for k, v in self.hls_url_cache.items() if v.get('expires', 0) < now]
for k in expired:
self.hls_url_cache.pop(k, None)
def _cache_hls_url(self, target_url, video_id='', kind='media'):
self._prune_hls_cache()
# 用单调递增计数器而非 len(dict) 生成后缀,避免清理后长度回落造成 key 碰撞。
self._hls_key_seq = getattr(self, '_hls_key_seq', 0) + 1
key = f'{int(time.time() * 1000)}_{self._hls_key_seq}'
self.hls_url_cache[key] = {
'url': target_url,
'video_id': video_id,
'kind': kind,
'expires': time.time() + self._hls_ttl(kind),
}
return f'http://127.0.0.1:9978/proxy?do=py&type=hls&key={quote(key)}'
def localProxy(self, params):
if params.get('do') != 'py' or params.get('type') != 'hls':
return None
key = params.get('key') or ''
item = self.hls_url_cache.get(key)
if not item or item.get('expires', 0) < time.time():
debug_log('hls proxy missing', {'key': key})
return [404, 'text/plain', 'HLS 缓存已过期']
# 访问续期:只要播放器还在反复刷新该 URL(尤其是 master/playlist),
# 就保持其存活,避免固定 TTL 到期后直播中途 404 卡顿。
item['expires'] = time.time() + self._hls_ttl(item.get('kind'))
target_url = item.get('url') or ''
try:
headers = self._hls_headers(target_url, item.get('kind'))
response = self.session.get(target_url, headers=headers, stream=True, timeout=15)
retried = False
if item.get('kind') == 'media' and response.status_code == 403:
retry_headers = self._hls_headers(target_url, 'media_retry')
response.close()
retried = True
response = self.session.get(target_url, headers=retry_headers, stream=True, timeout=15)
content_type = response.headers.get('content-type') or ''
is_m3u8 = item.get('kind') in ('master', 'playlist') or 'mpegurl' in content_type.lower() or target_url.split('?')[0].endswith('.m3u8')
debug_log('hls proxy response', {
'key': key,
'kind': item.get('kind'),
'status': response.status_code,
'content_type': content_type,
'is_m3u8': is_m3u8,
'url_len': len(target_url),
'path_tail': target_url.split('?')[0][-80:],
'retried': retried,
})
if is_m3u8:
text = response.text
rewritten = self._rewrite_m3u8(text, target_url, item.get('video_id') or '')
return [response.status_code, 'application/vnd.apple.mpegurl', rewritten, {'Content-Type': 'application/vnd.apple.mpegurl', 'Cache-Control': 'no-cache'}]
resp_headers = {'Content-Type': content_type or 'application/octet-stream', 'Cache-Control': 'no-cache'}
if response.headers.get('content-length'):
resp_headers['Content-Length'] = response.headers.get('content-length')
return [response.status_code, content_type or 'application/octet-stream', response.content, resp_headers]
except Exception as e:
debug_log('hls proxy error', {'key': key, 'error': repr(e)})
return [500, 'text/plain', f'HLS 代理失败: {str(e)}']
def _hls_headers(self, target_url, kind=None):
if kind == 'media_retry':
return {
'User-Agent': 'com.google.android.youtube/21.02.35 (Linux; U; Android 11) gzip',
'Accept': '*/*',
}
headers = self.header.copy()
headers['Accept'] = '*/*'
if kind in ('master', 'playlist'):
headers['Origin'] = 'https://www.youtube.com'
headers['Referer'] = 'https://www.youtube.com/'
elif kind == 'media':
headers['User-Agent'] = 'com.google.android.youtube/21.02.35 (Linux; U; Android 11) gzip'
headers.pop('Origin', None)
headers.pop('Referer', None)
return headers
def _rewrite_m3u8(self, text, base_url, video_id=''):
output = []
for line in (text or '').splitlines():
stripped = line.strip()
if not stripped:
output.append(line)
continue
if stripped.startswith('#'):
output.append(self._rewrite_m3u8_tag(line, base_url, video_id))
continue
absolute = urljoin(base_url, stripped)
kind = 'playlist' if stripped.endswith('.m3u8') or '/hls_playlist/' in stripped else 'media'
output.append(self._cache_hls_url(absolute, video_id, kind))
return '\n'.join(output) + '\n'
def _rewrite_m3u8_tag(self, line, base_url, video_id=''):
def replace_uri(match):
raw_url = match.group(1)
absolute = urljoin(base_url, raw_url)
proxied = self._cache_hls_url(absolute, video_id, 'media')
return f'URI="{proxied}"'
return re.sub(r'URI="([^"]+)"', replace_uri, line)
def _build_live_keyword(self, cid, filters=None):
raw = str(cid or '').strip()
terms = [raw if raw else 'live']
if isinstance(filters, dict):
for value in filters.values():
term = self._normalize_filter_term(value)
if term:
terms.append(term)
keyword = ' '.join([x for x in terms if x]).strip()
if 'live' not in keyword.lower() and '直播' not in keyword:
keyword = f'{keyword} live'
return keyword
def _normalize_filter_term(self, value):
if isinstance(value, (list, tuple)):
return ' '.join([self._normalize_filter_term(item) for item in value if item])
if isinstance(value, dict):
return ' '.join([self._normalize_filter_term(item) for item in value.values() if item])
return re.sub(r'\s+', ' ', str(value or '')).strip()[:180]
def _search_cache_key(self, key):
return re.sub(r'\s+', ' ', str(key or '')).strip().lower()
def _search_youtube_page(self, key, page=1):
page = max(1, int(page or 1))
cache_key = self._search_cache_key(key)
session = self.search_page_cache.get(cache_key)
if page == 1 or not session:
session = self._fetch_search_first_page(key)
self.search_page_cache[cache_key] = session
while len(session.get('pages', [])) < page and session.get('next'):
data = self._fetch_search_continuation(session)
videos = self._extract_videos_from_api(data, 30)
session.setdefault('pages', []).append(videos)
session['next'] = self._extract_continuation_token(data)
pages = session.get('pages', [])
videos = pages[page - 1] if len(pages) >= page else []
has_more = bool(session.get('next')) or len(pages) > page
debug_log('search page', {'key': key, 'page': page, 'count': len(videos), 'has_more': has_more})
return videos, has_more
def _fetch_search_first_page(self, key):
search_url = f'https://www.youtube.com/results?search_query={quote(str(key or ""))}&sp=EgJAAQ%253D%253D'
response = self.session.get(search_url, timeout=10)
html_str = response.text
data = self.yt._extract_json_after(html_str, 'ytInitialData') or {}
ytcfg = self.yt._extract_ytcfg(html_str) or {}
api_key = ytcfg.get('INNERTUBE_API_KEY') or self.yt._search(r'"INNERTUBE_API_KEY":"([^"]+)"', html_str)
context = ytcfg.get('INNERTUBE_CONTEXT') or {'client': {'clientName': 'WEB', 'clientVersion': '2.20240310.01.00', 'hl': 'zh-CN', 'gl': 'US'}}
client = context.get('client') or {}
return {
'key': key,
'api_key': api_key,
'context': context,
'client_name': client.get('clientName') or 'WEB',
'client_version': client.get('clientVersion') or '2.20240310.01.00',
'referer': search_url,
'pages': [self._extract_videos_from_api(data, 30)],
'next': self._extract_continuation_token(data),
}
def _fetch_search_continuation(self, session):
token = session.get('next')
api_key = session.get('api_key')
if not token or not api_key:
return {}
url = f'https://www.youtube.com/youtubei/v1/search?key={quote(api_key)}'
headers = self.header.copy()
headers.update({
'Content-Type': 'application/json',
'Origin': 'https://www.youtube.com',
'Referer': session.get('referer') or 'https://www.youtube.com/',
'X-YouTube-Client-Name': str(self.yt._client_name_id(session.get('client_name'))),
'X-YouTube-Client-Version': session.get('client_version') or '2.20240310.01.00',
})
payload = {'context': session.get('context') or {}, 'continuation': token}
response = self.session.post(url, json=payload, headers=headers, timeout=10)
response.raise_for_status()
return response.json()
def _extract_continuation_token(self, data):
tokens = []
def scan(obj):
if isinstance(obj, dict):
endpoint = obj.get('continuationEndpoint') or {}
token = endpoint.get('continuationCommand', {}).get('token')
if token:
tokens.append(token)
renderer = obj.get('continuationItemRenderer') or {}
token = renderer.get('continuationEndpoint', {}).get('continuationCommand', {}).get('token')
if token:
tokens.append(token)
for value in obj.values():
scan(value)
elif isinstance(obj, list):
for value in obj:
scan(value)
scan(data)
return tokens[0] if tokens else ''
def _extract_videos_from_api(self, data, limit=30):
videos = []
seen = set()
def scan(obj):
if len(videos) >= limit:
return
if isinstance(obj, dict):
for key in ('videoRenderer', 'compactVideoRenderer', 'gridVideoRenderer'):
if key in obj:
item = self._parse_renderer(obj[key])
if item and item['vod_id'] not in seen:
seen.add(item['vod_id'])
videos.append(item)
for value in obj.values():
scan(value)
elif isinstance(obj, list):
for value in obj:
scan(value)
scan(data)
return videos[:limit]
def _parse_renderer(self, renderer):
try:
video_id = renderer.get('videoId')
if not video_id:
nav = renderer.get('navigationEndpoint') or {}
video_id = (nav.get('watchEndpoint') or {}).get('videoId')
if not video_id:
return None
title_obj = renderer.get('title') or renderer.get('headline') or {}
title = title_obj.get('simpleText') or ''.join([x.get('text', '') for x in title_obj.get('runs', [])]) or 'YouTube Live'
badges = json.dumps(renderer.get('badges') or renderer.get('ownerBadges') or [], ensure_ascii=False)
overlays = json.dumps(renderer.get('thumbnailOverlays') or [], ensure_ascii=False)
view_text = self._text_from_obj(renderer.get('viewCountText'))
metadata = ' '.join([badges, overlays, view_text]).lower()
is_live = 'live' in metadata or '直播' in metadata or 'watching' in view_text.lower()
remarks = '直播' if is_live else (self._text_from_obj(renderer.get('lengthText')) or 'Live')
return {
'vod_id': video_id,
'vod_name': html.unescape(title),
'vod_pic': f'https://img.youtube.com/vi/{video_id}/hqdefault.jpg',
'vod_remarks': remarks,
}
except Exception as e:
debug_log('parse renderer error', repr(e))
return None
def _text_from_obj(self, obj):
if not obj:
return ''
if isinstance(obj, str):
return obj
if isinstance(obj, dict):
return obj.get('simpleText') or ''.join([x.get('text', '') for x in obj.get('runs', [])])
return ''
def _get_video_title(self, video_id):
try:
response = self.session.get(f'https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v={video_id}&format=json', timeout=5)
return response.json().get('title') or video_id
except Exception:
return video_id
def _safe_title(self, title):
if not title:
return 'live'
return re.sub(r'[#$@%&!?*|\\/:<>]', ' ', title)[:60]