Merge remote-tracking branch 'github/claude/friendly-dirac-hz5pje' into resume-downloads

This commit is contained in:
Jonathan Sykes
2026-10-03 00:28:45 +08:00
10 changed files with 189 additions and 9 deletions

View File

@@ -36,7 +36,7 @@ JSON shapes mirror the Tauri Rust bridge exactly — don't change one side alone
sends the `File` itself); cover images go to `PUT /api/admin/uploads/:id/art`. The multipart sends the `File` itself); cover images go to `PUT /api/admin/uploads/:id/art`. The multipart
`POST /api/admin/uploads` parses the whole body in memory — scripts with small files only. `POST /api/admin/uploads` parses the whole body in memory — scripts with small files only.
- Videos over `transcode.maxSeconds` are never re-encoded (lane would be tied up for hours). - Videos over `transcode.maxSeconds` are never re-encoded (lane would be tied up for hours).
- Deferred ideas (Cloudflare Worker fetch, Android device download): `docs/deferred-ideas.md`. - Deferred ideas (Cloudflare Worker fetch, Android device download, ffmpeg.wasm client editing): `docs/deferred-ideas.md`.
## Save to device + "On other devices" ## Save to device + "On other devices"
- `exportToDevice(video)` (app.js, helpers in `frontend/export.js`): puts the file in Photos / Files / Downloads. - `exportToDevice(video)` (app.js, helpers in `frontend/export.js`): puts the file in Photos / Files / Downloads.
@@ -76,6 +76,15 @@ JSON shapes mirror the Tauri Rust bridge exactly — don't change one side alone
`ignoreSearch` against the plain-URL precache. A new build = new URLs, so old ones simply get evicted. `ignoreSearch` against the plain-URL precache. A new build = new URLs, so old ones simply get evicted.
`/fonts` and `/icons` are cached 30 days. `/fonts` and `/icons` are cached 30 days.
## SHA-256 in WebAssembly (`frontend/sha256.js`)
- `Sha256.create()` runs its block function as WebAssembly (`frontend/wasm/sha256.c` → `scripts/build-sha256-wasm.sh`
→ generated `frontend/sha256-wasm.js`, base64, ~2 KB) and falls back to pure JS if WebAssembly is missing, the CSP
refuses it, or the load-time self-test ("abc") fails. ~150 MB/s vs ~65 MB/s in JS (Node/Chromium; phones are slower
but the ratio holds). It is synchronous on purpose (callers aren't async) — fine because the module is under 4 KB.
- Needs `'wasm-unsafe-eval'` in index.html's CSP `script-src`. Workers pull the blob in with `importScripts('/sha256-wasm.js')`
from inside sha256.js; the file is in the SW `SHELL` list. After editing the C file, re-run the build script and commit both.
`Sha256.engine()` says which one is active; `create({js:true})` forces JS (tests).
## Server media cache (`server/media-cache.js`) ## Server media cache (`server/media-cache.js`)
- Every played (`/api/streams`, LOW priority, ≤ `MEDIA_AUTO_MAX_SECONDS`, default 3 h) or saved - Every played (`/api/streams`, LOW priority, ≤ `MEDIA_AUTO_MAX_SECONDS`, default 3 h) or saved
(`/api/download`, HIGH, ≤ 3 h) video gets ONE copy: `$MEDIA_DIR/<id>.<gen>.mp4` (`/api/download`, HIGH, ≤ 3 h) video gets ONE copy: `$MEDIA_DIR/<id>.<gen>.mp4`

View File

@@ -32,3 +32,13 @@ yt-dlp (or an equivalent extractor) on the phone's own IP, checks the server
cache first, then uploads the finished file in the background through the cache first, then uploads the finished file in the background through the
device intake (`POST /api/p2p/intake`, `server/p2p-intake.js`). The server's device intake (`POST /api/p2p/intake`, `server/p2p-intake.js`). The server's
yt-dlp stays as the fallback when the device fails. yt-dlp stays as the fallback when the device fails.
## Client-side video editing with ffmpeg.wasm (deferred 2026-10-02)
Skipped on purpose. ffmpeg.wasm is about 25–30 MB, and fast (multi-threaded)
use needs cross-origin isolation (COOP/COEP). That would break the YouTube
thumbnails and avatars unless every image host sends CORP headers, and we do not
control `i.ytimg.com` / `ggpht.com`. The server already trims video (edit & download
runs ffmpeg there), so the browser gains nothing it needs. Revisit only if offline
editing becomes a requirement; then load it lazily from a worker and measure the
isolation fallout first.

View File

@@ -3,7 +3,7 @@
* thread, read in 4 MiB slices (never the whole video in memory). * thread, read in 4 MiB slices (never the whole video in memory).
* *
* In: { name } file name under OPFS videos/ (e.g. "abc.mp4") * In: { name } file name under OPFS videos/ (e.g. "abc.mp4")
* Out: { ok: true, sha256, size } | { ok: false, error } ^ * Out: { ok: true, sha256, size, engine } | { ok: false, error }
* ========================================================================== */ * ========================================================================== */
'use strict'; 'use strict';
importScripts('/sha256.js'); importScripts('/sha256.js');
@@ -19,7 +19,7 @@ self.onmessage = async (e) => {
for (let pos = 0; pos < file.size; pos += STEP) { for (let pos = 0; pos < file.size; pos += STEP) {
h.update(new Uint8Array(await file.slice(pos, pos + STEP).arrayBuffer())); h.update(new Uint8Array(await file.slice(pos, pos + STEP).arrayBuffer()));
} }
self.postMessage({ ok: true, sha256: h.hex(), size: file.size }); self.postMessage({ ok: true, sha256: h.hex(), size: file.size, engine: self.Sha256.engine() });
} catch (err) { } catch (err) {
self.postMessage({ ok: false, error: err && err.message ? err.message : String(err) }); self.postMessage({ ok: false, error: err && err.message ? err.message : String(err) });
} }

