Add Swoole alternative backend

This commit is contained in:
Jonathan Sykes
2026-10-14 00:43:38 +08:00
parent f465cc86d0
commit 1130fabc94
11 changed files with 756 additions and 0 deletions

43
alt/compare.mjs Normal file
View File

@@ -0,0 +1,43 @@
#!/usr/bin/env node
const base = process.argv[2];
if (!base) { console.error('usage: node alt/compare.mjs <bun-base-url>'); process.exit(2); }
const alt = (process.env.ALT_URL || 'http://127.0.0.1:3000').replace(/\/$/, '');
const video = process.env.VIDEO_ID || 'dQw4w9WgXcQ';
const generation = process.env.MEDIA_GEN || '1';
const query = process.env.SEARCH_QUERY || 'worship';
const paths = [
['search', `/api/search?q=${encodeURIComponent(query)}`],
['streams', `/api/streams?v=${encodeURIComponent(video)}`],
['download-prepare', `/api/download/${encodeURIComponent(video)}/prepare`],
['media-range', `/api/media/${encodeURIComponent(video)}?g=${encodeURIComponent(generation)}`],
];
function jsonPaths(value, prefix = '$', out = []) {
if (Array.isArray(value)) { out.push(`${prefix}[]`); if (value.length) jsonPaths(value[0], `${prefix}[]`, out); }
else if (value && typeof value === 'object') for (const key of Object.keys(value).sort()) { out.push(`${prefix}.${key}`); jsonPaths(value[key], `${prefix}.${key}`, out); }
return out.sort();
}
async function request(url, range = false) {
const headers = range ? { Range: 'bytes=0-1023' } : {};
const response = await fetch(url, { headers, signal: AbortSignal.timeout(Number(process.env.COMPARE_TIMEOUT_MS || 30000)) });
const type = response.headers.get('content-type') || '';
const body = type.includes('json') ? await response.json() : await response.arrayBuffer();
return { status: response.status, headers: Object.fromEntries(['content-type','cache-control','accept-ranges','content-range','etag'].map(k => [k,response.headers.get(k)])), body };
}
let failed = false;
for (const [name, path] of paths) {
try {
const [a,b] = await Promise.all([request(base.replace(/\/$/,'')+path,name==='media-range'),request(alt+path,name==='media-range')]);
const isJson = typeof a.body === 'object' && !ArrayBuffer.isView(a.body) && !(a.body instanceof ArrayBuffer);
const result = { name, path, bun: {status:a.status,headers:a.headers}, swoole:{status:b.status,headers:b.headers} };
if (isJson) { result.bun.shape=jsonPaths(a.body); result.swoole.shape=jsonPaths(b.body); result.shapeMatches=JSON.stringify(result.bun.shape)===JSON.stringify(result.swoole.shape); }
const headerKeys=['content-type','cache-control','accept-ranges','content-range','etag'];
const norm=(key,value)=>value===null?null:(key==='content-type'?value.toLowerCase().replace(/;\s*charset=utf-8/i,'; charset=utf-8'):value.trim());
result.headerMatches=headerKeys.filter((key)=>norm(key,a.headers[key])===norm(key,b.headers[key]));
result.headerDiffs=headerKeys.filter((key)=>norm(key,a.headers[key])!==norm(key,b.headers[key])).map((key)=>({key,bun:a.headers[key] ?? null,swoole:b.headers[key] ?? null}));
if(name==='media-range') result.rangeBytes={bun:a.body.byteLength,swoole:b.body.byteLength};
result.statusMatches=a.status===b.status;
console.log(JSON.stringify(result));
if(!result.statusMatches || (result.shapeMatches===false) || result.headerDiffs.length)failed=true;
} catch(error) { failed=true; console.error(JSON.stringify({name,path,error:String(error)})); }
}
if(failed) process.exitCode=1;

5
alt/swoole/BENCH.md Normal file
View File

@@ -0,0 +1,5 @@
# Swoole A/B benchmark
No benchmark numbers are recorded yet. The authoring host has no Docker client/daemon and PHP has neither `ext-swoole` nor `pdo_sqlite`, so it cannot start this backend. Do not treat unmeasured estimates as results.
Run `node alt/swoole/bench.mjs` against both servers from the same machine with a warmed search key and the same server-cached media id. The script emits JSON per endpoint and concurrency (1, 10, 50, 200); paste each server's actual output here with CPU, runtime versions, and commit id before comparing.

27
alt/swoole/Dockerfile Normal file
View File

