206 lines
6.4 KiB
JavaScript
206 lines
6.4 KiB
JavaScript
|
|
// SPDX-License-Identifier: Apache-2.0
|
||
|
|
//
|
||
|
|
// Browser-side audio store.
|
||
|
|
//
|
||
|
|
// Uploaded audio never lives on the server. The File handle from the picker (or
|
||
|
|
// drop) is copied into Origin Private File System — a sandboxed, per-origin
|
||
|
|
// filesystem the browser owns, which no other site can read and which the OS
|
||
|
|
// treats as browser cache, not user data. Everything downstream is driven from
|
||
|
|
// there:
|
||
|
|
//
|
||
|
|
// * playback reads a blob URL minted from OPFS, so scrubbing a 60 MB WAV
|
||
|
|
// never re-reads the original File
|
||
|
|
// * the SHA-256 that identifies a track is computed over OPFS bytes, so the
|
||
|
|
// id is stable across a reload (the File handle doesn't survive one)
|
||
|
|
// * re-analysis after a page reload re-streams from OPFS instead of asking the
|
||
|
|
// user to pick the file again
|
||
|
|
//
|
||
|
|
// The server only ever sees the bytes it needs for one request, spooled to a
|
||
|
|
// temp file and deleted before the response is written.
|
||
|
|
|
||
|
|
const OPFS_DIR = 'ujm-audio';
|
||
|
|
|
||
|
|
/** True when the browser can give us a durable private store. */
|
||
|
|
export function opfsSupported() {
|
||
|
|
return !!(navigator.storage && navigator.storage.getDirectory);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function dir() {
|
||
|
|
const root = await navigator.storage.getDirectory();
|
||
|
|
return root.getDirectoryHandle(OPFS_DIR, { create: true });
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Human-readable reason the store is unusable, or null when it's fine. */
|
||
|
|
export async function storageProblem(estimateBytes) {
|
||
|
|
if (!opfsSupported()) return 'this browser has no OPFS support';
|
||
|
|
try {
|
||
|
|
const est = await navigator.storage.estimate();
|
||
|
|
if (est.quota && estimateBytes && estimateBytes > est.quota * 0.9) {
|
||
|
|
return `need ~${mb(estimateBytes)} but only ${mb(est.quota - (est.usage || 0))} of browser storage is free`;
|
||
|
|
}
|
||
|
|
// Ask to be exempt from eviction: without this the browser may clear the
|
||
|
|
// store under pressure, and the file is the only copy we have.
|
||
|
|
if (navigator.storage.persist) await navigator.storage.persist();
|
||
|
|
} catch {
|
||
|
|
// estimate() is optional and can throw in private windows; not fatal.
|
||
|
|
}
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
function mb(bytes) {
|
||
|
|
return `${(bytes / 1024 / 1024).toFixed(0)} MB`;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Copy a File into OPFS under its content hash.
|
||
|
|
*
|
||
|
|
* Returns {fileId, size}. Uses `createWritable` in a single streamed pass so a
|
||
|
|
* large file is never held in memory in full.
|
||
|
|
*/
|
||
|
|
export async function put(file, onProgress) {
|
||
|
|
const d = await dir();
|
||
|
|
const tmpName = `.incoming-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||
|
|
const handle = await d.getFileHandle(tmpName, { create: true });
|
||
|
|
|
||
|
|
let written = 0;
|
||
|
|
const writable = await handle.createWritable();
|
||
|
|
try {
|
||
|
|
const reader = file.stream().getReader();
|
||
|
|
for (;;) {
|
||
|
|
const {done, value} = await reader.read();
|
||
|
|
if (done) break;
|
||
|
|
await writable.write(value);
|
||
|
|
written += value.byteLength;
|
||
|
|
if (onProgress) onProgress(written, file.size);
|
||
|
|
}
|
||
|
|
await writable.close();
|
||
|
|
} catch (err) {
|
||
|
|
await writable.abort().catch(() => {});
|
||
|
|
await d.removeEntry(tmpName).catch(() => {});
|
||
|
|
throw err;
|
||
|
|
}
|
||
|
|
|
||
|
|
const hash = await sha256(handle);
|
||
|
|
// Content-addressed: the same audio dropped twice costs one copy, and the id
|
||
|
|
// the server keys its analysis on is reproducible.
|
||
|
|
try {
|
||
|
|
await d.removeEntry(hash);
|
||
|
|
} catch {
|
||
|
|
// first time we've seen these bytes
|
||
|
|
}
|
||
|
|
const named = await rename(d, tmpName, hash);
|
||
|
|
const blob = await named.getFile();
|
||
|
|
return {fileId: hash, size: blob.size, sha256: hash};
|
||
|
|
}
|
||
|
|
|
||
|
|
async function rename(d, from, to) {
|
||
|
|
const fromHandle = await d.getFileHandle(from);
|
||
|
|
const src = await fromHandle.getFile();
|
||
|
|
const toHandle = await d.getFileHandle(to, {create: true});
|
||
|
|
const w = await toHandle.createWritable();
|
||
|
|
await src.stream().pipeTo(w);
|
||
|
|
await d.removeEntry(from);
|
||
|
|
return toHandle;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** SHA-256 of the stored file, read in chunks so memory stays flat. */
|
||
|
|
async function sha256(handle) {
|
||
|
|
const blob = await handle.getFile();
|
||
|
|
const digest = await crypto.subtle.digest('SHA-256', await blob.arrayBuffer());
|
||
|
|
return [...new Uint8Array(digest)]
|
||
|
|
.map((b) => b.toString(16).padStart(2, '0'))
|
||
|
|
.join('')
|
||
|
|
.slice(0, 32);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** The stored File for an id, or null when it isn't here. */
|
||
|
|
export async function get(fileId) {
|
||
|
|
if (!opfsSupported()) return null;
|
||
|
|
try {
|
||
|
|
const d = await dir();
|
||
|
|
const handle = await d.getFileHandle(fileId);
|
||
|
|
return await handle.getFile();
|
||
|
|
} catch {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function has(fileId) {
|
||
|
|
return (await get(fileId)) !== null;
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function remove(fileId) {
|
||
|
|
if (!opfsSupported()) return;
|
||
|
|
try {
|
||
|
|
const d = await dir();
|
||
|
|
await d.removeEntry(fileId);
|
||
|
|
} catch {
|
||
|
|
// already gone
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Ids currently held, newest touched last — used to prune orphans. */
|
||
|
|
export async function list() {
|
||
|
|
if (!opfsSupported()) return [];
|
||
|
|
const out = [];
|
||
|
|
const d = await dir();
|
||
|
|
for await (const [name] of d.entries()) {
|
||
|
|
if (!name.startsWith('.incoming-')) out.push(name);
|
||
|
|
}
|
||
|
|
return out;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Drop anything `.incoming-*`, i.e. an upload that died mid-write. */
|
||
|
|
export async function sweepIncomplete() {
|
||
|
|
if (!opfsSupported()) return 0;
|
||
|
|
const d = await dir();
|
||
|
|
let n = 0;
|
||
|
|
for await (const [name] of d.entries()) {
|
||
|
|
if (name.startsWith('.incoming-')) {
|
||
|
|
await d.removeEntry(name).catch(() => {});
|
||
|
|
n += 1;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return n;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Stream stored audio to an endpoint as a raw body.
|
||
|
|
*
|
||
|
|
* The body is sent as a stream (duplex: 'half') so a 200 MB file doesn't have to
|
||
|
|
* be materialised as a second in-memory copy just to be uploaded.
|
||
|
|
*/
|
||
|
|
export async function send(fileId, url, {method = 'POST', onProgress} = {}) {
|
||
|
|
const blob = await get(fileId);
|
||
|
|
if (!blob) throw new Error('audio is no longer in browser storage — re-add the file');
|
||
|
|
|
||
|
|
// Small files go as a plain body: some proxies mishandle streaming duplex
|
||
|
|
// requests, and below a few MB there is nothing to gain.
|
||
|
|
if (blob.size < 8 * 1024 * 1024) {
|
||
|
|
const buf = await blob.arrayBuffer();
|
||
|
|
if (onProgress) onProgress(blob.size, blob.size);
|
||
|
|
return fetch(url, {
|
||
|
|
method,
|
||
|
|
headers: {'Content-Type': 'application/octet-stream'},
|
||
|
|
body: buf,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
let sent = 0;
|
||
|
|
const body = blob.stream().pipeThrough(
|
||
|
|
new TransformStream({
|
||
|
|
transform(chunk, controller) {
|
||
|
|
sent += chunk.byteLength;
|
||
|
|
if (onProgress) onProgress(sent, blob.size);
|
||
|
|
controller.enqueue(chunk);
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
);
|
||
|
|
return fetch(url, {
|
||
|
|
method,
|
||
|
|
headers: {'Content-Type': 'application/octet-stream'},
|
||
|
|
body,
|
||
|
|
duplex: 'half',
|
||
|
|
});
|
||
|
|
}
|