View File

@@ -13,7 +13,7 @@
<meta name="apple-mobile-web-app-title" content="YT Player" /> <meta name="apple-mobile-web-app-title" content="YT Player" />
<meta <meta
http-equiv="Content-Security-Policy" http-equiv="Content-Security-Policy"
content="default-src 'self'; img-src 'self' https: data: asset: http://asset.localhost blob:; media-src 'self' https: blob: asset: http://asset.localhost; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; script-src 'self'; connect-src 'self' https: ipc: http://ipc.localhost blob:; worker-src 'self';" content="default-src 'self'; img-src 'self' https: data: asset: http://asset.localhost blob:; media-src 'self' https: blob: asset: http://asset.localhost; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; script-src 'self' 'wasm-unsafe-eval'; connect-src 'self' https: ipc: http://ipc.localhost blob:; worker-src 'self';"
/> />
<title>YT Player</title> <title>YT Player</title>
<link rel="manifest" href="/manifest.webmanifest" /> <link rel="manifest" href="/manifest.webmanifest" />
@@ -632,6 +632,7 @@
<script src="flag-ui.js"></script> <script src="flag-ui.js"></script>
<script src="export.js"></script> <script src="export.js"></script>
<script src="stats-core.js"></script> <script src="stats-core.js"></script>
<script src="sha256-wasm.js"></script>
<script src="sha256.js"></script> <script src="sha256.js"></script>
<script src="device-db.js"></script> <script src="device-db.js"></script>
<script src="p2p-client.js"></script> <script src="p2p-client.js"></script>

7
frontend/sha256-wasm.js Normal file
View File

@@ -0,0 +1,7 @@
/* GENERATED by scripts/build-sha256-wasm.sh from frontend/wasm/sha256.c — do not edit.
* WebAssembly SHA-256 block compression, embedded as base64 so it loads with the
* page / worker that already loads sha256.js (no extra request, no MIME or SW change). */
(function (root) {
root.SHA256_WASM_B64 = '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';
if (typeof module !== 'undefined' && module.exports) module.exports = root.SHA256_WASM_B64;
})(typeof globalThis !== 'undefined' ? globalThis : this);

View File

