5 Commits

Author SHA1 Message Date
Jonathan Sykes
c7f3335a9a Pass etagMatches into the request handler 2026-10-14 03:25:29 +08:00
Jonathan Sykes
83f4a121d1 Use swoole_cpu_num instead of shelling out before the server starts 2026-10-14 03:16:14 +08:00
Jonathan Sykes
d5a175cf45 Enable Swoole hooks after the blocking start-up work 2026-10-14 02:46:27 +08:00
Jonathan Sykes
6afb5aed6e Install unzip so the deno installer works in the Swoole image 2026-10-14 02:16:00 +08:00
Jonathan Sykes
1130fabc94 Add Swoole alternative backend 2026-10-14 00:43:38 +08:00
21 changed files with 728 additions and 4225 deletions

View File

@@ -1,39 +0,0 @@
#!/usr/bin/env node
const base = process.argv[2];
if (!base) { console.error('usage: node alt/bench.mjs <baseUrl> [--duration-ms=10000]'); process.exit(2); }
const duration = Number(process.argv.find(x => x.startsWith('--duration-ms='))?.split('=')[1]) || 10000;
const cachedId = process.env.TEST_MEDIA_ID || 'dQw4w9WgXcQ';
const query = encodeURIComponent(process.env.BENCH_QUERY || 'ytplayer-bench-seed');
const manifest = await (await fetch(new URL('/api/manifest', base))).json();
const cssHash = manifest.files?.['/styles.css']?.h;
const targets = [
['version', '/api/version'],
['static', `/styles.css${cssHash ? `?v=${cssHash}` : ''}`],
['cached-search', `/api/search?q=${query}`],
['media-range', `/api/media/${cachedId}?g=1`, { Range: 'bytes=0-1048575' }],
];
async function run(label, path, concurrency, headers = {}) {
let count = 0, responseBytes = 0, errors = 0;
const latencies = [];
const until = Date.now() + duration;
async function worker() {
while (Date.now() < until) {
const start = performance.now();
try {
const response = await fetch(new URL(path, base), { headers });
const body = await response.arrayBuffer();
responseBytes += body.byteLength;
if (!response.ok) errors++;
} catch { errors++; }
latencies.push(performance.now() - start);
count++;
}
}
await Promise.all(Array.from({ length: concurrency }, worker));
latencies.sort((a, b) => a - b);
const pick = p => latencies[Math.min(latencies.length - 1, Math.floor(latencies.length * p))] || 0;
return { route: label, concurrency, durationMs: duration, requests: count, rps: +(count / (duration / 1000)).toFixed(1), responseBytes, errors, p50Ms: +pick(.5).toFixed(1), p95Ms: +pick(.95).toFixed(1), p99Ms: +pick(.99).toFixed(1) };
}
for (const concurrency of [1, 10, 50, 200]) {
for (const [label, path, headers] of targets) console.log(JSON.stringify(await run(label, path, concurrency, headers)));
}

View File