@@ -0,0 +1,27 @@
# Build from repository root: docker build -f alt/swoole/Dockerfile .
FROM php:8.3-cli-bookworm
ADD https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/install-php-extensions
RUN chmod +x /usr/local/bin/install-php-extensions \
&& apt-get update -qq \
&& apt-get install -y --no-install-recommends ca-certificates curl ffmpeg brotli python3 \
&& rm -rf /var/lib/apt/lists/* \
&& install-php-extensions swoole-5.1.7 pdo_sqlite mbstring
# yt-dlp needs a JS runtime for current YouTube challenge extraction.
RUN curl -fsSL https://deno.land/install.sh | sh -s -- -y \
&& ln -s /root/.deno/bin/deno /usr/local/bin/deno \
&& curl -fsSL https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp \
&& chmod +x /usr/local/bin/yt-dlp \
&& printf -- '--js-runtimes deno:/usr/local/bin/deno\n' > /etc/yt-dlp.conf
WORKDIR /app
COPY frontend/ /app/public/
COPY alt/swoole/ /app/alt/swoole/
RUN mkdir -p /app/data/media /app/data \
&& date -u +"%Y-%m-%dT%H:%M:%SZ" > /app/build-time.txt
ENV PORT=3000 DB_PATH=/app/data/ytplayer.sqlite MEDIA_DIR=/app/data/media PUBLIC_DIR=/app/public
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD curl -fsS http://127.0.0.1:3000/api/version || exit 1
CMD ["php", "/app/alt/swoole/server.php"]

31
alt/swoole/README.md Normal file
View File

@@ -0,0 +1,31 @@
# ytplayer Swoole backend
Drop-in PHP 8.3 / Swoole 5 alternative for the existing `frontend/`. It is intentionally dependency-light: Swoole's coroutine HTTP server, PDO SQLite, SQLite WAL, cURL-free coroutine HTTP calls for InnerTube, and yt-dlp/ffmpeg subprocesses. Unimplemented optional APIs return the app-compatible 501 JSON response.
## Build and run
Build context is the repository root so the image copies the unmodified frontend:
```sh
docker build -f alt/swoole/Dockerfile -t ytplayer-swoole .
docker compose -f alt/swoole/docker-compose.yml up -d --build
```
The Compose service exposes only container port 3000 on the external `dokploy-network`; it has no host port mapping or router labels. Mount/point `MEDIA_VOLUME_MARKER` at an existing marker file on the media volume if that disk is removable. `MEDIA_CACHE_MAX_BYTES`, `DB_PATH`, `MEDIA_DIR`, and `PORT` are configurable. The data volume stores SQLite WAL and `<videoId>.<generation>.mp4/.m4a` files.
For a local A/B run, map a host port in a temporary override (do not add that mapping to the committed production Compose file), then use `node alt/compare.mjs http://bun-host:3000` with `ALT_URL=http://127.0.0.1:3000`. Set `VIDEO_ID` to an id already cached on both backends for Range and download parity. The `compare.mjs` script compares response status, selected headers, and JSON key paths without requiring result bodies to be byte-identical.
## Browser and performance checks
Open the Swoole URL in Chromium and exercise search, playback, Save to device, offline shell installation, and profile sync. For endpoint parity run:
```sh
ALT_URL=http://127.0.0.1:3000 VIDEO_ID=abcdefghijk node alt/compare.mjs http://127.0.0.1:3001
ALT_URL=http://127.0.0.1:3000 VIDEO_ID=abcdefghijk node alt/swoole/bench.mjs
```
The bench script runs 1/10/50/200 concurrent clients against version, a static CSS file, a cache-hit search, and a cached-file byte range. It prints p50/p95, request rate, and transfer bytes as JSON. Results must be recorded in `BENCH.md` from the same host and dataset for both backends.
## Current verification boundary
`php -l` and the pure-PHP manifest smoke checks can run on a host without Swoole. The host used to author this backend did not have ext-swoole, pdo_sqlite, or a Docker daemon; no HTTP server, Playwright browser run, or performance numbers are claimed until the image is built and run on a Swoole-capable host.

View File

@@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
/** Runtime asset manifest and static-byte preparation, kept independent of the HTTP server. */
final class AssetManifest
{
public array $manifest;
public array $delivery;
public string $index;
public string $serviceWorker;
public array $compressed = [];
public function __construct(private string $root, bool $hashing = true, bool $assetSync = true, ?string $override = null)
{
$this->root = rtrim($root, '/');
$assetSync = $hashing && $assetSync;
$raw = [];
$it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($this->root, FilesystemIterator::SKIP_DOTS));
foreach ($it as $entry) if ($entry->isFile()) {
$path = '/' . str_replace(DIRECTORY_SEPARATOR, '/', substr($entry->getPathname(), strlen($this->root) + 1));
$raw[$path] = file_get_contents($entry->getPathname());
}
ksort($raw, SORT_STRING);
$definition = isset($raw['/assets.json']) ? json_decode($raw['/assets.json'], true, 512, JSON_THROW_ON_ERROR) : ['groups' => ['core' => ['contract' => 1, 'eager' => true, 'files' => array_keys($raw)]]];
$include = $definition['include'] ?? [];
$runtime = [];
foreach ($raw as $path => $body) if (in_array($path, $include, true) || self::runtimePath($path)) $runtime[$path] = $body;
$groups = $definition['groups'] ?? [];
ksort($groups, SORT_STRING);
$contracts = [];
$membership = [];
foreach ($groups as $name => $group) {
$contracts[$name] = (int)($group['contract'] ?? 1);
foreach (($group['files'] ?? []) as $path) {
if (!isset($runtime[$path])) throw new RuntimeException("Asset group $name references missing or excluded $path");
if (isset($membership[$path])) throw new RuntimeException("Duplicate asset group membership for $path");
$membership[$path] = $name;
}
}
$files = [];
$delivery = [];
foreach ($raw as $path => $body) {
$entry = ['h' => self::hash10($body), 's' => strlen($body), 'g' => $membership[$path] ?? 'core'];
$delivery[$path] = $entry;
if (isset($runtime[$path])) $files[$path] = $entry;
}
$sourceIndex = $raw['/index.html'] ?? '';
$sourceIndex = str_replace('__APP_SCRIPT_HASH__', base64_encode(hash('sha256', $raw['/app.js'] ?? '', true)), $sourceIndex);
$canonicalIndex = $this->stampIndex($sourceIndex, $files, '__BUILD_TAG__', true);
$canonicalIndex = $this->embedAssets($canonicalIndex, $files, $groups, '__BUILD_TAG__', true, $assetSync);
$canonicalFiles = $files;
if ($sourceIndex !== '') $canonicalFiles['/index.html'] = [
'h' => self::hash10($canonicalIndex), 's' => strlen($canonicalIndex), 'g' => $membership['/index.html'] ?? 'core', 'source' => $files['/index.html']['h'] ?? '',
];
$canonical = ['assetSync' => $assetSync, 'files' => $canonicalFiles, 'groups' => $groups, 'contracts' => $contracts];
$canonicalJson = json_encode($canonical, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
$legacy = '';
foreach ($runtime as $path => $body) $legacy .= './public' . $path . $body;
$buildTag = $hashing ? substr(hash('sha256', $canonicalJson), 0, 12) : ($override ?: substr(hash('sha256', $legacy), 0, 12));
$this->index = $this->embedAssets($this->stampIndex($sourceIndex, $files, $buildTag, $hashing), $files, $groups, $buildTag, $hashing, $assetSync);
$swSource = $raw['/sw.js'] ?? '';
$this->serviceWorker = preg_replace("/typeof __BUILD_TAG__ !== 'undefined' \\? __BUILD_TAG__ : '[^']*'/", '"' . $buildTag . '"', $swSource) ?? $swSource;
$this->serviceWorker = str_replace("typeof __ASSET_SYNC__ !== 'undefined' ? __ASSET_SYNC__ : true", $assetSync ? 'true' : 'false', $this->serviceWorker);
$this->serviceWorker = str_replace("importScripts('/asset-sync-core.js')", "importScripts('/asset-sync-core.js?v=" . ($files['/asset-sync-core.js']['h'] ?? '') . "')", $this->serviceWorker);
$files['/index.html'] = ['h' => self::hash10($this->index), 's' => strlen($this->index), 'g' => 'core'];
$files['/sw.js'] = ['h' => self::hash10($this->serviceWorker), 's' => strlen($this->serviceWorker), 'g' => 'core'];
$delivery['/index.html'] = $files['/index.html'];
$delivery['/sw.js'] = $files['/sw.js'];
$this->delivery = $delivery;
$this->manifest = ['buildTag' => $buildTag, 'files' => $files, 'groups' => $groups, 'contracts' => $contracts];
}
public static function hash10(string $body): string { return substr(hash('sha256', $body), 0, 10); }
public static function runtimePath(string $path): bool
{
return $path !== '/admin.html' && !preg_match('~(?:^|/)perf(?:/|$)~i', $path)
&& !preg_match('~\.(?:test\.(?:js|mjs)|md|txt|c|entry\.js)$~i', $path);
}
private function embedAssets(string $source, array $files, array $groups, string $tag, bool $hashing, bool $assetSync): string
{
$pageFiles = [];
foreach ($groups as $group) foreach (($group['files'] ?? []) as $path) if ($path !== '/index.html' && $path !== '/sw.js' && isset($files[$path])) {
$pageFiles[$path] = $files[$path];
if (!$hashing) $pageFiles[$path]['h'] = $tag;
}
$json = json_encode(['buildTag' => $tag, 'groups' => (object)$groups, 'files' => (object)$pageFiles, 'appCache' => $assetSync], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
$json = str_replace('<', '\\u003c', $json);
return preg_replace('~(<script type="application/json" id="ytp-assets">).*?(</script>)~s', '$1' . $json . '$2', $source, 1) ?? $source;
}
private function stampIndex(string $source, array $files, string $tag, bool $hashing): string
{
if (!$hashing) {
$source = str_replace('__BUILD_TAG__', $tag, $source);
return preg_replace_callback('~((?:href|src)=")((?![a-z]+:|/)[^"?]+\.(?:css|js))(\")~i', fn($m) => $m[1] . $m[2] . '?v=' . $tag . $m[3], $source) ?? $source;
}
$source = preg_replace_callback('~<(?:script|link)\b[^>]*>~i', function($match) use ($files) {
return preg_replace_callback('~\b(href|src)=(["\'])([^"\']+)\2~i', function($attr) use ($files) {
$url = $attr[3];
if (preg_match('~^(?:[a-z][a-z\d+.-]*:|//|#)~i', $url)) return $attr[0];
$parts = parse_url($url);
$path = $parts['path'] ?? $url;
$path = '/' . ltrim(rawurldecode($path), '/');
if (!isset($files[$path]) || !preg_match('~\.(?:css|js)$~i', $path) || $path === '/sw.js') return $attr[0];
parse_str($parts['query'] ?? '', $query); $query['v'] = $files[$path]['h'];
$newUrl = ($parts['path'] ?? $url) . '?' . http_build_query($query, '', '&', PHP_QUERY_RFC3986) . (isset($parts['fragment']) ? '#' . $parts['fragment'] : '');
return $attr[1] . '=' . $attr[2] . $newUrl . $attr[2];
}, $match[0]) ?? $match[0];
}, $source) ?? $source;
return str_replace('__BUILD_TAG__', $tag, $source);
}
public function prepareCompression(): void
{
$it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($this->root, FilesystemIterator::SKIP_DOTS));
foreach ($it as $entry) if ($entry->isFile()) {
$path = '/' . str_replace(DIRECTORY_SEPARATOR, '/', substr($entry->getPathname(), strlen($this->root) + 1));
$raw = file_get_contents($entry->getPathname());
$this->compressed[$path] = preg_match('~\.(?:js|css|html|json|webmanifest|svg|txt)$~i', $path)
? self::compress($raw) : ['raw'=>$raw,'gz'=>null,'br'=>null,'etag'=>'"'.substr(hash('sha256',$raw),0,32).'"'];
}
$this->compressed['/index.html'] = self::compress($this->index);
$this->compressed['/sw.js'] = self::compress($this->serviceWorker);
}
public static function compress(string $raw): array
{
$br = null;
if (function_exists('brotli_compress')) $br = brotli_compress($raw, 5, BROTLI_TEXT);
elseif (is_executable('/usr/bin/brotli')) {
$output = sys_get_temp_dir() . '/ytp-brotli-' . bin2hex(random_bytes(8));
$p = proc_open(['/usr/bin/brotli', '-q', '5', '-c'], [['pipe','r'],['file',$output,'w'],['file','/dev/null','w']], $pipes, null, null, ['bypass_shell'=>true]);
if (is_resource($p)) {
fwrite($pipes[0], $raw); fclose($pipes[0]);
if (proc_close($p) === 0 && is_file($output)) $br = file_get_contents($output);
@unlink($output);
}
}
return ['raw' => $raw, 'gz' => gzencode($raw, 9), 'br' => $br, 'etag' => '"' . substr(hash('sha256', $raw), 0, 32) . '"'];
}
public function cacheControl(string $path, ?string $version, bool $hashing): string
{
$short = preg_match('~(^|/)(fonts|icons)/~', $path) ? 'public, max-age=2592000' : 'no-cache';
if (!$hashing && $path === '/index.html') return 'no-cache';
$binaryLegacy = !$hashing && !preg_match('~\.(?:js|css|html|json|webmanifest|svg|txt)$~i', $path);
if ($version !== null) {
$current = $hashing ? ($this->delivery[$path]['h'] ?? null) : $this->manifest['buildTag'];
if ($current !== null && hash_equals((string)$current, $version)) return 'public, max-age=31536000, immutable';
return $hashing ? 'no-store' : 'no-cache';
}
return preg_match('~(^|/)(fonts|icons)/~', $path) && ($hashing || $binaryLegacy) ? 'public, max-age=2592000' : 'no-cache';
}
}

