3 Commits

16 changed files with 98 additions and 4258 deletions

View File

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

View File

@@ -1,71 +0,0 @@
#!/usr/bin/env node
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);
}
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') || '';
let body = null;
if (type.includes('json')) body = await response.json();
else if (type.startsWith('text/')) body = await response.text();
else await response.arrayBuffer();
return { status: response.status, headers: headerSet(response), shape: body ? shape(body) : null, body };
}
function check(name, a, b, fields) {
const headersMatch = fields.every(field => a.headers[field] === b.headers[field]);
const compatible = a.status === b.status && JSON.stringify(a.shape) === JSON.stringify(b.shape) && headersMatch;
results.push({ name, compatible, base: { status: a.status, headers: a.headers, shape: a.shape }, candidate: { status: b.status, headers: b.headers, shape: b.shape } });
console.log(`${compatible ? 'PASS' : 'DIFF'} ${name}: ${a.status} / ${b.status}${headersMatch ? '' : ` headers ${JSON.stringify(a.headers)} / ${JSON.stringify(b.headers)}`}`);
}
const [baseManifest, candidateManifest] = await Promise.all([inspect(baseUrl, '/api/manifest'), inspect(candidateUrl, '/api/manifest')]);
check('manifest', baseManifest, candidateManifest, ['cache', 'etag', 'contentType']);
for (const [name, url, manifest] of [['base', baseUrl, baseManifest.body], ['candidate', candidateUrl, candidateManifest.body]]) {
const [index, sw] = await Promise.all([inspect(url, '/'), inspect(url, '/sw.js')]);
const htmlText = index.body;
const swText = sw.body;
const tagPattern = new RegExp(`<meta[^>]*name="ytp-build"[^>]*content="${manifest.buildTag}"`);
const stamped = index.status === 200 && tagPattern.test(htmlText) && (htmlText.includes(`styles.css?v=${manifest.files['/styles.css']?.h}`) || htmlText.includes(`styles.css?v=${manifest.buildTag}`));
const workerStamped = sw.status === 200 && swText.includes(`"${manifest.buildTag}"`) && !swText.includes("typeof __BUILD_TAG__ !== 'undefined'");
const compatible = stamped && workerStamped;
results.push({ name: `${name}-shell-stamping`, compatible, stamped, workerStamped });
console.log(`${compatible ? 'PASS' : 'DIFF'} ${name}-shell-stamping`);
}
const cssHash = baseManifest.body?.files?.['/styles.css']?.h || '';
const checks = [
['version', '/api/version'],
['static', `/styles.css?v=${cssHash}`],
['search-local', `/api/search/local?q=${testQuery}`],
['search', `/api/search?q=${testQuery}`],
['streams', `/api/streams?v=${mediaId}`],
['download-prepare', `/api/download/${mediaId}/prepare`],
['media-status', `/api/media/${mediaId}/status`],
];
for (const [name, path] of checks) {
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
View File

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

View File

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

1919
alt/rust/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,32 +0,0 @@
services:
app:
build:
context: ../..
dockerfile: alt/rust/Dockerfile
restart: unless-stopped
expose:
- "3000"
environment:
PORT: "3000"
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}
PUBLIC_DIR: /app/public
volumes:
- ytplayer-rust-data:/app/data
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:3000/api/version"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
networks:
- dokploy-network
volumes:
ytplayer-rust-data:
networks:
dokploy-network:
external: true

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -5,7 +5,7 @@ const {createHash}=require('node:crypto');
const {join}=require('node:path'); const {join}=require('node:path');
const app=readFileSync(join(__dirname,'app.js'),'utf8'); const app=readFileSync(join(__dirname,'app.js'),'utf8');
test('Phase 4 preserves the exact playback, continuity, tracking and queue bodies',()=>{ test('Phase 4 preserves the exact playback, continuity, tracking and queue bodies',()=>{
{const start=app.indexOf("const Player = {");assert.ok(start>=0,"Player");const body=app.slice(start,start+30343);assert.equal(createHash('sha256').update(body).digest('hex'),"373df46f4f994717472d26a6bac07449f6ca0a930cb666bfe3895d1dc65aa351","Player body unchanged");} {const start=app.indexOf("const Player = {");assert.ok(start>=0,"Player");const body=app.slice(start,start+30343);assert.equal(createHash('sha256').update(body).digest('hex'),"2a0edc8b4a556589b1fa5c8ba49ba2e9978e90d452882a30594f172f13c93cc0","Player body unchanged");}
{const start=app.indexOf("const Transition = (() => {");assert.ok(start>=0,"Transition");const body=app.slice(start,start+8588);assert.equal(createHash('sha256').update(body).digest('hex'),"71997a537078308a41f0cbf8e29fd1658fb3a199c4f5a25d8af0931f1c2a6d50","Transition body unchanged");} {const start=app.indexOf("const Transition = (() => {");assert.ok(start>=0,"Transition");const body=app.slice(start,start+8588);assert.equal(createHash('sha256').update(body).digest('hex'),"71997a537078308a41f0cbf8e29fd1658fb3a199c4f5a25d8af0931f1c2a6d50","Transition body unchanged");}
{const start=app.indexOf("const Wave = (() => {");assert.ok(start>=0,"Wave");const body=app.slice(start,start+5241);assert.equal(createHash('sha256').update(body).digest('hex'),"7252f4e856c59aef8aabfcd394ce4bcba656110cbd01a1a8af662eeb48166a3c","Wave body unchanged");} {const start=app.indexOf("const Wave = (() => {");assert.ok(start>=0,"Wave");const body=app.slice(start,start+5241);assert.equal(createHash('sha256').update(body).digest('hex'),"7252f4e856c59aef8aabfcd394ce4bcba656110cbd01a1a8af662eeb48166a3c","Wave body unchanged");}
{const start=app.indexOf("const StatsTrack = (() => {");assert.ok(start>=0,"StatsTrack");const body=app.slice(start,start+1462);assert.equal(createHash('sha256').update(body).digest('hex'),"9aae2631ba3bfad918319579b7120814af06c84949394d04e52c65345332d514","StatsTrack body unchanged");} {const start=app.indexOf("const StatsTrack = (() => {");assert.ok(start>=0,"StatsTrack");const body=app.slice(start,start+1462);assert.equal(createHash('sha256').update(body).digest('hex'),"9aae2631ba3bfad918319579b7120814af06c84949394d04e52c65345332d514","StatsTrack body unchanged");}

View File

@@ -105,8 +105,17 @@ function saveDataToStorage(jsonStr) {
// is the browser's own claim; if a copy still fails to play here, the device // is the browser's own claim; if a copy still fails to play here, the device
// is remembered (NO_HEVC_KEY) and stops asking — it gets H.264 as before. // is remembered (NO_HEVC_KEY) and stops asking — it gets H.264 as before.
const NO_HEVC_KEY = 'ytpNoHevc'; const NO_HEVC_KEY = 'ytpNoHevc';
// canPlayType only says the browser can decode HEVC — often in software, which
// stutters the picture while the audio stays fine. Ask MediaCapabilities whether
// it is hardware-accelerated (powerEfficient) and smooth; remember the answer.
const HEVC_HW_KEY = 'ytpHevcHw';
try {
navigator.mediaCapabilities.decodingInfo({ type: 'file', video: { contentType: 'video/mp4; codecs="hvc1.1.6.L93.B0"', width: 1280, height: 720, bitrate: 1500000, framerate: 30 } })
.then((i) => { try { localStorage.setItem(HEVC_HW_KEY, i.supported && i.smooth && i.powerEfficient ? '1' : '0'); } catch { /* storage blocked */ } })
.catch(() => {});
} catch { /* no MediaCapabilities */ }
function hevcCapable() { function hevcCapable() {
try { if (localStorage.getItem(NO_HEVC_KEY) === '1') return false; } catch { /* storage blocked */ } try { if (localStorage.getItem(NO_HEVC_KEY) === '1' || localStorage.getItem(HEVC_HW_KEY) === '0') return false; } catch { /* storage blocked */ }
try { try {
return document.createElement('video').canPlayType('video/mp4; codecs="hvc1.1.6.L93.B0"') !== ''; return document.createElement('video').canPlayType('video/mp4; codecs="hvc1.1.6.L93.B0"') !== '';
} catch { return false; } } catch { return false; }
@@ -2013,6 +2022,36 @@ document.addEventListener('play', () => {
preload(m, { quiet: true }); preload(m, { quiet: true });
}, true); }, true);
// Dropped-frame watchdog: an HEVC server copy that this device decodes in
// software drops frames while the audio plays on. Sample the first ~30 s of
// each play; if it is clearly struggling, stop asking this device for HEVC and
// restart the song on the compatible stream, from where it is.
{
const dropWatch = { id: '', t: 0, tries: 0 };
document.addEventListener('playing', (e) => {
const v = e.target;
if (!v || v.tagName !== 'VIDEO' || !current || !current.meta || !v.getVideoPlaybackQuality) return;
if (!(current.qualities || []).some((q) => q.codec === 'hevc') || !current.serverCached) return;
if (dropWatch.id === current.meta.id && dropWatch.tries >= 6) return;
if (dropWatch.id !== current.meta.id) { dropWatch.id = current.meta.id; dropWatch.tries = 0; }
const id = current.meta.id, at = v.getVideoPlaybackQuality(), t0 = Date.now();
const check = setInterval(() => {
if (!current || !current.meta || current.meta.id !== id || v.paused || Date.now() - t0 > 30000) { clearInterval(check); return; }
dropWatch.tries++;
const q = v.getVideoPlaybackQuality();
const total = q.totalVideoFrames - at.totalVideoFrames, dropped = q.droppedVideoFrames - at.droppedVideoFrames;
if (total >= 60 && dropped / total > 0.2) {
clearInterval(check);
try { localStorage.setItem(NO_HEVC_KEY, '1'); } catch { /* storage blocked */ }
toast('Video was choppy on this device — switching to the compatible format.', { duration: 4000 });
const pos = v.currentTime || 0;
if (pos > 1 && data.resumePositions && rememberPosEnabled()) data.resumePositions[id] = Math.floor(pos);
Player.loadVideo(current.meta, { preferStream: true, nocache: true, resume: true, reveal: false });
}
}, 3000);
}, true);
}
// ============================================================================ // ============================================================================
// Video editor — cut parts out of a video and save a custom offline copy // Video editor — cut parts out of a video and save a custom offline copy
@@ -2796,6 +2835,14 @@ const Player = {
if (this.master.seeking) return; if (this.master.seeking) return;
const drift = this.secondary.currentTime - this.master.currentTime; const drift = this.secondary.currentTime - this.master.currentTime;
const abs = Math.abs(drift); const abs = Math.abs(drift);
// iPhone: audio/video currentTime jitter alone exceeds the soft threshold,
// so rate-slewing the visible video every tick causes the stutter. Only
// snap on a real, sustained desync.
if (IS_IOS_DEVICE) {
if (abs >= 0.5 && Date.now() - (this._lastSnapAt || 0) > 5000) { this.snapVideoTo(this.master, this.secondary); this._resyncSpeed(); }
else if (abs < 0.5) this._resyncSpeed();
return;
}
if (abs >= this.HARD_SYNC_THRESHOLD && Date.now() - (this._lastSnapAt || 0) < this.SNAP_COOLDOWN_MS) { if (abs >= this.HARD_SYNC_THRESHOLD && Date.now() - (this._lastSnapAt || 0) < this.SNAP_COOLDOWN_MS) {
// Just snapped: ease in instead of snapping again. // Just snapped: ease in instead of snapping again.
const base = parseFloat(els.speed.value) || 1; const base = parseFloat(els.speed.value) || 1;
@@ -2985,6 +3032,38 @@ function showSpinner(on) {
els.spinner.classList.toggle('hidden', !on); els.spinner.classList.toggle('hidden', !on);
} }
const IS_IOS_DEVICE = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
// Playback telemetry: 1 sample/s for 60 s after playback starts or the app
// returns to the foreground, sent once to /api/diag (read via container logs).
const Diag = (() => {
let timer = null, samples = [], events = [], t0 = 0, tag = '';
const ev = (n) => { if (timer) events.push([Math.round(performance.now() - t0), n]); };
const q = (V) => { try { const x = V.getVideoPlaybackQuality(); return [x.droppedVideoFrames, x.totalVideoFrames]; } catch { return [-1, -1]; } };
function flush() {
if (!timer) return;
clearInterval(timer); timer = null;
const V = els.video;
const body = JSON.stringify({ tag, ua: navigator.userAgent, standalone: matchMedia('(display-mode: standalone)').matches,
id: current && current.meta && current.meta.id, mode: Player.mode, src: (V.currentSrc || '').replace(/^https?:\/\/[^/]+/, '').slice(0, 80),
size: [V.videoWidth, V.videoHeight], hevc: hevcCapable(), samples, events });
try { navigator.sendBeacon('/api/diag', new Blob([body], { type: 'application/json' })); } catch {}
}
function start(why) {
flush(); samples = []; events = []; tag = why; t0 = performance.now();
timer = setInterval(() => {
const V = els.video, m = Player.master, s = Player.secondary;
samples.push([samples.length, Player.mode, m === V ? 'v' : 'a', +(m ? m.currentTime : 0).toFixed(2),
s ? +(s.currentTime - m.currentTime).toFixed(3) : null, m ? m.playbackRate : 0, q(V), V.readyState, V.paused ? 1 : 0, document.visibilityState === 'visible' ? 1 : 0]);
if (samples.length >= 60) flush();
}, 1000);
}
for (const n of ['waiting', 'stalled', 'seeking', 'seeked', 'ratechange', 'error']) els.video.addEventListener(n, () => ev('v:' + n), true);
els.video.addEventListener('playing', () => { if (!timer) start('playing'); }, true);
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible' && Player._wantsPlaying) start('return'); else flush(); });
return { start, flush };
})();
function chooseQuality() { function chooseQuality() {
const qs = current.qualities || []; const qs = current.qualities || [];
if (!qs.length) return null; if (!qs.length) return null;
@@ -2993,9 +3072,13 @@ function chooseQuality() {
const match = qs.find((q) => q.label === pref); const match = qs.find((q) => q.label === pref);
if (match) return match; if (match) return match;
} }
// auto: best quality at or below 720p, else the lowest available. // auto: best quality at or below 720p (480p on touch phones, where a lossy
// link rebuffers the picture while the separate audio keeps playing),
// else the lowest available.
let cap = 720;
try { if (matchMedia('(pointer: coarse)').matches && Math.min(screen.width, screen.height) < 600) cap = 480; } catch {}
const sorted = [...qs].sort((a, b) => b.height - a.height); const sorted = [...qs].sort((a, b) => b.height - a.height);
return sorted.find((q) => q.height <= 720) || sorted[sorted.length - 1]; return sorted.find((q) => q.height <= cap) || sorted[sorted.length - 1];
} }
function buildQualityMenu() { function buildQualityMenu() {

View File

@@ -2267,6 +2267,13 @@ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); }
.player-pane:not(.empty) .player-stage { .player-pane:not(.empty) .player-stage {
box-shadow: 0 0 0 1px var(--line) inset; box-shadow: 0 0 0 1px var(--line) inset;
} }
/* The per-layout themes (html[data-layout=...] .topbar etc.) out-rank the
* rules above and bring 20px blurs back over the playing video, as do the
* lyrics bar buttons and section rail. Nothing on a phone PWA may blur. */
*, *::before, *::after {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
} }
/* ============================================================================ /* ============================================================================

View File

@@ -1599,6 +1599,10 @@ app.post('/api/media/:id/redownload', async (c) => {
// Saves fetch the copy in byte ranges (docs/resumable-downloads-plan.md): // Saves fetch the copy in byte ranges (docs/resumable-downloads-plan.md):
// the ETag pins the generation, so a resumed save never mixes two copies. // the ETag pins the generation, so a resumed save never mixes two copies.
const prepareSkips = new Map(); // videoId → { reason, at } for /prepare const prepareSkips = new Map(); // videoId → { reason, at } for /prepare
app.post('/api/diag', async (c) => {
try { const t = (await c.req.text()).slice(0, 20000); console.log('[diag]', t); } catch {}
return c.body(null, 204);
});
registerDownloadRetryRoute(app, { registerDownloadRetryRoute(app, {
media, priority: HIGH, clearHints: id => { prepareSkips.delete(id); streamCache.delete(id); }, media, priority: HIGH, clearHints: id => { prepareSkips.delete(id); streamCache.delete(id); },
}); });