@@ -1,71 +1,43 @@
#!/usr/bin/env node #!/usr/bin/env node
import assert from 'node:assert/strict'; const base = process.argv[2];
if (!base) { console.error('usage: node alt/compare.mjs <bun-base-url>'); process.exit(2); }
const [baseUrl, candidateUrl = process.env.RUST_URL || 'http://127.0.0.1:3000'] = process.argv.slice(2); const alt = (process.env.ALT_URL || 'http://127.0.0.1:3000').replace(/\/$/, '');
if (!baseUrl) { const video = process.env.VIDEO_ID || 'dQw4w9WgXcQ';
console.error('usage: node alt/compare.mjs <bun-base-url> [rust-url]'); const generation = process.env.MEDIA_GEN || '1';
process.exit(2); const query = process.env.SEARCH_QUERY || 'worship';
} const paths = [
const mediaId = process.env.TEST_MEDIA_ID || 'dQw4w9WgXcQ'; ['search', `/api/search?q=${encodeURIComponent(query)}`],
const testQuery = encodeURIComponent(process.env.TEST_QUERY || 'ytplayer-bench-seed'); ['streams', `/api/streams?v=${encodeURIComponent(video)}`],
const results = []; ['download-prepare', `/api/download/${encodeURIComponent(video)}/prepare`],
const shape = value => Array.isArray(value) ? value.map(shape) : value && typeof value === 'object' ['media-range', `/api/media/${encodeURIComponent(video)}?g=${encodeURIComponent(generation)}`],
? Object.fromEntries(Object.keys(value).sort().map(key => [key, shape(value[key])])) : typeof value;
const headerSet = response => ({
contentType: (response.headers.get('content-type') || '').split(';')[0],
cache: response.headers.get('cache-control'),
range: response.headers.get('content-range'),
acceptRanges: response.headers.get('accept-ranges'),
encoding: response.headers.get('content-encoding'),
assetHash: response.headers.get('x-asset-hash'),
etag: response.headers.get('etag'),
});
async function inspect(url, path, { method = 'GET', headers = {} } = {}) {
const response = await fetch(new URL(path, url), { method, headers });
const type = response.headers.get('content-type') || '';
let body = null;
if (type.includes('json')) body = await response.json();
else if (type.startsWith('text/')) body = await response.text();
else await response.arrayBuffer();
return { status: response.status, headers: headerSet(response), shape: body ? shape(body) : null, body };
}
function check(name, a, b, fields) {
const headersMatch = fields.every(field => a.headers[field] === b.headers[field]);
const compatible = a.status === b.status && JSON.stringify(a.shape) === JSON.stringify(b.shape) && headersMatch;
results.push({ name, compatible, base: { status: a.status, headers: a.headers, shape: a.shape }, candidate: { status: b.status, headers: b.headers, shape: b.shape } });
console.log(`${compatible ? 'PASS' : 'DIFF'} ${name}: ${a.status} / ${b.status}${headersMatch ? '' : ` headers ${JSON.stringify(a.headers)} / ${JSON.stringify(b.headers)}`}`);
}
const [baseManifest, candidateManifest] = await Promise.all([inspect(baseUrl, '/api/manifest'), inspect(candidateUrl, '/api/manifest')]);
check('manifest', baseManifest, candidateManifest, ['cache', 'etag', 'contentType']);
for (const [name, url, manifest] of [['base', baseUrl, baseManifest.body], ['candidate', candidateUrl, candidateManifest.body]]) {
const [index, sw] = await Promise.all([inspect(url, '/'), inspect(url, '/sw.js')]);
const htmlText = index.body;
const swText = sw.body;
const tagPattern = new RegExp(`<meta[^>]*name="ytp-build"[^>]*content="${manifest.buildTag}"`);
const stamped = index.status === 200 && tagPattern.test(htmlText) && (htmlText.includes(`styles.css?v=${manifest.files['/styles.css']?.h}`) || htmlText.includes(`styles.css?v=${manifest.buildTag}`));
const workerStamped = sw.status === 200 && swText.includes(`"${manifest.buildTag}"`) && !swText.includes("typeof __BUILD_TAG__ !== 'undefined'");
const compatible = stamped && workerStamped;
results.push({ name: `${name}-shell-stamping`, compatible, stamped, workerStamped });
console.log(`${compatible ? 'PASS' : 'DIFF'} ${name}-shell-stamping`);
}
const cssHash = baseManifest.body?.files?.['/styles.css']?.h || '';
const checks = [
['version', '/api/version'],
['static', `/styles.css?v=${cssHash}`],
['search-local', `/api/search/local?q=${testQuery}`],
['search', `/api/search?q=${testQuery}`],
['streams', `/api/streams?v=${mediaId}`],
['download-prepare', `/api/download/${mediaId}/prepare`],
['media-status', `/api/media/${mediaId}/status`],
]; ];
for (const [name, path] of checks) { function jsonPaths(value, prefix = '$', out = []) {
const [base, candidate] = await Promise.all([inspect(baseUrl, path), inspect(candidateUrl, path)]); if (Array.isArray(value)) { out.push(`${prefix}[]`); if (value.length) jsonPaths(value[0], `${prefix}[]`, out); }
const fields = name === 'static' ? ['contentType', 'cache', 'encoding', 'assetHash'] : ['contentType']; else if (value && typeof value === 'object') for (const key of Object.keys(value).sort()) { out.push(`${prefix}.${key}`); jsonPaths(value[key], `${prefix}.${key}`, out); }
check(name, base, candidate, fields); return out.sort();
} }
const [baseRange, rustRange] = await Promise.all([ async function request(url, range = false) {
inspect(baseUrl, `/api/media/${mediaId}?g=1`, { headers: { Range: 'bytes=0-63' } }), const headers = range ? { Range: 'bytes=0-1023' } : {};
inspect(candidateUrl, `/api/media/${mediaId}?g=1`, { headers: { Range: 'bytes=0-63' } }), const response = await fetch(url, { headers, signal: AbortSignal.timeout(Number(process.env.COMPARE_TIMEOUT_MS || 30000)) });
]); const type = response.headers.get('content-type') || '';
check('media-range', baseRange, rustRange, ['contentType', 'cache', 'range', 'acceptRanges', 'etag']); const body = type.includes('json') ? await response.json() : await response.arrayBuffer();
if (results.some(result => !result.compatible)) process.exitCode = 1; 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;

1
alt/rust/.gitignore vendored
View File

@@ -1 +0,0 @@
/target/

View File

@@ -1,27 +0,0 @@
# Local backend A/B benchmark
Measured 2026-10-14 on the same Linux host, Rust optimized release build vs Bun/Hono, loopback HTTP, Chromium not involved in the load generator. Both services served the identical `frontend/` tree and used a seeded SQLite search row plus a 1 MiB cached-media fixture. The benchmark ran sequentially, four routes, 750 ms per route/concurrency. `responseBytes` are Node fetch's decoded response-body bytes, not raw compressed wire bytes. These are short smoke measurements; use a longer run and production-like media/network before drawing capacity conclusions.
Command:
```sh
BENCH_QUERY=rust-backend-parity-20261014 TEST_MEDIA_ID=dQw4w9WgXcQ node alt/bench.mjs http://127.0.0.1:3101 --duration-ms=750
BENCH_QUERY=rust-backend-parity-20261014 TEST_MEDIA_ID=dQw4w9WgXcQ node alt/bench.mjs http://127.0.0.1:3100 --duration-ms=750
```
Each cell is `requests/sec (p95 ms)`; all 32 route/concurrency runs reported zero errors.
| Concurrency | Backend | `/api/version` | CSS asset | Cached search | 1 MiB media range |
|---:|---|---:|---:|---:|---:|
| 1 | Bun | 2,425 (0.7) | 521 (3.4) | 1,041 (2.5) | 612 (3.5) |
| 1 | Rust release | 1,844 (0.9) | 665 (2.0) | 2,269 (0.6) | 480 (3.9) |
| 10 | Bun | 7,008 (2.7) | 1,179 (12.4) | 1,623 (17.2) | 685 (27.0) |
| 10 | Rust release | 3,872 (3.3) | 1,131 (12.9) | 4,228 (3.1) | 599 (27.4) |
| 50 | Bun | 10,033 (11.3) | 1,279 (44.3) | 1,783 (54.7) | 769 (105.5) |
| 50 | Rust release | 4,267 (17.4) | 1,199 (52.7) | 4,733 (14.5) | 689 (135.4) |
| 200 | Bun | 9,947 (33.7) | 1,333 (203.0) | 1,863 (161.0) | 1,029 (266.3) |
| 200 | Rust release | 4,396 (59.8) | 1,328 (202.7) | 4,533 (59.9) | 863 (644.7) |
The Rust backend led the cached-search route in this short run and was close on static CSS at high concurrency. Bun served `/api/version` and large media ranges faster at high concurrency; Rust's 1 MiB Range p95 widened to 644.7 ms at 200 connections. The shared in-memory static snapshot and boot-time Brotli/gzip encodings avoided disk lookup and per-request recompression, but the Range implementation still streams through Tokio file reads. No result implies production superiority.
Bun's `yt-dlp` Python worker could not import the system `yt_dlp` package on this host. Bench routes were seeded and do not exercise YouTube upstream latency or media encoding. Docker is unavailable on this host, so image build/compose smoke was not run.

1919
alt/rust/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,25 +0,0 @@
[package]
name = "ytplayer-rust-backend"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = { version = "0.8", features = ["http1", "tokio", "query", "json"] }
base64 = "0.22"
brotli = "9"
bytes = "1"
flate2 = "1"
futures-util = "0.3"
hex = "0.4"
http = "1"
mime_guess = "2"
regex = "1"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "stream", "gzip", "brotli"] }
rusqlite = { version = "0.32", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["preserve_order"] }
sha2 = "0.10"
tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.7", features = ["io"] }
tower-http = { version = "0.6", features = ["compression-full", "cors", "trace"] }
url = "2"