28
alt/swoole/bench.mjs Normal file
View File

@@ -0,0 +1,28 @@
#!/usr/bin/env node
const base = (process.argv[2] || process.env.ALT_URL || 'http://127.0.0.1:3000').replace(/\/$/,'');
const video = process.env.VIDEO_ID || 'dQw4w9WgXcQ';
const generation = process.env.MEDIA_GEN || '1';
const query = process.env.SEARCH_QUERY || 'worship';
const seconds = Number(process.env.BENCH_SECONDS || 3);
const scenarios = [
['version','/api/version',false], ['static-css','/styles.css',false],
['cached-search',`/api/search?q=${encodeURIComponent(query)}`,false],
['cached-range',`/api/media/${encodeURIComponent(video)}?g=${encodeURIComponent(generation)}`,true],
];
const percentile = (values,p) => values.length ? values[Math.min(values.length-1,Math.ceil(values.length*p)-1)] : 0;
async function run(path, range, concurrency) {
const latencies=[]; let bytes=0, count=0, failures=0; const until=Date.now()+seconds*1000;
await Promise.all(Array.from({length:concurrency},async()=>{
while(Date.now()<until) {
const start=performance.now();
try { const response=await fetch(base+path,{headers:range?{Range:'bytes=0-65535'}:{},signal:AbortSignal.timeout(30000)}); const data=await response.arrayBuffer(); bytes+=data.byteLength; count++; if(!response.ok)failures++; }
catch { failures++; }
latencies.push(performance.now()-start);
}
}));
latencies.sort((a,b)=>a-b); return {endpoint:path,concurrency,seconds,requests:count,requestsPerSecond:+(count/seconds).toFixed(2),bytes,latencyMs:{p50:+percentile(latencies,.5).toFixed(2),p95:+percentile(latencies,.95).toFixed(2)},failures};
}
for(const [name,path,range] of scenarios) for(const concurrency of [1,10,50,200]) {
try { console.log(JSON.stringify({name,...await run(path,range,concurrency)})); }
catch(e) { console.error(JSON.stringify({name,path,concurrency,error:String(e)})); process.exitCode=1; }
}

