Compare commits
1 Commits
main
...
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)));
|
||||
}
|
||||
71
alt/compare.mjs
Normal file
71
alt/compare.mjs
Normal file
@@ -0,0 +1,71 @@
|
||||
#!/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
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();
|
||||
}
|
||||
32
alt/rust/docker-compose.yml
Normal file
32
alt/rust/docker-compose.yml
Normal file
@@ -0,0 +1,32 @@
|
||||
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
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ const {createHash}=require('node:crypto');
|
||||
const {join}=require('node:path');
|
||||
const app=readFileSync(join(__dirname,'app.js'),'utf8');
|
||||
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'),"2a0edc8b4a556589b1fa5c8ba49ba2e9978e90d452882a30594f172f13c93cc0","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'),"373df46f4f994717472d26a6bac07449f6ca0a930cb666bfe3895d1dc65aa351","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 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");}
|
||||
|
||||
@@ -105,17 +105,8 @@ function saveDataToStorage(jsonStr) {
|
||||
// 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.
|
||||
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() {
|
||||
try { if (localStorage.getItem(NO_HEVC_KEY) === '1' || localStorage.getItem(HEVC_HW_KEY) === '0') return false; } catch { /* storage blocked */ }
|
||||
try { if (localStorage.getItem(NO_HEVC_KEY) === '1') return false; } catch { /* storage blocked */ }
|
||||
try {
|
||||
return document.createElement('video').canPlayType('video/mp4; codecs="hvc1.1.6.L93.B0"') !== '';
|
||||
} catch { return false; }
|
||||
@@ -2022,36 +2013,6 @@ document.addEventListener('play', () => {
|
||||
preload(m, { quiet: 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
|
||||
@@ -2835,14 +2796,6 @@ const Player = {
|
||||
if (this.master.seeking) return;
|
||||
const drift = this.secondary.currentTime - this.master.currentTime;
|
||||
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) {
|
||||
// Just snapped: ease in instead of snapping again.
|
||||
const base = parseFloat(els.speed.value) || 1;
|
||||
@@ -3032,38 +2985,6 @@ function showSpinner(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() {
|
||||
const qs = current.qualities || [];
|
||||
if (!qs.length) return null;
|
||||
@@ -3072,13 +2993,9 @@ function chooseQuality() {
|
||||
const match = qs.find((q) => q.label === pref);
|
||||
if (match) return match;
|
||||
}
|
||||
// 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 {}
|
||||
// auto: best quality at or below 720p, else the lowest available.
|
||||
const sorted = [...qs].sort((a, b) => b.height - a.height);
|
||||
return sorted.find((q) => q.height <= cap) || sorted[sorted.length - 1];
|
||||
return sorted.find((q) => q.height <= 720) || sorted[sorted.length - 1];
|
||||
}
|
||||
|
||||
function buildQualityMenu() {
|
||||
|
||||
@@ -2267,13 +2267,6 @@ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); }
|
||||
.player-pane:not(.empty) .player-stage {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
|
||||
@@ -1599,10 +1599,6 @@ app.post('/api/media/:id/redownload', async (c) => {
|
||||
// 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.
|
||||
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, {
|
||||
media, priority: HIGH, clearHints: id => { prepareSkips.delete(id); streamCache.delete(id); },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user