View File

@@ -1,22 +0,0 @@
# Build from the repository root: docker build -f alt/rust/Dockerfile -t ytplayer-rust .
FROM rust:1-bookworm AS builder
WORKDIR /build
COPY alt/rust/Cargo.toml alt/rust/Cargo.lock ./
COPY alt/rust/src ./src
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \
--mount=type=cache,target=/build/target \
cargo build --release && cp target/release/ytplayer-rust-backend /tmp/ytplayer-rust-backend
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl ffmpeg python3 python3-pip \
&& pip3 install --break-system-packages --no-cache-dir yt-dlp \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /tmp/ytplayer-rust-backend /usr/local/bin/ytplayer-rust-backend
COPY frontend/ /app/public/
RUN mkdir -p /app/data/media
ENV PORT=3000 PUBLIC_DIR=/app/public DB_PATH=/app/data/ytplayer.db MEDIA_DIR=/app/data/media \
MEDIA_CACHE_MAX_BYTES=10737418240 YTDLP_PATH=/usr/local/bin/yt-dlp FFMPEG_PATH=/usr/bin/ffmpeg FFPROBE_PATH=/usr/bin/ffprobe
EXPOSE 3000
ENTRYPOINT ["/usr/local/bin/ytplayer-rust-backend"]

View File

