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