@@ -1,6 +1,10 @@
/* ============================================================================ /* ============================================================================
* sha256.js — incremental SHA-256 (pure JS; window.Sha256 / worker / node) * sha256.js — incremental SHA-256 (pure JS; window.Sha256 / worker / node)
* *
* The block function runs as WebAssembly (wasm/sha256.c → sha256-wasm.js) when the
* browser allows it, with the pure-JS version below as the fallback — a failed
* load or a failed self-test silently keeps hashing in JS.
*
* WebCrypto's digest() needs the whole input at once, which would pull a * WebCrypto's digest() needs the whole input at once, which would pull a
* multi-hundred-MB video into memory. This hasher takes chunks as they stream * multi-hundred-MB video into memory. This hasher takes chunks as they stream
* past (downloads, peer transfers, OPFS reads) and keeps ~100 bytes of state. * past (downloads, peer transfers, OPFS reads) and keeps ~100 bytes of state.
@@ -22,7 +26,41 @@
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
]); ]);
function create() { // ---- WebAssembly engine (optional) -------------------------------------
// Memory layout shared with wasm/sha256.c: state at STATE, input blocks at DATA.
const STATE = 16384, DATA = 65536, CHUNK = 1 << 20; // 1 MiB staging window
const IV = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19];
let wasm; // undefined = not tried yet, null = unavailable, else { compress, state, data }
function loadWasm() {
if (wasm !== undefined) return wasm;
wasm = null;
try {
if (typeof WebAssembly === 'undefined') return wasm;
if (!root.SHA256_WASM_B64 && typeof importScripts === 'function') importScripts('/sha256-wasm.js');
let b64 = root.SHA256_WASM_B64;
if (!b64 && typeof require === 'function') b64 = require('./sha256-wasm.js');
if (!b64) return wasm;
const bin = typeof Buffer !== 'undefined' ? Uint8Array.from(Buffer.from(b64, 'base64'))
: Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
const memory = new WebAssembly.Memory({ initial: (DATA + CHUNK) / 65536, maximum: (DATA + CHUNK) / 65536 });
const inst = new WebAssembly.Instance(new WebAssembly.Module(bin), { env: { memory } });
const e = {
compress: inst.exports.compress,
state: new Uint32Array(memory.buffer, STATE, 8),
data: new Uint8Array(memory.buffer, DATA, CHUNK),
};
// Self-test: one padded block of "abc" must give the well-known digest.
e.state.set(IV);
e.data.fill(0, 0, 64); e.data.set([0x61, 0x62, 0x63, 0x80], 0); e.data[63] = 24;
e.compress(STATE, DATA, 1);
if (e.state[0] !== 0xba7816bf || e.state[7] !== 0xf20015ad) return wasm;
wasm = e;
} catch { wasm = null; }
return wasm;
}
function create(opts) {
const useWasm = !(opts && opts.js) && loadWasm();
const H = new Uint32Array([ const H = new Uint32Array([
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19, 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
]); ]);
@@ -58,6 +96,21 @@
H[4] = (H[4] + e) | 0; H[5] = (H[5] + f) | 0; H[6] = (H[6] + g) | 0; H[7] = (H[7] + h) | 0; H[4] = (H[4] + e) | 0; H[5] = (H[5] + f) | 0; H[6] = (H[6] + g) | 0; H[7] = (H[7] + h) | 0;
} }
// Hash n whole 64-byte blocks of buf starting at off — wasm in 1 MiB windows, else JS.
function blocks(buf, off, n) {
if (!useWasm) { for (let k = 0; k < n; k++) compress(buf, off + k * 64); return; }
const w = useWasm;
w.state.set(H);
const per = CHUNK / 64;
for (let done = 0; done < n;) {
const m = Math.min(per, n - done);
w.data.set(buf.subarray(off + done * 64, off + (done + m) * 64));
w.compress(STATE, DATA, m);
done += m;
}
H.set(w.state);
}
function update(data) { function update(data) {
if (done) throw new Error('sha256: update() after digest'); if (done) throw new Error('sha256: update() after digest');
const u8 = data instanceof Uint8Array ? data : new Uint8Array(data); const u8 = data instanceof Uint8Array ? data : new Uint8Array(data);
@@ -68,9 +121,10 @@
block.set(u8.subarray(0, take), blockLen); block.set(u8.subarray(0, take), blockLen);
blockLen += take; blockLen += take;
i = take; i = take;
if (blockLen === 64) { compress(block, 0); blockLen = 0; } if (blockLen === 64) { blocks(block, 0, 1); blockLen = 0; }
} }
for (; i + 64 <= u8.length; i += 64) compress(u8, i); const whole = Math.floor((u8.length - i) / 64);
if (whole) { blocks(u8, i, whole); i += whole * 64; }
if (i < u8.length) { block.set(u8.subarray(i), 0); blockLen = u8.length - i; } if (i < u8.length) { block.set(u8.subarray(i), 0); blockLen = u8.length - i; }
return api; return api;
} }
@@ -80,12 +134,12 @@
done = true; done = true;
const bits = total * 8; const bits = total * 8;
block[blockLen++] = 0x80; block[blockLen++] = 0x80;
if (blockLen > 56) { block.fill(0, blockLen); compress(block, 0); blockLen = 0; } if (blockLen > 56) { block.fill(0, blockLen); blocks(block, 0, 1); blockLen = 0; }
block.fill(0, blockLen, 56); block.fill(0, blockLen, 56);
const hi = Math.floor(bits / 0x100000000), lo = bits >>> 0; const hi = Math.floor(bits / 0x100000000), lo = bits >>> 0;
block[56] = hi >>> 24; block[57] = hi >>> 16; block[58] = hi >>> 8; block[59] = hi; block[56] = hi >>> 24; block[57] = hi >>> 16; block[58] = hi >>> 8; block[59] = hi;
block[60] = lo >>> 24; block[61] = lo >>> 16; block[62] = lo >>> 8; block[63] = lo; block[60] = lo >>> 24; block[61] = lo >>> 16; block[62] = lo >>> 8; block[63] = lo;
compress(block, 0); blocks(block, 0, 1);
} }
const out = new Uint8Array(32); const out = new Uint8Array(32);
for (let i = 0; i < 8; i++) { for (let i = 0; i < 8; i++) {
@@ -107,6 +161,8 @@
const Sha256 = { const Sha256 = {
create, create,
hex: (data) => create().update(data).hex(), hex: (data) => create().update(data).hex(),
// Which engine create() will use: 'wasm' or 'js'.
engine: () => (loadWasm() ? 'wasm' : 'js'),
isHex: (s) => typeof s === 'string' && /^[0-9a-f]{64}$/.test(s), isHex: (s) => typeof s === 'string' && /^[0-9a-f]{64}$/.test(s),
}; };
if (typeof module !== 'undefined' && module.exports) module.exports = Sha256; if (typeof module !== 'undefined' && module.exports) module.exports = Sha256;

View File

@@ -43,3 +43,32 @@ test('update after digest throws; isHex', () => {
assert.ok(Sha256.isHex(ref(Buffer.from('x')))); assert.ok(Sha256.isHex(ref(Buffer.from('x'))));
assert.ok(!Sha256.isHex('ABC')); assert.ok(!Sha256.isHex('ABC'));
}); });
// ---- WebAssembly engine ----
test('wasm engine is available in Node and matches the JS engine and node:crypto', () => {
const crypto = require('node:crypto');
assert.equal(Sha256.engine(), 'wasm');
const sizes = [0, 1, 55, 56, 63, 64, 65, 1000, (1 << 20) - 1, 1 << 20, (1 << 20) + 1, 3_000_001];
for (const n of sizes) {
const buf = Buffer.alloc(n).map((_, i) => (i * 7 + n) & 255);
const want = crypto.createHash('sha256').update(buf).digest('hex');
assert.equal(Sha256.create().update(buf).hex(), want, `wasm ${n}`);
assert.equal(Sha256.create({ js: true }).update(buf).hex(), want, `js ${n}`);
}
});
test('wasm engine hashes streamed odd-sized chunks like one-shot', () => {
const crypto = require('node:crypto');
const buf = Buffer.alloc(2_500_003).map((_, i) => (i * 13) & 255);
const h = Sha256.create();
for (let o = 0; o < buf.length; o += 77_777) h.update(buf.subarray(o, o + 77_777));
assert.equal(h.hex(), crypto.createHash('sha256').update(buf).digest('hex'));
});
test('falls back to the JS engine when WebAssembly is unavailable', () => {
const { execFileSync } = require('node:child_process');
const out = execFileSync(process.execPath, ['-e',
"delete globalThis.WebAssembly; const S=require('./frontend/sha256.js');" +
"console.log(S.engine(), S.hex(new TextEncoder().encode('abc')))"], { cwd: require('node:path').join(__dirname, '..') }).toString().trim();
assert.equal(out, 'js ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad');
});

View File

@@ -70,6 +70,7 @@ const SHELL = [
'/lyrics-core.js', '/lyrics-core.js',
'/stats-core.js', '/stats-core.js',
'/sha256.js', '/sha256.js',
'/sha256-wasm.js',
'/device-db.js', '/device-db.js',
'/hash-worker.js', '/hash-worker.js',
'/p2p-client.js', '/p2p-client.js',

46
frontend/wasm/sha256.c Normal file
View File

@@ -0,0 +1,46 @@
// SHA-256 block compression for frontend/sha256.js (built to WebAssembly).
// No libc, no allocation: JS owns the memory layout.
// state (8 x u32) at STATE
// input (N x 64 B) at DATA
// Build: scripts/build-sha256-wasm.sh (writes frontend/sha256-wasm.js)
typedef unsigned int u32;
typedef unsigned char u8;
static const u32 K[64] = {
0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2 };
#define ROR(x,n) (((x) >> (n)) | ((x) << (32 - (n))))
__attribute__((export_name("compress")))
void compress(u32 *state, const u8 *data, u32 blocks) {
u32 W[64];
while (blocks--) {
for (int i = 0; i < 16; i++, data += 4)
W[i] = ((u32)data[0] << 24) | ((u32)data[1] << 16) | ((u32)data[2] << 8) | data[3];
for (int i = 16; i < 64; i++) {
u32 s0 = ROR(W[i-15],7) ^ ROR(W[i-15],18) ^ (W[i-15] >> 3);
u32 s1 = ROR(W[i-2],17) ^ ROR(W[i-2],19) ^ (W[i-2] >> 10);
W[i] = W[i-16] + s0 + W[i-7] + s1;
}
u32 a=state[0],b=state[1],c=state[2],d=state[3],e=state[4],f=state[5],g=state[6],h=state[7];
// 8 rounds per iteration with the variables renamed instead of shuffled.
#define RND(a,b,c,d,e,f,g,h,i) do { \
u32 t1 = h + (ROR(e,6) ^ ROR(e,11) ^ ROR(e,25)) + (g ^ (e & (f ^ g))) + K[i] + W[i]; \
u32 t2 = (ROR(a,2) ^ ROR(a,13) ^ ROR(a,22)) + ((a & b) | (c & (a | b))); \
d += t1; h = t1 + t2; } while (0)
for (int i = 0; i < 64; i += 8) {
RND(a,b,c,d,e,f,g,h,i); RND(h,a,b,c,d,e,f,g,i+1);
RND(g,h,a,b,c,d,e,f,i+2); RND(f,g,h,a,b,c,d,e,i+3);
RND(e,f,g,h,a,b,c,d,i+4); RND(d,e,f,g,h,a,b,c,i+5);
RND(c,d,e,f,g,h,a,b,i+6); RND(b,c,d,e,f,g,h,a,i+7);
}
state[0]+=a; state[1]+=b; state[2]+=c; state[3]+=d; state[4]+=e; state[5]+=f; state[6]+=g; state[7]+=h;
}
}

21
scripts/build-sha256-wasm.sh Executable file
View File

@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# Rebuild frontend/sha256-wasm.js (the base64-embedded WebAssembly SHA-256 block
# function) from frontend/wasm/sha256.c. Needs clang with wasm32 + wasm-ld.
set -euo pipefail
cd "$(dirname "$0")/.."
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
clang --target=wasm32 -O3 -nostdlib -fno-builtin -ffreestanding \
-Wl,--no-entry -Wl,--export=compress -Wl,--import-memory -Wl,--initial-memory=65536 -Wl,--max-memory=1114112 \
-Wl,-z,stack-size=4096 -Wl,--strip-all \
frontend/wasm/sha256.c -o "$tmp/sha256.wasm"
b64=$(base64 -w0 "$tmp/sha256.wasm")
cat > frontend/sha256-wasm.js <<JS
/* GENERATED by scripts/build-sha256-wasm.sh from frontend/wasm/sha256.c — do not edit.
* WebAssembly SHA-256 block compression, embedded as base64 so it loads with the
* page / worker that already loads sha256.js (no extra request, no MIME or SW change). */
(function (root) {
root.SHA256_WASM_B64 = '$b64';
if (typeof module !== 'undefined' && module.exports) module.exports = root.SHA256_WASM_B64;
})(typeof globalThis !== 'undefined' ? globalThis : this);
JS
echo "wasm: $(wc -c < "$tmp/sha256.wasm") bytes"