@@ -1,29 +0,0 @@
# Rust backend experiment
Drop-in web backend experiment using Axum/Tokio, Tower HTTP compression, reqwest/rustls and SQLite. The existing `frontend/` is copied at image build time and is not modified.
## Run locally
From the repository root:
```sh
cargo run --manifest-path alt/rust/Cargo.toml
```
By default the service serves `./frontend` when `./public` does not exist. Set `PUBLIC_DIR`, `PORT`, `DB_PATH`, `MEDIA_DIR`, `MEDIA_VOLUME_MARKER`, `MEDIA_CACHE_MAX_BYTES`, `YTDLP_PATH`, `FFMPEG_PATH`, `FFPROBE_PATH`, `APP_VERSION`, `ASSET_HASHING`, and `ASSET_SYNC` as needed. `yt-dlp`, `ffmpeg` and `ffprobe` must be on PATH for live search/streams/media jobs.
The UI is available at `http://localhost:3000/`; `/api/version` is the health endpoint. Set `BUILD_TAG` only for the legacy `ASSET_HASHING=0` rollback mode. Normal mode computes the tag from runtime assets and stamps each CSS/JS URL.
## Container
From the repository root, `docker build -f alt/rust/Dockerfile -t ytplayer-rust .` or `docker compose -f alt/rust/docker-compose.yml up --build`. Compose exposes container port 3000 on external `dokploy-network` and persists SQLite/media data in `/app/data`; it intentionally has no host port mapping or proxy labels.
## Compare and benchmark
`node alt/compare.mjs <bun-base-url> [rust-url]` compares status, JSON shape, selected headers, static stamps, and byte-range responses. Set `TEST_MEDIA_ID` to a cached 11-character id for stream/download/range checks. `node alt/rust/browser-smoke.mjs <rust-url>` opens the actual UI in Chromium; set `SMOKE_QUERY` to a cached query to exercise search and card rendering. The benchmark command and measured local results are in [BENCH.md](BENCH.md).
## Implemented surface and known gaps
Static SPA delivery, asset manifest/version/SW stamping, compression, search with in-memory/SQLite cache + InnerTube and yt-dlp fallback, stream resolution/warming, YouTube range proxy, validated basic media caching, download preparation/range transfer, and local profile/user persistence are implemented. The notes/admin/P2P/party/remote/upload families and saved-profile password/key protection are intentionally not in this experimental build. Media caching deliberately skips HEVC optimization, advanced retry/backoff lanes, full LRU free-space guards, upload media, chapters, and transcode policy parity; use the production Bun backend for those requirements.
The cache job is detached from request lifetimes, but the current validation is a reduced safety gate (H.264 + AAC, complete demux, size/duration checks) and is not yet the production validation suite. This is an A/B prototype, not a production replacement.

View File