71
alt/swoole/database.php Normal file
View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
final class Store
{
public ?PDO $pdo = null;
public function __construct(string $path, bool $initializeSchema = true)
{
if (!extension_loaded('pdo_sqlite')) throw new RuntimeException('pdo_sqlite is required');
@mkdir(dirname($path), 0775, true);
$this->pdo = new PDO('sqlite:' . $path, null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]);
$this->pdo->exec('PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=5000;');
if (!$initializeSchema) return;
$this->pdo->exec('PRAGMA journal_mode=WAL;');
$this->pdo->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS users (fingerprint TEXT PRIMARY KEY,last_version TEXT,last_seen INTEGER NOT NULL DEFAULT(unixepoch()));
CREATE TABLE IF NOT EXISTS playlists (fingerprint TEXT PRIMARY KEY,data TEXT NOT NULL DEFAULT '[]',updated_at INTEGER NOT NULL DEFAULT(unixepoch()));
CREATE TABLE IF NOT EXISTS video_history (fingerprint TEXT NOT NULL,video_id TEXT NOT NULL,title TEXT,channel TEXT,thumbnail TEXT,duration REAL DEFAULT 0,accessed_at INTEGER NOT NULL DEFAULT(unixepoch()),PRIMARY KEY(fingerprint,video_id));
CREATE INDEX IF NOT EXISTS idx_vh_fp_time ON video_history(fingerprint,accessed_at DESC);
CREATE TABLE IF NOT EXISTS profiles (name TEXT PRIMARY KEY,data TEXT NOT NULL DEFAULT '{}',created_at INTEGER NOT NULL DEFAULT(unixepoch()),updated_at INTEGER NOT NULL DEFAULT(unixepoch()),secret_hash TEXT,secret_kind TEXT);
CREATE TABLE IF NOT EXISTS media_cache (video_id TEXT PRIMARY KEY,status TEXT NOT NULL DEFAULT 'queued',gen INTEGER NOT NULL DEFAULT 0,size INTEGER NOT NULL DEFAULT 0,height INTEGER NOT NULL DEFAULT 0,vcodec TEXT,acodec TEXT,duration REAL NOT NULL DEFAULT 0,optimized INTEGER NOT NULL DEFAULT 0,meta TEXT NOT NULL DEFAULT '{}',priority INTEGER NOT NULL DEFAULT 1,auto INTEGER NOT NULL DEFAULT 0,attempts INTEGER NOT NULL DEFAULT 0,error TEXT,retry_at INTEGER NOT NULL DEFAULT 0,created_at INTEGER NOT NULL DEFAULT 0,updated_at INTEGER NOT NULL DEFAULT 0,last_access INTEGER NOT NULL DEFAULT 0,hits INTEGER NOT NULL DEFAULT 0,sha256 TEXT);
CREATE INDEX IF NOT EXISTS idx_media_lru ON media_cache(status,last_access);
CREATE TABLE IF NOT EXISTS search_cache (q TEXT PRIMARY KEY,results TEXT NOT NULL,size INTEGER NOT NULL DEFAULT 0,fetched_at INTEGER NOT NULL DEFAULT 0,last_access INTEGER NOT NULL DEFAULT 0);
CREATE INDEX IF NOT EXISTS idx_search_cache_lru ON search_cache(last_access);
CREATE TABLE IF NOT EXISTS video_meta (id TEXT PRIMARY KEY,card TEXT NOT NULL,hay TEXT NOT NULL,seen INTEGER NOT NULL DEFAULT 1,updated_at INTEGER NOT NULL DEFAULT 0);
CREATE INDEX IF NOT EXISTS idx_video_meta_upd ON video_meta(updated_at);
SQL);
}
public function close(): void { $this->pdo = null; }
public function one(string $sql, array $args = []): ?array { $s = $this->pdo->prepare($sql); $s->execute($args); $r = $s->fetch(); return $r === false ? null : $r; }
public function all(string $sql, array $args = []): array { $s = $this->pdo->prepare($sql); $s->execute($args); return $s->fetchAll(); }
public function run(string $sql, array $args = []): int { $s = $this->pdo->prepare($sql); $s->execute($args); return $s->rowCount(); }
public function syncUser(array $body): void
{
$fp = trim((string)($body['fingerprint'] ?? ''));
$this->run('INSERT INTO users(fingerprint,last_version,last_seen) VALUES(?,?,unixepoch()) ON CONFLICT(fingerprint) DO UPDATE SET last_version=excluded.last_version,last_seen=excluded.last_seen', [$fp, $body['appVersion'] ?? null]);
if (is_array($body['playlists'] ?? null)) $this->run('INSERT INTO playlists(fingerprint,data,updated_at) VALUES(?,?,unixepoch()) ON CONFLICT(fingerprint) DO UPDATE SET data=excluded.data,updated_at=excluded.updated_at', [$fp, json_encode($body['playlists'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)]);
if (is_array($body['recentVideo'] ?? null) && !empty($body['recentVideo']['id'])) $this->recordVideo($fp, $body['recentVideo']);
}
public function recordVideo(string $fp, array $v): void
{
$this->run('INSERT INTO video_history(fingerprint,video_id,title,channel,thumbnail,duration,accessed_at) VALUES(?,?,?,?,?,?,unixepoch()) ON CONFLICT(fingerprint,video_id) DO UPDATE SET title=excluded.title,channel=excluded.channel,thumbnail=excluded.thumbnail,duration=excluded.duration,accessed_at=excluded.accessed_at', [$fp, (string)$v['id'], $v['title'] ?? null, $v['channel'] ?? null, $v['thumbnail'] ?? null, (float)($v['duration'] ?? 0)]);
$this->run('DELETE FROM video_history WHERE fingerprint=? AND video_id NOT IN (SELECT video_id FROM video_history WHERE fingerprint=? ORDER BY accessed_at DESC LIMIT 200)', [$fp, $fp]);
}
public function userData(string $fp): array
{
$user = $this->one('SELECT last_version FROM users WHERE fingerprint=?', [$fp]);
$playlist = $this->one('SELECT data FROM playlists WHERE fingerprint=?', [$fp]);
$history = $this->all('SELECT video_id AS id,title,channel,thumbnail,duration FROM video_history WHERE fingerprint=? ORDER BY accessed_at DESC LIMIT 50', [$fp]);
return ['lastVersion' => $user['last_version'] ?? null, 'playlists' => $playlist ? json_decode($playlist['data'], true) ?: [] : [], 'history' => array_map(static fn($r) => ['id'=>$r['id'],'title'=>$r['title'],'channel'=>$r['channel'],'thumbnail'=>$r['thumbnail'],'duration'=>(float)$r['duration']], $history)];
}
public function rememberCards(array $cards): void
{
$now = time(); $this->pdo->beginTransaction();
try { foreach ($cards as $card) if (!empty($card['id'])) {
$json = json_encode($card, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$hay = mb_strtolower(($card['title'] ?? '') . ' ' . ($card['channel'] ?? ''));
$this->run('INSERT INTO video_meta(id,card,hay,seen,updated_at) VALUES(?,?,?,1,?) ON CONFLICT(id) DO UPDATE SET card=excluded.card,hay=excluded.hay,seen=video_meta.seen+1,updated_at=excluded.updated_at', [$card['id'],$json,$hay,$now]);
} $this->pdo->commit(); } catch (Throwable $e) { $this->pdo->rollBack(); throw $e; }
}
public function localSearch(string $query, int $limit = 60): array
{
$words = array_slice(preg_split('/\s+/u', mb_strtolower(trim($query))) ?: [], 0, 8); $words = array_filter($words, fn($w) => $w !== '');
if (!$words) return [];
$where = implode(' AND ', array_fill(0, count($words), "hay LIKE ? ESCAPE '!'"));
$args = array_map(fn($w) => '%' . str_replace(['!','%','_'], ['!!','!%','!_'], $w) . '%', $words); $args[] = $limit;
$rows = $this->all("SELECT card FROM video_meta WHERE $where ORDER BY seen DESC,updated_at DESC LIMIT ?", $args);
return array_values(array_filter(array_map(fn($r) => json_decode($r['card'], true), $rows)));
}
}

View File

@@ -0,0 +1,33 @@
services:
app:
build:
context: ../..
dockerfile: alt/swoole/Dockerfile
restart: unless-stopped
expose:
- "3000"
environment:
PORT: "3000"
DB_PATH: /app/data/ytplayer.sqlite
MEDIA_DIR: /app/data/media
MEDIA_VOLUME_MARKER: ${MEDIA_VOLUME_MARKER:-}
MEDIA_CACHE_MAX_BYTES: ${MEDIA_CACHE_MAX_BYTES:-10737418240}
APP_VERSION: ${APP_VERSION:-1.0.0}
SEARCH_INNERTUBE: ${SEARCH_INNERTUBE:-1}
volumes:
- swoole_data:/app/data
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:3000/api/version"]
interval: 30s
timeout: 5s
start_period: 10s
retries: 3
networks:
- dokploy-network
volumes:
swoole_data:
networks:
dokploy-network:
external: true

View File

@@ -0,0 +1,24 @@
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { createAssetManifest } from '../../server/asset-manifest.js';
const scenarios = [
['default', true, true],
['hashing-off', false, true],
['asset-sync-off', true, false],
];
for (const [name, hashing, assetSync] of scenarios) {
const php = spawnSync('php', ['-r', `require "alt/swoole/asset-manifest.php"; $m=new AssetManifest("frontend", ${hashing ? 'true' : 'false'}, ${assetSync ? 'true' : 'false'}); echo json_encode(["manifest"=>$m->manifest,"index"=>$m->index,"sw"=>$m->serviceWorker]);`], { encoding: 'utf8' });
assert.equal(php.status, 0, php.stderr);
const result = JSON.parse(php.stdout);
const bun = createAssetManifest('./frontend', { hashing, assetSync });
assert.equal(result.manifest.buildTag, bun.manifest.buildTag, `${name}: build tags match`);
assert.deepEqual(result.manifest.files, bun.manifest.files, `${name}: manifest file entries match`);
assert.deepEqual(result.manifest.groups, bun.manifest.groups, `${name}: asset groups match`);
assert.equal(result.index, bun.index, `${name}: stamped index bytes match`);
assert.equal(result.sw, bun.sw, `${name}: injected service worker bytes match`);
assert.equal(result.manifest.files['/admin.html'], undefined, `${name}: admin is not in the runtime manifest`);
assert.ok(result.index.includes(`<meta name="ytp-build" content="${result.manifest.buildTag}"`));
assert.match(result.sw, new RegExp(result.manifest.buildTag));
console.log(`manifest smoke passed (${name}): build=${result.manifest.buildTag}, files=${Object.keys(result.manifest.files).length}`);
}

109
alt/swoole/media-cache.php Normal file
View File

@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
/** Server-owned, resumable-by-cache media jobs. Jobs are detached from HTTP request lifetime. */
final class MediaCache
{
private array $jobs = [];
public function __construct(private Store $db, private string $dir, private ?string $volumeMarker, private int $budget, private string $ytDlp, private string $ffmpeg, private string $ffprobe)
{
@mkdir($this->dir, 0775, true); @mkdir($this->dir . '/.tmp', 0775, true);
}
public static function validId(string $id): bool { return (bool)preg_match('/^[A-Za-z0-9_-]{6,64}$/', $id); }
public function row(string $id): ?array { return $this->db->one('SELECT * FROM media_cache WHERE video_id=?', [$id]); }
public function path(string $id, ?int $gen = null, string $ext = 'mp4'): ?string
{
$r = $this->row($id); if (!$r || $r['status'] !== 'ready') return null;
$gen ??= (int)$r['gen']; if ($gen < (int)$r['gen'] - 1 || $gen > (int)$r['gen']) return null;
$path = $this->dir . '/' . $id . '.' . $gen . '.' . $ext; return is_file($path) ? $path : null;
}
public function touch(string $id): void { $this->db->run('UPDATE media_cache SET last_access=?,hits=hits+1 WHERE video_id=?', [time(), $id]); }
public function status(string $id): array
{
$r = $this->row($id); if (!$r) return ['status'=>'missing','size'=>0,'gen'=>0];
return ['status'=>$r['status'],'size'=>(int)$r['size'],'gen'=>(int)$r['gen'],'height'=>(int)$r['height'],'optimized'=>(bool)$r['optimized'],'error'=>$r['error'] ?: null,'duration'=>(float)$r['duration'],'retryAt'=>(int)$r['retry_at']];
}
public function queue(string $id, int $priority): void
{
if (!self::validId($id) || isset($this->jobs[$id])) return;
$r = $this->row($id); if ($r && $r['status'] === 'ready' && $this->path($id)) { $this->touch($id); return; }
if ($r && $r['retry_at'] && (int)$r['retry_at'] > time()) return;
$this->jobs[$id] = true;
go(function() use ($id, $priority) { try { $this->downloadAndValidate($id, $priority); } catch (Throwable $e) { $this->fail($id, $e->getMessage()); } finally { unset($this->jobs[$id]); } });
}
private function downloadAndValidate(string $id, int $priority): void
{
$lock = fopen($this->dir . '/.tmp/media-cache.lock', 'c');
if (!$lock) throw new RuntimeException('cannot open media cache lock');
while (!flock($lock, LOCK_EX | LOCK_NB)) Swoole\Coroutine::sleep(.05);
try {
$existing = $this->row($id);
if ($existing && $existing['status'] === 'ready' && $this->path($id)) { $this->touch($id); return; }
if ($existing && (int)$existing['retry_at'] > time()) return;
if ($this->volumeMarker && !is_file($this->volumeMarker)) throw new RuntimeException('media volume marker is missing');
$this->db->run("INSERT INTO media_cache(video_id,status,priority,auto,created_at,updated_at) VALUES(?,'downloading',?,?,?,?) ON CONFLICT(video_id) DO UPDATE SET status='downloading',priority=MIN(media_cache.priority,excluded.priority),error=NULL,updated_at=excluded.updated_at", [$id,$priority,$priority === 1 ? 1 : 0,time(),time()]);
$target = $this->dir . '/.tmp/' . $id . '.' . bin2hex(random_bytes(5)) . '.mp4';
try {
$url = 'https://www.youtube.com/watch?v=' . rawurlencode($id);
$format = 'bestvideo[height<=720][vcodec^=avc1]+bestaudio[acodec^=mp4a]/best[height<=720][ext=mp4]';
$this->run([$this->ytDlp,$url,'--no-warnings','--no-playlist','-f',$format,'--merge-output-format','mp4','--limit-rate',getenv('DOWNLOAD_RATE') ?: '2M','-o',$target]);
if (!is_file($target)) throw new RuntimeException('yt-dlp did not produce an MP4');
$probe = $this->run([$this->ffprobe,'-v','error','-show_entries','format=duration:stream=codec_type,codec_name,height,pix_fmt','-of','json',$target]);
$facts = json_decode($probe['out'], true, 512, JSON_THROW_ON_ERROR); $streams = $facts['streams'] ?? [];
$video = null; $audio = false;
foreach ($streams as $s) { if (($s['codec_type'] ?? '') === 'video') $video = $s; if (($s['codec_type'] ?? '') === 'audio' && ($s['codec_name'] ?? '') === 'aac') $audio = true; }
if (!$video || ($video['codec_name'] ?? '') !== 'h264' || !$audio || !preg_match('/^yuvj?420p$/', $video['pix_fmt'] ?? 'yuv420p')) throw new RuntimeException('validation: expected 8-bit H.264 + AAC media');
$duration = (float)($facts['format']['duration'] ?? 0); if ($duration <= 0 || filesize($target) < 100 * 1024) throw new RuntimeException('validation: file duration or size is invalid');
$this->db->run("UPDATE media_cache SET status='validating',updated_at=? WHERE video_id=?", [time(),$id]);
$this->run([$this->ffmpeg,'-v','error','-nostdin','-i',$target,'-map','0','-c','copy','-f','null','-']);
$this->run([$this->ffmpeg,'-v','error','-nostdin','-i',$target,'-t','3','-f','null','-']);
$this->run([$this->ffmpeg,'-v','error','-nostdin','-sseof','-5','-i',$target,'-f','null','-']);
$faststart = $target . '.faststart.mp4';
$this->run([$this->ffmpeg,'-v','error','-nostdin','-i',$target,'-map','0:v:0','-map','0:a:0?','-c','copy','-movflags','+faststart','-y',$faststart]);
if (!is_file($faststart) || filesize($faststart) < 100 * 1024) throw new RuntimeException('faststart remux produced an invalid MP4');
@unlink($target); if (!rename($faststart,$target)) throw new RuntimeException('could not publish faststart MP4');
$idInfo = $this->run([$this->ytDlp,'-J','--no-warnings','--no-playlist',$url]); $info = json_decode($idInfo['out'], true) ?: [];
$sidecar = preg_replace('/\.mp4$/','.m4a',$target);
$this->run([$this->ffmpeg,'-v','error','-nostdin','-i',$target,'-vn','-c:a','copy','-movflags','+faststart','-y',$sidecar]);
if (!is_file($sidecar) || filesize($sidecar) < 1024) throw new RuntimeException('audio sidecar extraction failed');
$old = $this->row($id); $gen = (int)($old['gen'] ?? 0) + 1;
$final = $this->dir . '/' . $id . '.' . $gen . '.mp4'; $audio = $this->dir . '/' . $id . '.' . $gen . '.m4a';
if (!rename($target,$final)) throw new RuntimeException('could not publish media file');
if (!rename($sidecar,$audio)) { @unlink($final); throw new RuntimeException('could not publish audio sidecar'); }
foreach (['mp4','m4a'] as $ext) foreach (glob($this->dir.'/'.$id.'.*.'.$ext) ?: [] as $oldFile) {
if (preg_match('/\.(\d+)\.'.preg_quote($ext,'/').'$/',$oldFile,$oldMatch) && (int)$oldMatch[1] < $gen - 1) @unlink($oldFile);
}
$size = filesize($final) + (is_file($audio) ? filesize($audio) : 0); $sha = hash_file('sha256',$final);
$meta = json_encode(['title'=>$info['title'] ?? '(untitled)','channel'=>$info['channel'] ?? $info['uploader'] ?? '', 'channelId'=>$info['channel_id'] ?? $info['uploader_id'] ?? '', 'channelUrl'=>$info['channel_url'] ?? $info['uploader_url'] ?? '', 'duration'=>$duration], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$this->db->run("UPDATE media_cache SET status='ready',gen=?,size=?,height=?,vcodec=?,acodec='aac',duration=?,optimized=1,meta=?,sha256=?,error=NULL,retry_at=0,attempts=0,updated_at=?,last_access=? WHERE video_id=?", [$gen,$size,(int)($video['height'] ?? 720),$video['codec_name'],$duration,$meta,$sha,time(),time(),$id]);
$this->evict();
} finally {
@unlink($target); @unlink($target . '.faststart.mp4'); @unlink(preg_replace('/\.mp4$/','.m4a',$target));
}
} finally { flock($lock, LOCK_UN); fclose($lock); }
}
private function run(array $command): array
{
$p = proc_open($command, [['pipe','r'],['pipe','w'],['pipe','w']], $pipes, null, null, ['bypass_shell'=>true]);
if (!is_resource($p)) throw new RuntimeException('could not start ' . $command[0]);
fclose($pipes[0]); stream_set_blocking($pipes[1], false); stream_set_blocking($pipes[2], false); $out=''; $err='';
$exitCode=null;
while (true) { $out .= stream_get_contents($pipes[1]); $err .= stream_get_contents($pipes[2]); $status=proc_get_status($p); if (!$status['running']) { $exitCode=(int)$status['exitcode']; break; } Swoole\Coroutine::sleep(.05); }
$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, -800)) ?: ($command[0] . ' failed'));
return ['out'=>$out,'err'=>$err];
}
private function fail(string $id, string $error): void
{
$r=$this->row($id); $tries=(int)($r['attempts'] ?? 0)+1; $delay=min(86400, 900 * (2 ** min(6,$tries-1)));
$this->db->run("INSERT INTO media_cache(video_id,status,attempts,error,retry_at,updated_at) VALUES(?,'failed',?,?,?,?) ON CONFLICT(video_id) DO UPDATE SET status='failed',attempts=excluded.attempts,error=excluded.error,retry_at=excluded.retry_at,updated_at=excluded.updated_at", [$id,$tries,substr($error,0,800),time()+$delay,time()]);
}
private function evict(): void
{
$max=$this->budget; $rows=$this->db->all("SELECT video_id,gen,last_access FROM media_cache WHERE status='ready' ORDER BY last_access ASC"); $sizes=[];
foreach($rows as $r){$id=$r['video_id'];$bytes=0;foreach(glob($this->dir.'/'.$id.'.*.mp4') ?: [] as $file)$bytes+=filesize($file) ?: 0;foreach(glob($this->dir.'/'.$id.'.*.m4a') ?: [] as $file)$bytes+=filesize($file) ?: 0;$sizes[$id]=$bytes;}
$total=array_sum($sizes);
foreach ($rows as $r) { if ($total <= $max) break; $id=$r['video_id']; foreach (glob($this->dir.'/'.$id.'.*.mp4') ?: [] as $file) @unlink($file); foreach (glob($this->dir.'/'.$id.'.*.m4a') ?: [] as $file) @unlink($file); $this->db->run("UPDATE media_cache SET status='missing',size=0,updated_at=? WHERE video_id=?", [time(),$id]); $total -= $sizes[$id] ?? 0; }
}
}

231
alt/swoole/server.php Normal file
View File

@@ -0,0 +1,231 @@
<?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';
Swoole\Runtime::enableCoroutine(true, SWOOLE_HOOK_ALL);
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();
$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)(shell_exec('getconf _NPROCESSORS_ONLN 2>/dev/null') ?: 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) {
$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();