Files
ytplayer/alt/swoole/server.php
2026-10-14 03:25:29 +08:00

233 lines
32 KiB
PHP

<?php
declare(strict_types=1);
use Swoole\Http\Request;
use Swoole\Http\Response;
use Swoole\Http\Server;
use Swoole\Coroutine\Http\Client as HttpClient;
if (!extension_loaded('swoole')) { fwrite(STDERR, "ext-swoole is required; use alt/swoole/Dockerfile\n"); exit(2); }
require __DIR__ . '/asset-manifest.php';
require __DIR__ . '/database.php';
require __DIR__ . '/media-cache.php';
date_default_timezone_set('UTC');
$root = getenv('PUBLIC_DIR') ?: '/app/public';
$port = (int)(getenv('PORT') ?: 3000);
$appVersion = getenv('APP_VERSION') ?: '1.0.0';
$buildTime = getenv('BUILD_TIME') ?: (trim((string)@file_get_contents('/app/build-time.txt')) ?: gmdate('c'));
$hashing = getenv('ASSET_HASHING') !== '0';
$assetSync = getenv('ASSET_SYNC') !== '0';
$assets = new AssetManifest($root, $hashing, $assetSync, getenv('BUILD_TAG') ?: null);
$assets->prepareCompression();
$manifestBody = json_encode($assets->manifest, JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE);
$manifestCompressed = AssetManifest::compress($manifestBody);
$dbPath = getenv('DB_PATH') ?: '/app/data/ytplayer.sqlite';
$mediaDir = getenv('MEDIA_DIR') ?: '/app/data/media';
$mediaMarker = getenv('MEDIA_VOLUME_MARKER') ?: null;
$mediaBudget = (int)(getenv('MEDIA_CACHE_MAX_BYTES') ?: 10737418240);
$ytDlpPath = getenv('YTDLP_PATH') ?: '/usr/local/bin/yt-dlp';
$ffmpegPath = getenv('FFMPEG_PATH') ?: '/usr/bin/ffmpeg';
$ffprobePath = getenv('FFPROBE_PATH') ?: '/usr/bin/ffprobe';
$store = null;
$media = null;
// Create the schema before Swoole forks; PDO handles are never inherited by workers.
$schemaStore = new Store($dbPath);
$schemaStore->close();
// Hooks go on only after the blocking start-up work above (proc_open needs a coroutine once hooked).
Swoole\Runtime::enableCoroutine(true, SWOOLE_HOOK_ALL);
$mime = ['html'=>'text/html; charset=utf-8','js'=>'text/javascript; charset=utf-8','mjs'=>'text/javascript; charset=utf-8','css'=>'text/css; charset=utf-8','json'=>'application/json; charset=utf-8','webmanifest'=>'application/manifest+json','svg'=>'image/svg+xml','png'=>'image/png','jpg'=>'image/jpeg','jpeg'=>'image/jpeg','webp'=>'image/webp','woff2'=>'font/woff2','woff'=>'font/woff','ttf'=>'font/ttf','wasm'=>'application/wasm','ico'=>'image/x-icon','txt'=>'text/plain; charset=utf-8','mp4'=>'video/mp4','m4a'=>'audio/mp4'];
$searchMem=[]; $searchInFlight=[]; $streamMem=[]; $streamInFlight=[]; $searchMax=200; $searchTtl=600; $searchStale=15*86400; $searchWrites=0;
$json = static function(Response $res, array $data, int $status=200, array $headers=[]): void {
$body=json_encode($data, JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE|JSON_INVALID_UTF8_SUBSTITUTE);
$res->status($status); $res->header('Content-Type','application/json; charset=utf-8'); $res->header('Cache-Control','no-store');
foreach ($headers as $k=>$v) $res->header($k,(string)$v); $res->end($body === false ? '{}' : $body);
};
$bodyJson = static function(Request $req): array { $body=json_decode($req->rawContent() ?: '{}',true); return is_array($body) ? $body : []; };
$error = static fn(string $message, int $status=500) => ['status'=>$status,'body'=>['ok'=>false,'error'=>$message]];
$etagMatches = static function(string $header, string $etag): bool {
foreach (explode(',', $header) as $candidate) {
$candidate = trim($candidate);
if ($candidate === '*' || hash_equals($etag, preg_replace('/^W\//', '', $candidate) ?? $candidate)) return true;
}
return false;
};
$command = static function(array $argv, float $timeout=1800): array {
$p=proc_open($argv,[['pipe','r'],['pipe','w'],['pipe','w']],$pipes,null,null,['bypass_shell'=>true]);
if (!is_resource($p)) throw new RuntimeException('cannot start ' . ($argv[0] ?? 'process'));
fclose($pipes[0]); stream_set_blocking($pipes[1],false); stream_set_blocking($pipes[2],false); $out=''; $err=''; $start=microtime(true);
$exitCode=null;
do { $out .= stream_get_contents($pipes[1]); $err .= stream_get_contents($pipes[2]); $s=proc_get_status($p); if (!$s['running']) { $exitCode=(int)$s['exitcode']; break; } if (microtime(true)-$start>$timeout) { proc_terminate($p,15); throw new RuntimeException('process timed out'); } Swoole\Coroutine::sleep(.05); } while(true);
$out .= stream_get_contents($pipes[1]); $err .= stream_get_contents($pipes[2]); fclose($pipes[1]); fclose($pipes[2]); $code=proc_close($p);
if ($code===-1 && $exitCode!==null) $code=$exitCode;
if ($code!==0) throw new RuntimeException(trim(substr($err,-1000)) ?: 'process failed'); return ['out'=>$out,'err'=>$err];
};
$fetchTube = static function(string $host, string $path, array $payload, int $timeout=7): array {
$client=new HttpClient($host,443,true); $client->set(['timeout'=>$timeout,'connect_timeout'=>5,'ssl_verify_peer'=>true]);
$client->setHeaders(['Host'=>$host,'User-Agent'=>'Mozilla/5.0 ytplayer/1.0','Content-Type'=>'application/json','Accept'=>'application/json']);
$client->post($path,json_encode($payload,JSON_UNESCAPED_SLASHES)); $status=$client->statusCode; $response=$client->body; $client->close();
if ($status<200 || $status>=300) throw new RuntimeException("YouTube HTTP $status"); $decoded=json_decode($response,true); if (!is_array($decoded)) throw new RuntimeException('invalid YouTube response'); return $decoded;
};
$duration = static function(?string $s): int { if (!$s) return 0; $parts=array_map('intval',explode(':',$s)); return (int)array_reduce($parts,fn($a,$v)=>$a*60+$v,0); };
$cardsFrom = static function(array $node) use (&$cardsFrom,$duration): array {
$out=[];
$visit=function($value) use (&$visit,&$out,$duration) {
if (!is_array($value)) return;
if (isset($value['videoRenderer']) && is_array($value['videoRenderer'])) {
$v=$value['videoRenderer']; $id=$v['videoId'] ?? null;
if (is_string($id)) { $owner=$v['ownerText']['runs'][0] ?? []; $browse=$owner['navigationEndpoint']['browseEndpoint'] ?? []; $channelPath=$browse['canonicalBaseUrl'] ?? (($browse['browseId'] ?? '') ? '/channel/' . $browse['browseId'] : '');
$out[]=['id'=>$id,'title'=>implode('',array_column($v['title']['runs'] ?? [],'text')) ?: ($v['title']['simpleText'] ?? '(untitled)'),'channel'=>$owner['text'] ?? '', 'channelId'=>$browse['browseId'] ?? '', 'channelUrl'=>$channelPath ? 'https://www.youtube.com'.$channelPath : '', 'duration'=>$duration($v['lengthText']['simpleText'] ?? ''), 'thumbnail'=>'https://i.ytimg.com/vi/'.$id.'/mqdefault.jpg']; }
}
foreach ($value as $k=>$child) if (!preg_match('/adSlot|promoted|adPlacement/i',(string)$k)) { if (is_array($child)) $visit($child); }
};
$visit($node); $seen=[]; return array_values(array_filter($out,static function($c)use(&$seen){if(isset($seen[$c['id']]))return false;$seen[$c['id']]=true;return true;}));
};
$innertubeSearch = static function(string $query) use ($fetchTube,$cardsFrom): array {
$context=['client'=>['clientName'=>'WEB','clientVersion'=>'2.20250101.00.00','hl'=>'en','gl'=>'US']];
$data=$fetchTube('www.youtube.com','/youtubei/v1/search?prettyPrint=false',['context'=>$context,'query'=>$query]);
$cards=$cardsFrom($data); $all=$cards;
$contents=$data['contents']['twoColumnSearchResultsRenderer']['primaryContents']['sectionListRenderer']['contents'] ?? [];
$token=''; foreach ($contents as $s) { $token=$s['continuationItemRenderer']['continuationEndpoint']['continuationCommand']['token'] ?? ''; if ($token) break; }
for($page=0;$token && $page<13 && count($all)<200;$page++) {
try { $next=$fetchTube('www.youtube.com','/youtubei/v1/search?prettyPrint=false',['context'=>$context,'continuation'=>$token]); }
catch(Throwable) { break; }
$more=$cardsFrom($next); if (!$more) break; $ids=array_column($all,'id'); foreach($more as $card) if(!in_array($card['id'],$ids,true))$all[]=$card;
$token=''; $iter=new RecursiveIteratorIterator(new RecursiveArrayIterator($next)); foreach($iter as $k=>$v) { if($k==='token' && is_string($v)) { $token=$v; break; } }
}
return array_slice($all,0,200);
};
$slim = static function(array $j): ?array {
$id=(string)($j['id'] ?? ''); if ($id==='') return null;
return ['id'=>$id,'title'=>$j['title'] ?? '(untitled)','channel'=>$j['channel'] ?? $j['uploader'] ?? '', 'channelId'=>$j['channel_id'] ?? $j['uploader_id'] ?? '', 'channelUrl'=>$j['channel_url'] ?? $j['uploader_url'] ?? '', 'duration'=>is_numeric($j['duration'] ?? null) ? (float)$j['duration'] : 0, 'thumbnail'=>'https://i.ytimg.com/vi/'.$id.'/mqdefault.jpg', 'tags'=>array_slice($j['tags'] ?? [],0,30), 'categories'=>array_slice($j['categories'] ?? [],0,30), 'description'=>substr((string)($j['description'] ?? ''),0,1200),'viewCount'=>(int)($j['view_count'] ?? 0)];
};
$ytSearch = static function(string $query) use ($innertubeSearch,$command,$slim): array {
if (getenv('SEARCH_INNERTUBE') !== '0') { try { return $innertubeSearch($query); } catch(Throwable $e) { error_log('[search] InnerTube fallback: '.$e->getMessage()); } }
$run=$command([getenv('YTDLP_PATH') ?: '/usr/local/bin/yt-dlp','ytsearch25:'.$query,'--dump-json','--flat-playlist','--no-warnings','--ignore-errors'],180);
$out=[]; foreach(preg_split('/\r?\n/',$run['out']) as $line) { $j=json_decode($line,true); if(is_array($j) && ($card=$slim($j)))$out[]=$card; } return $out;
};
$searchRefresh = static function(string $query) use (&$searchMem,&$searchInFlight,&$searchWrites,&$store,$ytSearch,$searchMax): array {
$key=mb_strtolower($query); if(isset($searchInFlight[$key])) { $waiter=new Swoole\Coroutine\Channel(1); $searchInFlight[$key]['waiters'][]=$waiter; $result=$waiter->pop(240); if(is_array($result)) { if(isset($result['__error'])) throw new RuntimeException($result['__error']); return $result; } throw new RuntimeException('shared search timed out'); }
$searchInFlight[$key]=['waiters'=>[]];
try { $cards=array_slice($ytSearch($query),0,200); $now=(int)(microtime(true)*1000); if(count($searchMem)>=$searchMax) array_shift($searchMem); $searchMem[$key]=['results'=>$cards,'fetchedAt'=>$now];
$encoded=json_encode(array_slice($cards,0,200),JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE|JSON_INVALID_UTF8_SUBSTITUTE); $store->run('INSERT INTO search_cache(q,results,size,fetched_at,last_access) VALUES(?,?,?,?,?) ON CONFLICT(q) DO UPDATE SET results=excluded.results,size=excluded.size,fetched_at=excluded.fetched_at,last_access=excluded.last_access',[$key,$encoded,strlen($encoded),$now,$now]);
if (++$searchWrites % 25 === 0) {
$maxRows=max(1,(int)(getenv('SEARCH_CACHE_MAX_ROWS') ?: 250000)); $maxBytes=max(1,(int)(getenv('SEARCH_CACHE_MAX_BYTES') ?: 2147483648));
$totals=$store->one('SELECT COUNT(*) AS n, COALESCE(SUM(size),0) AS b FROM search_cache'); $count=(int)($totals['n'] ?? 0); $bytes=(int)($totals['b'] ?? 0);
while ($count>$maxRows || $bytes>$maxBytes) { $old=$store->all('SELECT q,size FROM search_cache ORDER BY last_access ASC LIMIT 500'); if(!$old)break; foreach($old as $row){if($count<=$maxRows && $bytes<=$maxBytes)break;$store->run('DELETE FROM search_cache WHERE q=?',[$row['q']]);$count--;$bytes-=(int)$row['size'];} }
}
if($cards)$store->rememberCards($cards); $result=$searchMem[$key]; foreach(($searchInFlight[$key]['waiters'] ?? []) as $waiter)$waiter->push($result); unset($searchInFlight[$key]); return $result;
} catch(Throwable $e) { foreach(($searchInFlight[$key]['waiters'] ?? []) as $waiter)$waiter->push(['__error'=>$e->getMessage()]); unset($searchInFlight[$key]); throw $e; }
};
$resolveStreams = static function(string $id) use (&$streamMem,&$streamInFlight,$command,$slim,&$store): array {
if(isset($streamMem[$id]) && $streamMem[$id]['expiresAt']>time()) return $streamMem[$id];
if(isset($streamInFlight[$id])) { while(isset($streamInFlight[$id])) Swoole\Coroutine::sleep(.05); if(isset($streamMem[$id]))return $streamMem[$id]; }
$streamInFlight[$id]=true;
try {
$r=$command([getenv('YTDLP_PATH') ?: '/usr/local/bin/yt-dlp','-J','--no-warnings','--no-playlist','https://www.youtube.com/watch?v='.$id],120); $info=json_decode($r['out'],true,512,JSON_THROW_ON_ERROR); $formats=[]; $soonest=PHP_INT_MAX;
foreach(($info['formats'] ?? []) as $f) { if(empty($f['url']) || (($f['protocol'] ?? '') && $f['protocol']!=='https'))continue; $formats[]=['formatId'=>(string)($f['format_id'] ?? ''),'height'=>(int)($f['height'] ?? 0),'vcodec'=>$f['vcodec'] ?? null,'acodec'=>$f['acodec'] ?? null,'abr'=>(float)($f['abr'] ?? 0),'ext'=>$f['ext'] ?? '', 'url'=>$f['url'],'headers'=>$f['http_headers'] ?? []]; }
$entry=['info'=>$info,'formats'=>$formats,'expiresAt'=>time()+1800]; $card=$slim($info); if($card)$store->rememberCards([$card]); $streamMem[$id]=$entry; return $entry;
} finally { unset($streamInFlight[$id]); }
};
$rangeSend = static function(Request $req, Response $res, string $path, string $type, string $cache='no-cache', ?string $etag=null, array $extra=[]): void {
if (!is_file($path)) { $res->status(404); $res->end(); return; }
$total=filesize($path); $range=$req->header['range'] ?? ''; $ifRange=$req->header['if-range'] ?? '';
if($range && $ifRange && $etag && trim($ifRange)!==$etag)$range='';
$start=0;$end=max(0,$total-1);$status=200;
if($range) {
if(!preg_match('/^bytes=(\d*)-(\d*)$/',$range,$m) || ($m[1]==='' && $m[2]==='')) { $res->status(416); $res->header('Content-Range','bytes */'.$total); $res->end(); return; }
if($m[1]==='') { $suffix=(int)$m[2]; $start=max(0,$total-$suffix); }
else { $start=(int)$m[1]; $end=$m[2]!=='' ? min((int)$m[2],$total-1) : $total-1; }
if($start>$end || $start >= $total) { $res->status(416); $res->header('Content-Range','bytes */'.$total); $res->end(); return; }
$status=206;
}
$length=$end-$start+1; $res->status($status); $res->header('Content-Type',$type); $res->header('Accept-Ranges','bytes'); $res->header('Content-Length',(string)$length); $res->header('Cache-Control',$cache);
if($etag)$res->header('ETag',$etag); foreach($extra as $k=>$v)$res->header($k,(string)$v); if($status===206)$res->header('Content-Range',"bytes $start-$end/$total");
if(strtoupper($req->server['request_method'] ?? 'GET')==='HEAD') { $res->end(); return; }
if (!$res->sendfile($path,$start,$length)) { $res->status(500); $res->end(); }
};
$streamsPayload = static function(string $id,array $row): array {
$meta=json_decode($row['meta'] ?? '{}',true) ?: []; $url='/api/media/'.$id.'?g='.$row['gen'];
return ['meta'=>['id'=>$id,'title'=>$meta['title'] ?? '(untitled)','channel'=>$meta['channel'] ?? '', 'channelId'=>$meta['channelId'] ?? '', 'channelUrl'=>$meta['channelUrl'] ?? '', 'duration'=>(float)($meta['duration'] ?? $row['duration'] ?? 0),'thumbnail'=>'https://i.ytimg.com/vi/'.$id.'/hqdefault.jpg'], 'audioUrl'=>$url.'&a=1','qualities'=>[['label'=>((int)($row['height'] ?: 720)).'p','height'=>(int)($row['height'] ?: 720),'hasAudio'=>true,'url'=>$url,'ext'=>'mp4','codec'=>$row['vcodec'] ?: 'h264']], 'serverCached'=>true, 'cid'=>$row['sha256'] ?? null];
};
$server = new Server('0.0.0.0',$port,SWOOLE_PROCESS,SWOOLE_SOCK_TCP);
$cpu=max(1,(int)(function_exists('swoole_cpu_num') ? swoole_cpu_num() : 1));
$server->set(['worker_num'=>$cpu,'max_request'=>10000,'dispatch_mode'=>2,'enable_coroutine'=>true,'http_compression'=>false,'package_max_length'=>8*1024*1024,'open_tcp_nodelay'=>true,'log_level'=>SWOOLE_LOG_WARNING]);
$server->on('WorkerStart', function() use (&$store,&$media,$dbPath,$mediaDir,$mediaMarker,$mediaBudget,$ytDlpPath,$ffmpegPath,$ffprobePath) {
$store = new Store($dbPath, false);
$media = new MediaCache($store,$mediaDir,$mediaMarker,$mediaBudget,$ytDlpPath,$ffmpegPath,$ffprobePath);
});
$server->on('request',function(Request $req,Response $res) use ($server,$root,$port,$appVersion,$buildTime,$assets,$hashing,&$store,&$media,$mime,$manifestBody,$manifestCompressed,&$searchMem,$searchRefresh,$searchStale,$searchTtl,$ytSearch,$resolveStreams,$rangeSend,$streamsPayload,$json,$bodyJson,$error,$command,$fetchTube,$cardsFrom,$etagMatches) {
$path=parse_url($req->server['request_uri'] ?? '/',PHP_URL_PATH) ?: '/'; $method=strtoupper($req->server['request_method'] ?? 'GET'); $q=$req->get ?? [];
try {
if($path==='/api/version' && $method==='GET') { $json($res,['version'=>$appVersion,'buildTag'=>$assets->manifest['buildTag'],'buildTime'=>$buildTime],200,['Cache-Control'=>'no-store, no-cache, must-revalidate']); return; }
if($path==='/api/manifest' && $method==='GET') {
$etag='"'.$assets->manifest['buildTag'].'"'; $res->header('Cache-Control','no-store'); $res->header('ETag',$etag);
if($etagMatches((string)($req->header['if-none-match'] ?? ''),$etag)) { $res->status(304);$res->end();return; }
$body=$manifestBody; $entry=$manifestCompressed; $res->header('Content-Type','application/json; charset=utf-8'); $res->header('ETag',$etag); $res->header('Vary','Accept-Encoding');
if(str_contains($req->header['accept-encoding'] ?? '','br') && $entry['br']!==null){$res->header('Content-Encoding','br');$res->end($entry['br']);return;} if(str_contains($req->header['accept-encoding'] ?? '','gzip')){$res->header('Content-Encoding','gzip');$res->end($entry['gz']);return;} $res->end($body); return;
}
if($path==='/api/search/local' && $method==='GET') { $query=trim((string)($q['q'] ?? '')); if($query===''){$json($res,['ok'=>false,'error'=>'empty query'],400);return;} $json($res,['ok'=>true,'results'=>$store->localSearch($query),'local'=>true]);return; }
if($path==='/api/search' && $method==='GET') {
$query=trim((string)($q['q'] ?? '')); if($query===''){$json($res,['ok'=>false,'error'=>'empty query'],400);return;} $key=mb_strtolower($query); $hit=$searchMem[$key] ?? null;
if(($q['refresh'] ?? '')!=='1' && !$hit) { $cached=$store->one('SELECT results,fetched_at FROM search_cache WHERE q=?',[$key]); if($cached){$hit=['results'=>json_decode($cached['results'],true) ?: [],'fetchedAt'=>(int)$cached['fetched_at']];$searchMem[$key]=$hit;} }
if($hit) { $now=(int)(microtime(true)*1000); $store->run('UPDATE search_cache SET last_access=? WHERE q=? AND last_access<?',[$now,$key,$now-60000]); }
$age=(int)(microtime(true)*1000)-(int)($hit['fetchedAt'] ?? 0);
if(($q['refresh'] ?? '')!=='1' && $hit && $age<$searchTtl*1000){$json($res,['ok'=>true,'results'=>$hit['results'],'fetchedAt'=>$hit['fetchedAt']]);return;}
if(($q['refresh'] ?? '')!=='1' && $hit && $age<$searchStale*1000){$json($res,['ok'=>true,'results'=>$hit['results'],'stale'=>true,'fetchedAt'=>$hit['fetchedAt']]);go(function()use($query,$searchRefresh){try{$searchRefresh($query);}catch(Throwable $e){error_log('[search] revalidate: '.$e->getMessage());}});return;}
try{$fresh=$searchRefresh($query);$json($res,['ok'=>true,'results'=>$fresh['results'],'fetchedAt'=>$fresh['fetchedAt']]);}catch(Throwable $e){$json($res,['ok'=>false,'error'=>$e->getMessage()],500);}return;
}
if($path==='/api/streams/warm' && $method==='GET') { $id=preg_replace('/[^A-Za-z0-9_-]/','',(string)($q['v'] ?? '')); if($id)go(function()use($resolveStreams,$id){try{$resolveStreams($id);}catch(Throwable){}});$json($res,['ok'=>true,'warming'=>true]);return; }
if($path==='/api/streams' && $method==='GET') {
$id=substr(preg_replace('/[^A-Za-z0-9_-]/','',(string)($q['v'] ?? '')),0,64); if($id===''){$json($res,['ok'=>false,'error'=>'missing videoId'],400);return;}
$row=$media->row($id); if(($q['nocache'] ?? '')!=='1' && $row && $row['status']==='ready'){$media->touch($id);$json($res,['ok'=>true,'data'=>$streamsPayload($id,$row)]);return;}
$entry=$resolveStreams($id); $info=$entry['info']; $formats=$entry['formats']; $audio=null; $score=-1;
foreach($formats as $f) if(($f['vcodec'] ?? '')==='none' && !empty($f['acodec'])){$s=(float)($f['abr'] ?? 0)+(str_contains((string)$f['acodec'],'mp4a')?1000:0);if($s>$score){$score=$s;$audio=$f;}}
$audioUrl=$audio?'/api/play?v='.$id.'&audio=1&f='.rawurlencode($audio['formatId']):null; $qualities=[];$seen=[];
foreach([false,true] as $progressive)foreach($formats as $f){$hasVideo=!empty($f['vcodec'])&&$f['vcodec']!=='none';$hasAudio=!empty($f['acodec'])&&$f['acodec']!=='none';$height=(int)($f['height'] ?? 0);if(!$hasVideo||$height<=0||$hasAudio!==$progressive||isset($seen[$height]))continue;$seen[$height]=true;$qualities[]=['label'=>$height.'p','height'=>$height,'hasAudio'=>$progressive,'url'=>'/api/play?v='.$id.'&h='.$height.'&f='.rawurlencode($f['formatId']),'ext'=>$f['ext'] ?? ''];}
usort($qualities,fn($a,$b)=>$b['height']<=>$a['height']); if(!$qualities&&!$audioUrl){$json($res,['ok'=>false,'error'=>!empty($info['is_live'])?'Live streams are not supported':'No playable formats for this video'],422);return;}
go(function()use($media,$id){$media->queue($id,1);});
$meta=['id'=>$id,'title'=>$info['title'] ?? '(untitled)','channel'=>$info['channel'] ?? $info['uploader'] ?? '', 'channelId'=>$info['channel_id'] ?? $info['uploader_id'] ?? '', 'channelUrl'=>$info['channel_url'] ?? $info['uploader_url'] ?? '', 'duration'=>(float)($info['duration'] ?? 0),'thumbnail'=>'https://i.ytimg.com/vi/'.$id.'/hqdefault.jpg'];
$json($res,['ok'=>true,'data'=>['meta'=>$meta,'audioUrl'=>$audioUrl,'qualities'=>$qualities]]);return;
}
if(preg_match('~^/api/media/([A-Za-z0-9_-]+)/status$~',$path,$m) && $method==='GET'){$json($res,['ok'=>true,...$media->status($m[1])]);return;}
if(preg_match('~^/api/media/([A-Za-z0-9_-]+)$~',$path,$m) && in_array($method,['GET','HEAD'],true)){$id=$m[1];$audio=($q['a'] ?? '')==='1';$file=$media->path($id,isset($q['g'])?(int)$q['g']:null,$audio?'m4a':'mp4');if(!$file){$json($res,['ok'=>false,'error'=>'not cached'],404);return;}$media->touch($id);$row=$media->row($id);$etag='"'.$id.'.'.($row['gen'] ?? '').'"';$rangeSend($req,$res,$file,$audio?'audio/mp4':'video/mp4',isset($q['g'])?'public, max-age=31536000, immutable':'no-cache',$etag);return;}
if(preg_match('~^/api/download/([A-Za-z0-9_-]+)/prepare$~',$path,$m) && $method==='GET'){$id=$m[1];if(!MediaCache::validId($id)){$json($res,['ok'=>false,'error'=>'invalid video id'],400);return;}$row=$media->row($id);if($row&&$row['status']==='ready'&&($file=$media->path($id))){$json($res,['ok'=>true,'state'=>'ready','gen'=>(int)$row['gen'],'size'=>filesize($file),'sha256'=>$row['sha256'] ?: null,'etag'=>'"'.$id.'.'.$row['gen'].'"','ext'=>'mp4']);return;}if($row&&$row['status']==='failed'&&(int)$row['retry_at']>time()){$json($res,['ok'=>true,'state'=>'legacy','reason'=>$row['error'] ?: 'server fetch failed']);return;}$media->queue($id,0);$json($res,['ok'=>true,'state'=>'working','status'=>$row['status'] ?? 'queued']);return;}
if(preg_match('~^/api/download/([A-Za-z0-9_-]+)$~',$path,$m) && in_array($method,['GET','HEAD'],true)){$id=$m[1];$deadline=microtime(true)+1800;while(!($file=$media->path($id))&&microtime(true)<$deadline){$st=$media->status($id);if($st['status']==='failed'){$json($res,['ok'=>false,'error'=>$st['error'] ?? 'download failed'],503);return;}Swoole\Coroutine::sleep(.3);}if(!$file){$json($res,['ok'=>false,'error'=>'download is still preparing'],503);return;}$row=$media->row($id);$etag='"'.$id.'.'.$row['gen'].'"';$rangeSend($req,$res,$file,'video/mp4','no-store',$etag,['X-Content-SHA256'=>$row['sha256'] ?? '', 'Access-Control-Expose-Headers'=>'X-Content-SHA256, Content-Range, Content-Length, ETag, Accept-Ranges']);return;}
if(preg_match('~^/api/play$~',$path) && $method==='GET'){
$id=preg_replace('/[^A-Za-z0-9_-]/','',(string)($q['v'] ?? ''));$fId=(string)($q['f'] ?? '');$entry=$resolveStreams($id);$fmt=null;
foreach($entry['formats'] as $f)if($f['formatId']===$fId){$fmt=$f;break;} if(!$fmt){$json($res,['ok'=>false,'error'=>'stream format expired'],410);return;}
$url=parse_url($fmt['url']);$host=$url['host'] ?? '';if(!$host||!str_ends_with($host,'googlevideo.com')){$json($res,['ok'=>false,'error'=>'invalid upstream stream'],502);return;}
$client=new HttpClient($host,443,true);$client->set(['timeout'=>45,'connect_timeout'=>8,'ssl_verify_peer'=>true,'keep_alive'=>false]);$headers=['Host'=>$host,'User-Agent'=>'Mozilla/5.0 ytplayer/1.0'];foreach(($fmt['headers'] ?? []) as $k=>$v)if(is_string($v))$headers[$k]=$v;$headers['Range']=$req->header['range'] ?? 'bytes=0-10485759';$client->setHeaders($headers);$client->get(($url['path'] ?? '/').(isset($url['query'])?'?'.$url['query']:''));$status=$client->statusCode;foreach(['content-type'=>'Content-Type','content-length'=>'Content-Length','content-range'=>'Content-Range','accept-ranges'=>'Accept-Ranges'] as $src=>$dest)if(isset($client->headers[$src]))$res->header($dest,(string)$client->headers[$src]);$res->status($status>=200&&$status<600?$status:502);$res->header('Cache-Control','no-store');$res->end($client->body ?: '');$client->close();return;
}
if($path==='/api/user/sync' && $method==='POST'){$b=$bodyJson($req);$fp=substr(trim((string)($b['fingerprint'] ?? '')),0,200);if($fp===''){$json($res,['ok'=>false,'error'=>'missing fingerprint'],400);return;}$b['fingerprint']=$fp;$store->syncUser($b);$json($res,['ok'=>true]);return;}
if($path==='/api/user/data' && $method==='GET'){$fp=trim((string)($q['fp'] ?? ''));if($fp===''){$json($res,['ok'=>false,'error'=>'missing fingerprint'],400);return;}$json($res,['ok'=>true,...$store->userData($fp),'version'=>$appVersion]);return;}
if($path==='/api/profile/info' && $method==='GET'){$name=strtolower(trim((string)($q['name'] ?? '')));if(!$name){$json($res,['ok'=>false,'error'=>'missing name'],400);return;}$r=$store->one('SELECT secret_hash,secret_kind FROM profiles WHERE name=?',[$name]);$json($res,['ok'=>true,'exists'=>(bool)$r,'protected'=>(bool)($r['secret_hash'] ?? null),'kind'=>$r['secret_kind'] ?? null]);return;}
if($path==='/api/profile/create' && $method==='POST'){$b=$bodyJson($req);$name=strtolower(trim((string)($b['name'] ?? '')));if(!$name)$name='profile-'.bin2hex(random_bytes(4));if(!preg_match('/^[a-z0-9_-]{3,40}$/',$name)){$json($res,['ok'=>false,'error'=>'invalid name'],400);return;}$profileData=json_encode(is_array($b['data'] ?? null)?$b['data']:new stdClass(),JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE);if(strlen($profileData)>2000000){$json($res,['ok'=>false,'error'=>'profile data too large'],413);return;}try{$store->run('INSERT INTO profiles(name,data) VALUES(?,?)',[$name,$profileData]);}catch(Throwable){$json($res,['ok'=>false,'error'=>'profile already exists'],409);return;}$row=$store->one('SELECT updated_at FROM profiles WHERE name=?',[$name]);$json($res,['ok'=>true,'name'=>$name,'updatedAt'=>(int)$row['updated_at']]);return;}
if($path==='/api/profile/load' && $method==='GET'){$name=strtolower(trim((string)($q['name'] ?? '')));if(!$name){$json($res,['ok'=>false,'error'=>'missing name'],400);return;}$r=$store->one('SELECT data,updated_at,secret_hash,secret_kind FROM profiles WHERE name=?',[$name]);if(!$r){$json($res,['ok'=>false,'error'=>'profile not found'],404);return;}if($r['secret_hash']&&!password_verify((string)($req->header['x-profile-secret'] ?? ''),$r['secret_hash'])){$json($res,['ok'=>false,'needSecret'=>true,'error'=>'this profile is protected'],401);return;}$json($res,['ok'=>true,'name'=>$name,'data'=>json_decode($r['data'],true) ?: [],'updatedAt'=>(int)$r['updated_at'],'protected'=>(bool)$r['secret_hash'],'kind'=>$r['secret_kind']]);return;}
if($path==='/api/profile/save' && $method==='POST'){$b=$bodyJson($req);$name=strtolower(trim((string)($b['name'] ?? '')));if(!$name){$json($res,['ok'=>false,'error'=>'missing name'],400);return;}$existing=$store->one('SELECT secret_hash FROM profiles WHERE name=?',[$name]);if(!$existing){$json($res,['ok'=>false,'error'=>'profile not found'],404);return;}$profileData=json_encode(is_array($b['data'] ?? null)?$b['data']:[],JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE);if(strlen($profileData)>2000000){$json($res,['ok'=>false,'error'=>'profile data too large'],413);return;}if($existing['secret_hash']&&!password_verify((string)($b['secret'] ?? $req->header['x-profile-secret'] ?? ''),$existing['secret_hash'])){$json($res,['ok'=>false,'needSecret'=>true,'error'=>'this profile is protected'],401);return;}$store->run('UPDATE profiles SET data=?,updated_at=unixepoch() WHERE name=?',[$profileData,$name]);$r=$store->one('SELECT updated_at FROM profiles WHERE name=?',[$name]);$json($res,['ok'=>true,'updatedAt'=>(int)$r['updated_at']]);return;}
if($path==='/api/profile/secret' && $method==='POST'){$b=$bodyJson($req);$name=strtolower(trim((string)($b['name'] ?? '')));if(!$name){$json($res,['ok'=>false,'error'=>'missing name'],400);return;}$r=$store->one('SELECT secret_hash FROM profiles WHERE name=?',[$name]);if(!$r){$json($res,['ok'=>false,'error'=>'profile not found'],404);return;}if($r['secret_hash']&&!password_verify((string)($b['current'] ?? $req->header['x-profile-secret'] ?? ''),$r['secret_hash'])){$json($res,['ok'=>false,'needSecret'=>true,'error'=>'current secret is incorrect'],401);return;}if(($b['secret'] ?? null)===null){$store->run('UPDATE profiles SET secret_hash=NULL,secret_kind=NULL WHERE name=?',[$name]);$json($res,['ok'=>true,'protected'=>false]);return;}$secret=(string)($b['secret'] ?? '');$kind=($b['kind'] ?? '')==='key'?'key':'password';if(($kind==='password'&&(strlen($secret)<8||strlen($secret)>200))||($kind==='key'&&!preg_match('/^[A-Za-z0-9_-]{32,128}$/',$secret))){$json($res,['ok'=>false,'error'=>'invalid profile secret'],400);return;}$store->run('UPDATE profiles SET secret_hash=?,secret_kind=? WHERE name=?',[password_hash($secret,PASSWORD_DEFAULT),$kind,$name]);$json($res,['ok'=>true,'protected'=>true,'kind'=>$kind]);return;}
if(str_starts_with($path,'/api/')){$json($res,['ok'=>false,'error'=>'not in this build'],501);return;}
// Static files use the exact runtime manifest body for index/SW, and source bytes otherwise.
$decoded=rawurldecode($path); if(str_contains($decoded,'..') || str_contains($decoded,"\0")){$res->status(400);$res->end('bad path');return;}
$target=rtrim($root,'/').$decoded; $spa=false; if($decoded==='/' || $decoded==='/index.html' || !is_file($target)){$target=$root.'/index.html';$decoded='/index.html';$spa=true;}
$raw=$decoded==='/index.html'?$assets->index:($decoded==='/sw.js'?$assets->serviceWorker:null); if($decoded!=='/index.html' && $decoded!=='/sw.js' && !is_file($target)){$res->status(404);$res->end('Not Found');return;}
$version=array_key_exists('v',$q)?(string)$q['v']:null;$cc=$assets->cacheControl($decoded,$version,$hashing);$entry=$decoded==='/index.html'?$assets->compressed['/index.html']:($decoded==='/sw.js'?$assets->compressed['/sw.js']:($assets->compressed[$decoded] ?? null));
if($entry===null){$raw=file_get_contents($target);if($raw===false){$res->status(404);$res->end('Not Found');return;}$entry=AssetManifest::compress($raw);}
$raw=$entry['raw'];$res->header('Content-Type',$mime[pathinfo($decoded,PATHINFO_EXTENSION)] ?? 'application/octet-stream');$res->header('Cache-Control',$decoded==='/sw.js'?'no-store, no-cache, must-revalidate':$cc);$res->header('Vary','Accept-Encoding');$res->header('ETag',$entry['etag']);$res->header('X-Asset-Hash',$assets->delivery[$decoded]['h'] ?? AssetManifest::hash10($raw));
if($etagMatches((string)($req->header['if-none-match'] ?? ''),$entry['etag'])){$res->status(304);$res->end();return;}
$accept=$req->header['accept-encoding'] ?? '';if(str_contains($accept,'br')&&$entry['br']!==null){$res->header('Content-Encoding','br');$res->header('Content-Length',(string)strlen($entry['br']));$res->end($method==='HEAD'?'':$entry['br']);return;}if(str_contains($accept,'gzip')){$res->header('Content-Encoding','gzip');$res->header('Content-Length',(string)strlen($entry['gz']));$res->end($method==='HEAD'?'':$entry['gz']);return;}
if($method==='HEAD'){$res->header('Content-Length',(string)strlen($raw));$res->end();return;}
if($decoded!=='/index.html'&&$decoded!=='/sw.js'){$res->header('Content-Length',(string)strlen($raw));if($res->sendfile($target))return;}
$res->end($raw);
} catch(Throwable $e) { error_log('[swoole] '.$method.' '.$path.': '.$e->getMessage()); $json($res,['ok'=>false,'error'=>$e->getMessage()],500); }
});
echo "[ytplayer-swoole] listening on 0.0.0.0:$port build={$assets->manifest['buildTag']} workers=$cpu\n";
$server->start();