@@ -1,26 +0,0 @@
#!/usr/bin/env node
import assert from 'node:assert/strict';
import { chromium } from 'playwright';
const base = process.argv[2] || 'http://127.0.0.1:3000';
const query = process.env.SMOKE_QUERY;
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
const pageErrors = [];
page.on('pageerror', error => pageErrors.push(error.message));
try {
const response = await page.goto(base, { waitUntil: 'domcontentloaded', timeout: 30000 });
assert.equal(response?.status(), 200, 'index page should load');
assert.equal(await page.title(), 'YT Player');
assert.equal(await page.locator('meta[name="ytp-build"]').getAttribute('content'), (await (await fetch(new URL('/api/version', base))).json()).buildTag);
assert.equal(await page.locator('#searchInput').count(), 1, 'search control should mount');
if (query) {
await page.locator('#searchInput').fill(query);
await page.locator('#searchInput').press('Enter');
await page.waitForFunction(() => document.querySelectorAll('#cards .card').length > 0, { timeout: 15000 });
}
assert.deepEqual(pageErrors, [], 'page should not throw during boot or search');
console.log(JSON.stringify({ ok: true, url: base, title: await page.title(), searchCards: await page.locator('#cards .card').count(), pageErrors }));
} finally {
await browser.close();
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,464 +0,0 @@
use base64::{engine::general_purpose::STANDARD, Engine};
use bytes::Bytes;
use regex::Regex;
use serde_json::{json, Map, Value};
use sha2::{Digest, Sha256};
use std::{
collections::{BTreeMap, BTreeSet},
fs,
path::Path,
};
#[derive(Clone, Debug, serde::Serialize)]
pub struct AssetFile {
pub h: String,
pub s: usize,
pub g: String,
}
#[derive(Clone)]
pub struct AssetSnapshot {
pub build_tag: String,
pub manifest: Value,
pub delivery_files: BTreeMap<String, AssetFile>,
pub bytes: BTreeMap<String, Bytes>,
pub etags: BTreeMap<String, String>,
}
fn hash10(bytes: &[u8]) -> String {
hex::encode(Sha256::digest(bytes))[..10].to_string()
}
fn hash12(bytes: &[u8]) -> String {
hex::encode(Sha256::digest(bytes))[..12].to_string()
}
fn etag(bytes: &[u8]) -> String {
format!("\"{}\"", &hex::encode(Sha256::digest(bytes))[..32])
}
fn collect(dir: &Path, prefix: &str, out: &mut BTreeMap<String, Vec<u8>>) -> std::io::Result<()> {
let mut entries = fs::read_dir(dir)?.collect::<Result<Vec<_>, _>>()?;
entries.sort_by_key(|e| e.file_name());
for entry in entries {
let path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
let url = format!("{prefix}/{name}");
if path.is_dir() {
collect(&path, &url, out)?;
} else {
out.insert(url, fs::read(path)?);
}
}
Ok(())
}
fn runtime(path: &str, include: &BTreeSet<String>) -> bool {
if include.contains(path) {
return true;
}
path != "/admin.html"
&& !path.to_ascii_lowercase().split('/').any(|p| p == "perf")
&& ![".test.js", ".test.mjs", ".md", ".txt", ".c", ".entry.js"]
.iter()
.any(|s| path.to_ascii_lowercase().ends_with(s))
}
fn embed(
source: &str,
files: &BTreeMap<String, AssetFile>,
groups: &Value,
tag: &str,
asset_sync: bool,
hashing: bool,
) -> String {
let mut page_files = Map::new();
if let Some(groups_obj) = groups.as_object() {
for group in groups_obj.values() {
if let Some(paths) = group.get("files").and_then(Value::as_array) {
for p in paths.iter().filter_map(Value::as_str) {
if p == "/index.html" || p == "/sw.js" {
continue;
}
if let Some(file) = files.get(p) {
page_files.insert(p.to_string(), json!({"h":if hashing { file.h.as_str() } else { tag },"s":file.s,"g":file.g}));
}
}
}
}
}
let assets = json!({"buildTag":tag,"groups":groups,"files":page_files,"appCache":asset_sync})
.to_string()
.replace('<', "\\u003c");
let re = Regex::new(r#"(?s)(<script type="application/json" id="ytp-assets">).*?(</script>)"#)
.unwrap();
re.replace(source, format!("$1{assets}$2")).into_owned()
}
fn stamp(source: &str, files: &BTreeMap<String, AssetFile>, tag: &str, hashing: bool) -> String {
if !hashing {
let tag_re = Regex::new(r"(?is)<(?:script|link)\b[^>]*>").unwrap();
let attr_re = Regex::new(r#"(?i)\b(href|src)=["']([^"']+\.(?:css|js))["']"#).unwrap();
return tag_re
.replace_all(
&source.replace("__BUILD_TAG__", tag),
|caps: &regex::Captures| {
attr_re
.replace_all(&caps[0], |a: &regex::Captures| {
let raw = &a[2];
if raw.starts_with('/') || raw.starts_with("//") || raw.contains(':') {
return a[0].to_string();
}
let separator = if raw.contains('?') { '&' } else { '?' };
format!("{}=\"{}{}v={}\"", &a[1], raw, separator, tag)
})
.into_owned()
},
)
.into_owned();
}
let tag_re = Regex::new(r"(?is)<(?:script|link)\b[^>]*>").unwrap();
let attr_re = Regex::new(r#"\b(href|src)=(?:"([^"]+)"|'([^']+)')"#).unwrap();
let stamped = tag_re
.replace_all(source, |caps: &regex::Captures| {
let tag_text = caps.get(0).unwrap().as_str();
attr_re
.replace_all(tag_text, |a: &regex::Captures| {
let raw = a.get(2).or_else(|| a.get(3)).unwrap().as_str();
if raw.contains(":") || raw.starts_with("//") || raw.starts_with('#') {
return a[0].to_string();
}
let url = url::Url::parse(&format!("https://assets.invalid/{raw}"));
let Ok(parsed) = url else {
return a[0].to_string();
};
let path = parsed.path();
let Some(file) = files.get(path) else {
return a[0].to_string();
};
if !(path.ends_with(".css") || path.ends_with(".js")) || path == "/sw.js" {
return a[0].to_string();
}
let base = raw.split(['?', '#']).next().unwrap_or(raw);
let suffix = if let Some((_, query)) = raw.split_once('?') {
let rest = query
.split('#')
.next()
.unwrap_or("")
.split('&')
.filter(|p| !p.starts_with("v="))
.collect::<Vec<_>>()
.join("&");
if rest.is_empty() {
format!("?v={}", file.h)
} else {
format!("?v={}&{}", file.h, rest)
}
} else {
format!("?v={}", file.h)
};
let frag = raw
.split_once('#')
.map(|(_, f)| format!("#{f}"))
.unwrap_or_default();
let quote = if a.get(2).is_some() { "\"" } else { "'" };
format!("{}={}{}{}{}{}", &a[1], quote, base, suffix, frag, quote)
})
.into_owned()
})
.into_owned();
stamped.replace("__BUILD_TAG__", tag)
}
pub fn load(
root: &Path,
hashing: bool,
sync: bool,
tag_override: Option<&str>,
) -> Result<AssetSnapshot, Box<dyn std::error::Error>> {
let mut bytes = BTreeMap::new();
collect(root, "", &mut bytes)?;
let def: Value = bytes.get("/assets.json").map(|b| serde_json::from_slice(b)).transpose()?.unwrap_or_else(|| json!({"groups":{"core":{"contract":1,"eager":true,"files":bytes.keys().cloned().collect::<Vec<_>>()}}}));
let include = def
.get("include")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str)
.map(str::to_string)
.collect::<BTreeSet<_>>();
let mut groups = Map::new();
let mut contracts = Map::new();
let mut membership = BTreeMap::<String, String>::new();
if let Some(group_obj) = def.get("groups").and_then(Value::as_object) {
for name in group_obj.keys().cloned().collect::<BTreeSet<_>>() {
let group = group_obj[&name].clone();
let files = group
.get("files")
.and_then(Value::as_array)
.ok_or("asset group files missing")?;
let mut uniq = BTreeSet::new();
for f in files.iter().filter_map(Value::as_str) {
if !runtime(f, &include) {
return Err(format!("excluded runtime file in group {name}: {f}").into());
}
if !bytes.contains_key(f) {
return Err(format!("missing asset group file {f}").into());
}
if !uniq.insert(f) || membership.insert(f.into(), name.clone()).is_some() {
return Err(format!("duplicate asset membership: {f}").into());
}
}
contracts.insert(
name.clone(),
group.get("contract").cloned().unwrap_or(json!(1)),
);
groups.insert(name, group);
}
}
let groups = Value::Object(groups);
let contracts = Value::Object(contracts);
let mut files = BTreeMap::new();
let mut delivery_files = BTreeMap::new();
for (path, content) in &bytes {
let f = AssetFile {
h: hash10(content),
s: content.len(),
g: membership
.get(path)
.cloned()
.unwrap_or_else(|| "core".into()),
};
delivery_files.insert(path.clone(), f.clone());
if runtime(path, &include) {
files.insert(path.clone(), f);
}
}
let mut source = String::from_utf8(bytes.get("/index.html").cloned().unwrap_or_default())?;
if let Some(app) = bytes.get("/app.js") {
source = source.replace("__APP_SCRIPT_HASH__", &STANDARD.encode(Sha256::digest(app)));
}
let sync = hashing && sync;
let embedded = embed(&source, &files, &groups, "__BUILD_TAG__", sync, hashing);
let canonical_index = stamp(&embedded, &files, "__BUILD_TAG__", hashing);
let mut canonical_files = Map::new();
for (path, file) in &files {
let mut obj = Map::new();
obj.insert("h".into(), json!(file.h));
obj.insert("s".into(), json!(file.s));
obj.insert("g".into(), json!(file.g));
canonical_files.insert(path.clone(), Value::Object(obj));
}
if hashing && files.contains_key("/index.html") {
let original = &files["/index.html"];
let mut obj = Map::new();
obj.insert("h".into(), json!(hash10(canonical_index.as_bytes())));
obj.insert("s".into(), json!(canonical_index.len()));
obj.insert("g".into(), json!(original.g));
obj.insert("source".into(), json!(original.h));
canonical_files.insert("/index.html".into(), Value::Object(obj));
}
let canonical =
json!({"assetSync":sync,"files":canonical_files,"groups":groups,"contracts":contracts});
let mut legacy_hasher = Sha256::new();
for (path, content) in &bytes {
if runtime(path, &include) {
legacy_hasher.update(format!("./public{path}").as_bytes());
legacy_hasher.update(content);
}
}
let legacy_tag = hex::encode(legacy_hasher.finalize())[..12].to_string();
let build_tag = if hashing {
hash12(serde_json::to_string(&canonical)?.as_bytes())
} else {
tag_override.unwrap_or(&legacy_tag).to_string()
};
let index = embed(
&stamp(&source, &files, &build_tag, hashing),
&files,
&groups,
&build_tag,
sync,
hashing,
);
let mut sw =
String::from_utf8(bytes.get("/sw.js").cloned().unwrap_or_default()).unwrap_or_default();
let inject =
Regex::new(r"typeof __BUILD_TAG__ !== 'undefined' \? __BUILD_TAG__ : '[^']*'").unwrap();
sw = inject.replace(&sw, format!("\"{build_tag}\"")).into_owned();
sw = sw.replace(
"typeof __ASSET_SYNC__ !== 'undefined' ? __ASSET_SYNC__ : true",
if sync { "true" } else { "false" },
);
if let Some(core) = files.get("/asset-sync-core.js") {
sw = sw.replace(
"importScripts('/asset-sync-core.js')",
&format!("importScripts('/asset-sync-core.js?v={}')", core.h),
);
}
if !index.is_empty() {
files.insert(
"/index.html".into(),
AssetFile {
h: hash10(index.as_bytes()),
s: index.len(),
g: "core".into(),
},
);
}
if !sw.is_empty() {
files.insert(
"/sw.js".into(),
AssetFile {
h: hash10(sw.as_bytes()),
s: sw.len(),
g: "core".into(),
},
);
}
if !index.is_empty() {
bytes.insert("/index.html".into(), index.as_bytes().to_vec());
}
if !sw.is_empty() {
bytes.insert("/sw.js".into(), sw.as_bytes().to_vec());
}
delivery_files.extend(files.clone());
let manifest =
json!({"buildTag":build_tag,"files":files,"groups":groups,"contracts":contracts});
let etags = bytes
.iter()
.map(|(path, content)| (path.clone(), etag(content)))
.collect();
let bytes = bytes
.into_iter()
.map(|(path, content)| (path, Bytes::from(content)))
.collect();
Ok(AssetSnapshot {
build_tag,
manifest,
delivery_files,
bytes,
etags,
})
}
pub fn cache_control(
path: &str,
version: Option<&str>,
current: Option<&AssetFile>,
hashing: bool,
build_tag: &str,
) -> &'static str {
if !hashing && path == "/index.html" {
return "no-cache";
}
if let Some(version) = version {
if current.map(|f| f.h.as_str()) == Some(version) || (!hashing && version == build_tag) {
return "public, max-age=31536000, immutable";
}
return if hashing { "no-store" } else { "no-cache" };
}
if path.starts_with("/fonts/") || path.starts_with("/icons/") {
"public, max-age=2592000"
} else {
"no-cache"
}
}
#[cfg(test)]
mod tests {
use super::{cache_control, embed, stamp, AssetFile};
use std::{collections::BTreeMap, path::Path};
#[test]
fn repository_frontend_snapshot_has_hashed_assets_and_injected_tags() {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../frontend");
let snapshot = super::load(&root, true, true, None).expect("frontend asset manifest");
assert_eq!(snapshot.build_tag.len(), 12);
assert!(
snapshot.manifest["files"]["/app.js"]["h"]
.as_str()
.unwrap()
.len()
== 10
);
assert!(snapshot.bytes["/index.html"]
.windows(snapshot.build_tag.len())
.any(|w| w == snapshot.build_tag.as_bytes()));
let sw = String::from_utf8(snapshot.bytes["/sw.js"].to_vec()).unwrap();
assert!(sw.contains(&snapshot.build_tag));
let index = String::from_utf8(snapshot.bytes["/index.html"].to_vec()).unwrap();
assert!(index.contains(&format!(
"styles.css?v={}",
snapshot.manifest["files"]["/styles.css"]["h"]
.as_str()
.unwrap()
)));
assert!(index.contains(&format!(
r#""/app.js":{{"h":"{}""#,
snapshot.manifest["files"]["/app.js"]["h"].as_str().unwrap()
)));
}
#[test]
fn rollback_snapshot_uses_legacy_single_tag() {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../frontend");
let snapshot =
super::load(&root, false, false, Some("legacy-build")).expect("rollback snapshot");
assert_eq!(snapshot.build_tag, "legacy-build");
let index = String::from_utf8(snapshot.bytes["/index.html"].to_vec()).unwrap();
assert!(index.contains("styles.css?v=legacy-build"));
assert!(index.contains(r#""/app.js":{"h":"legacy-build""#));
assert!(index.contains(r#""appCache":false"#));
}
#[test]
fn legacy_stamping_only_changes_relative_urls_in_resource_tags() {
let input = r#"<link rel="stylesheet" href="styles.css"><script src="/root.js"></script><script src="https://cdn.test/app.js"></script><a href="other.js">link</a>"#;
let output = stamp(input, &BTreeMap::new(), "legacy-tag", false);
assert!(output.contains(r#"href="styles.css?v=legacy-tag""#));
assert!(output.contains(r#"src="/root.js""#));
assert!(output.contains(r#"src="https://cdn.test/app.js""#));
assert!(output.contains(r#"href="other.js""#));
}
#[test]
fn legacy_index_embeds_single_build_tag_for_each_asset() {
let file = AssetFile {
h: "file-hash".into(),
s: 10,
g: "core".into(),
};
let files = BTreeMap::from([("/app.js".to_string(), file)]);
let groups = serde_json::json!({"core":{"files":["/app.js"]}});
let source = r#"<script type="application/json" id="ytp-assets">{}</script>"#;
let output = embed(source, &files, &groups, "legacy-tag", false, false);
assert!(output.contains(r#""h":"legacy-tag""#));
assert!(!output.contains("file-hash"));
}
#[test]
fn cache_header_matrix_uses_legacy_build_tag_and_file_hashes() {
let file = AssetFile {
h: "abc123".into(),
s: 10,
g: "core".into(),
};
assert_eq!(
cache_control(
"/app.js",
Some("legacy-tag"),
Some(&file),
false,
"legacy-tag"
),
"public, max-age=31536000, immutable"
);
assert_eq!(
cache_control("/app.js", Some("old-tag"), Some(&file), false, "legacy-tag"),
"no-cache"
);
assert_eq!(
cache_control("/app.js", Some("abc123"), Some(&file), true, "build"),
"public, max-age=31536000, immutable"
);
assert_eq!(
cache_control("/app.js", Some("stale"), Some(&file), true, "build"),
"no-store"
);
}
}

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 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
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

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

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; }
}
}

232
alt/swoole/server.php Normal file
View 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))&&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();