Compare commits
6 Commits
codex/jame
...
codex/swoo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7f3335a9a | ||
|
|
83f4a121d1 | ||
|
|
d5a175cf45 | ||
|
|
6afb5aed6e | ||
|
|
1130fabc94 | ||
|
|
f465cc86d0 |
43
alt/compare.mjs
Normal file
43
alt/compare.mjs
Normal 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
5
alt/swoole/BENCH.md
Normal 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
27
alt/swoole/Dockerfile
Normal 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 unzip 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
31
alt/swoole/README.md
Normal 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.
|
||||
154
alt/swoole/asset-manifest.php
Normal file
154
alt/swoole/asset-manifest.php
Normal 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
28
alt/swoole/bench.mjs
Normal 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
71
alt/swoole/database.php
Normal 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)));
|
||||
}
|
||||
}
|
||||
33
alt/swoole/docker-compose.yml
Normal file
33
alt/swoole/docker-compose.yml
Normal 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
|
||||
24
alt/swoole/manifest-smoke.mjs
Normal file
24
alt/swoole/manifest-smoke.mjs
Normal 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
109
alt/swoole/media-cache.php
Normal 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; }
|
||||
}
|
||||
}
|
||||
232
alt/swoole/server.php
Normal file
232
alt/swoole/server.php
Normal file
@@ -0,0 +1,232 @@
|
||||
<?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))&µtime(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();
|
||||
39
plans/mysql-search-swoole-plan.md
Normal file
39
plans/mysql-search-swoole-plan.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Plan: built-in search data in MySQL, and a Swoole port (benchmark first)
|
||||
|
||||
Owner request 2026-10-13. Nothing here is built yet. Phase 0 decides whether phases 2–3 happen at all.
|
||||
|
||||
## Why
|
||||
- Cold search is ~12.6 s (yt-dlp) and every request pays the ~250 ms home -> VPS -> WireGuard path several times (see memory `worship-slow-cloudflare-plan`).
|
||||
- Today's "quick search" is `search_cache` (query string -> JSON blob, libsql) plus `video_catalog` / `GET /api/search/local`. It only helps for a query someone already ran.
|
||||
- Goal: answer *any* worship-song query from a local index in a few ms, before touching YouTube, and handle more concurrent users per box.
|
||||
|
||||
## Phase 0 - measure before committing (1 day, no production change)
|
||||
1. Capture a baseline on the current Bun/Hono server: `/api/search/local`, cached `/api/search`, `/api/version`, static asset, `/api/media/<id>` range request. Tool: `bombardier`/`oha` at 1/10/50/200 connections, from the homelab VM itself (removes the ISP path from the numbers).
|
||||
2. Build a throwaway Swoole (OpenSwoole/Swoole 5, PHP 8.3, coroutine MySQL pool) service that serves the same three endpoints from MySQL. Base it on `personal/AppStarterSwooleLite`. Same box, same data.
|
||||
3. Compare req/s, p50/p95/p99, RSS, CPU at each concurrency level. Also compare streaming a 10 MB slice (proxy path), since that is where Bun may already win or tie.
|
||||
4. Decision gate: port only if Swoole is clearly better (>= 1.5x req/s or half the p99) on the endpoints that matter. If the gap is small, keep Bun and only do the MySQL index (phase 1).
|
||||
|
||||
## Phase 1 - MySQL search index (independent of the port)
|
||||
- Table `song_index(video_id PK, title, uploader, title_norm, uploader_norm, duration, views, published, thumb, lang, source, updated_at)` with `FULLTEXT(title_norm, uploader_norm)` (ngram parser for CJK / Tagalog / Cebuano titles) plus a prefix index on `title_norm` for as-you-type.
|
||||
- Seed from data we already own: `video_catalog`, `search_cache` result blobs (de-duplicated by video id), saved/cached media ids, lyrics-library ids, admin-curated lists. Optionally a one-off yt-dlp crawl of known worship channels.
|
||||
- Query path: normalise (lower-case, strip accents/punctuation) -> `MATCH ... AGAINST` in boolean mode with title boost -> rank by relevance, then views/recency -> return card JSON in the same shape as `/api/search`. Answer from MySQL first; if fewer than N good hits, fall through to yt-dlp and write what comes back into `song_index` (self-growing).
|
||||
- Background refresh job: re-resolve stale rows, drop dead videos.
|
||||
- API: keep `/api/search` contract unchanged; add `source: "index"|"live"` to the response so the UI can show instantly and then top up.
|
||||
- MySQL runs on the homelab (same host as the app, local socket, no WireGuard hop). Needs a decision on engine: reuse an existing Dokploy MySQL service or add a dedicated one.
|
||||
- Tests: ranking fixtures (exact title, partial, typo, non-Latin), fall-through behaviour, de-dupe, and a latency budget (< 30 ms p95 at 10k rows, < 80 ms at 1M).
|
||||
|
||||
## Phase 2 - Swoole port (only if phase 0 says yes)
|
||||
- Strangler approach, not a rewrite: Swoole service takes over read-heavy, stateless routes first (`/api/search*`, `/api/version`, static + manifest delivery, notes read). Bun keeps the stateful parts (yt-dlp pool, media cache writes, party/remote WebSockets, p2p hub, transcription) behind it; a small router (Traefik path rules or Swoole reverse-proxy) splits traffic.
|
||||
- Move route groups one by one behind a flag; each move must pass the existing server tests re-expressed against the new service plus the Playwright suites unchanged.
|
||||
- Things that do not port cleanly and stay in Bun for now: `yt-dlp` worker pool, WebSocket party/remote hubs, OPFS-related admin tooling.
|
||||
- Rollback = flip the router rule back; both services share the same MySQL.
|
||||
|
||||
## Phase 3 - cutover and capacity
|
||||
- Beta first (beta.worship.hesed.sbs), 1 week soak, then main.
|
||||
- Publish capacity numbers (users per box) from the phase-0 harness re-run against the final stack.
|
||||
|
||||
## Risks / open decisions for the owner
|
||||
- Does the homelab have the headroom for MySQL + Swoole next to the app (the USB cache drive is currently unmounted and the host froze on past USB incidents)?
|
||||
- Is it OK to run a PHP service in this repo, or should the port live in a separate repo (`ytplayer-swoole`)?
|
||||
- Index scope: only songs users have searched, or a proactive crawl of worship channels (more storage and yt-dlp load)?
|
||||
- Mostly a latency problem is the network path, not the runtime: a Swoole port will not fix the 250 ms RTT. The Cloudflare tunnel plan still stands and is complementary.
|
||||
Reference in New Issue
Block a user