9.5 KiB
9.5 KiB
id, title, created, depends_on, est_files
| id | title | created | depends_on | est_files | ||
|---|---|---|---|---|---|---|
| 012-device-file-registry-288d55 | Add the on-device IndexedDB file registry and hash saves while downloading | 2026-09-29 |
|
6 |
012 — On-device file registry + hash while saving
Objective
Implements flow 2 of docs/p2p-architecture.md. After this plan:
frontend/device-db.js(window.DeviceDB) keeps one IndexedDB record per saved video:{ videoId, cid, size, savedAt, lastCheckedAt, state }.- The OPFS download worker hashes bytes as it writes them. If the server sent
X-Content-SHA256(plan 009) and the hash differs, the save FAILS and the partial file is deleted ("integrity check failed"). preload()records the file (state: 'verified'when the hashes matched,'unverified'when the server sent none,'unhashed'for the main-thread fallback); deleting / clearing saved videos removes the records.
Pre-tested in Chromium with the harness in plans/harness/ (good hash → saved and
verified; wrong hash → rejected, no file left; no header → saved with a local hash).
Context the executor must NOT rediscover
plans/patches/012-opfs-hash.diffpatchesfrontend/opfs-worker.js(importScripts/sha256.js, hash in the write loop, compare withx-content-sha256, post{ type:'done', ext, sha256, expectedSha, size }) andfrontend/opfs.js(downloadVideoresolves{ ok:true, sha256, expectedSha, size }).frontend/app.js:157-198async function opfsDownload(videoId, { mux = false } = {})— worker path:if (typeof window.OPFS.downloadVideo === 'function' && typeof Worker !== 'undefined') { const w = await window.OPFS.downloadVideo(videoId, url); if (w.ok) return { ok: true, cached: true }; workerError = w.error || null; }frontend/app.js:258-268:async function opfsDelete(videoId) { if (!window.OPFS || !window.OPFS.isSupported()) return { ok: true }; try { await window.OPFS.deleteVideo(videoId); } catch { /* ignore */ } return { ok: true }; } async function opfsClear() { if (!window.OPFS || !window.OPFS.isSupported()) return { ok: true }; try { await window.OPFS.clearAll(); } catch { /* ignore */ } return { ok: true }; }frontend/app.jsasync function preload(video, …)(~line 1237), success branch:const res = await API.cacheDownload(id, { mux }); if (res && res.ok && res.cached) { cachedIds.add(id); cacheMutations++; warmThumb(thumbUrlFor(id, video));- Script order in
frontend/index.htmlafter plan 011:… stats-core.js, sha256.js, async-guard.js, sw-update.js, app.js. frontend/sw.jsSHELLafter plan 011 contains'/sha256.js',.
Steps
- Create
frontend/device-db.js— copy VERBATIM from the Appendix. - From the repo root:
git apply plans/patches/012-opfs-hash.diff(STOP and report on failure). frontend/index.html— add<script src="device-db.js"></script>directly after thesha256.jsline.frontend/sw.jsSHELL— add'/device-db.js',directly after'/sha256.js',.frontend/app.jsopfsDownload— the lineif (w.ok) return { ok: true, cached: true };appears TWICE in app.js; change ONLY the first one (insideasync function opfsDownload, ~line 180), NOT the one insideopfsDownloadEdited(edited cuts are never shared). Change it to:(The main-thread fallback below keeps returningif (w.ok) return { ok: true, cached: true, sha256: w.sha256 || null, expectedSha: w.expectedSha || null, size: w.size || 0 };{ ok: true, cached: true }— no hash.)frontend/app.js— directly ABOVEasync function preload(video, …)add:// This device's record of what it holds and each file's content id // (docs/p2p-architecture.md flow 2). The P2P client reports these. function recordDeviceFile(id, res) { if (!WEB || !window.DeviceDB) return; const cid = res && window.Sha256 && window.Sha256.isHex(res.sha256) ? res.sha256 : null; const state = cid ? (res.expectedSha === cid ? 'verified' : 'unverified') : 'unhashed'; const now = Date.now(); window.DeviceDB.putFile({ videoId: id, cid, size: (res && res.size) || 0, savedAt: now, lastCheckedAt: now, state }) .then(() => { if (window.P2PClient) window.P2PClient.changed(); }) .catch(() => {}); }frontend/app.jspreloadsuccess branch — aftercacheMutations++;addrecordDeviceFile(id, res);frontend/app.jsopfsDelete— after thetry { await window.OPFS.deleteVideo(videoId); } …line add:if (window.DeviceDB) { await window.DeviceDB.deleteFile(videoId); if (window.P2PClient) window.P2PClient.changed(); }opfsClear— after itstry { await window.OPFS.clearAll(); } …line add:if (window.DeviceDB) { await window.DeviceDB.clear(); if (window.P2PClient) window.P2PClient.changed(); }
Out of scope / do NOT touch
opfsDownloadEdited(edited cuts are device-only, never shared).- No server calls here (plan 013 reports holdings). Do not hash old files here (plan 013).
- Do not migrate
_ytpdata/ playlists to IndexedDB.
Verification
cd /home/user/ytplayer && node --check frontend/app.js frontend/opfs.js frontend/opfs-worker.js frontend/device-db.js && echo SYNTAX_OK
node --test frontend/*.test.js 2>&1 | grep -E "^# (pass|fail)"
grep -c "device-db.js" frontend/index.html frontend/sw.js
# Browser check (Chromium). Skip with a note in Findings if no Chromium/playwright is available.
cd plans/harness && (npm ls playwright >/dev/null 2>&1 || npm i --no-save playwright >/dev/null 2>&1)
bun device-db-server.js >/tmp/ytp012.log 2>&1 & SRV=$!; sleep 2; timeout 60 node device-db-check.mjs; kill $SRV; true
Expected: SYNTAX_OK; # fail 0; grep 1 each; the browser check prints JSON containing
"shaOk":true,"goodExpected":true, "bad":{"ok":false,"error":"integrity check failed (content hash mismatch)"},
"list":["good","nohash"], "rec":"verified", "after":0.
Report format (executor: follow exactly)
Output ONLY the following, no other prose:
git diff(unified) of all changes.- Raw output of the Verification commands.
Findings:— max 10 lines.
Do not commit. Do not push. Do not touch files outside the Steps.
Appendix — frontend/device-db.js
/* ============================================================================
* device-db.js — this device's own file registry (IndexedDB "ytp-device")
*
* One record per saved video, next to the bytes in OPFS:
* { videoId, cid, size, savedAt, lastCheckedAt, state }
* cid SHA-256 of the stored file (P2P content id) or null
* state 'verified' hash computed on save and equal to the server's
* 'unverified' hash computed, but the server sent none to compare
* 'unhashed' saved before hashing existed / main-thread fallback
* The P2P client (p2p-client.js) reads this to report holdings; OPFS stays the
* source of truth for what is playable (a record without a file is ignored).
* Every call resolves (null / [] on failure) — private windows can block IDB.
* See docs/p2p-architecture.md.
* ========================================================================== */
(function () {
'use strict';
const DB_NAME = 'ytp-device';
const VERSION = 1;
let _open = null;
function open() {
if (!_open) {
_open = new Promise((resolve, reject) => {
const r = indexedDB.open(DB_NAME, VERSION);
r.onupgradeneeded = () => {
const d = r.result;
if (!d.objectStoreNames.contains('files')) {
const s = d.createObjectStore('files', { keyPath: 'videoId' });
s.createIndex('cid', 'cid', { unique: false });
}
};
r.onsuccess = () => resolve(r.result);
r.onerror = () => reject(r.error);
r.onblocked = () => reject(new Error('device-db blocked'));
});
_open.catch(() => { _open = null; });
}
return _open;
}
const done = (req) => new Promise((resolve, reject) => {
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
async function store(mode) {
const d = await open();
return d.transaction('files', mode).objectStore('files');
}
async function safe(fn, fallback) {
try { return await fn(); } catch { return fallback; }
}
window.DeviceDB = {
isSupported: () => typeof indexedDB !== 'undefined',
putFile: (rec) => safe(async () => {
if (!rec || !rec.videoId) return null;
await done((await store('readwrite')).put({
videoId: String(rec.videoId),
cid: rec.cid || null,
size: Number(rec.size) || 0,
savedAt: Number(rec.savedAt) || Date.now(),
lastCheckedAt: Number(rec.lastCheckedAt) || Date.now(),
state: rec.state || 'unhashed',
}));
return true;
}, null),
getFile: (videoId) => safe(async () => (await done((await store('readonly')).get(String(videoId)))) || null, null),
getByCid: (cid) => safe(async () => (await done((await store('readonly')).index('cid').get(String(cid)))) || null, null),
listFiles: () => safe(async () => (await done((await store('readonly')).getAll())) || [], []),
deleteFile: (videoId) => safe(async () => { await done((await store('readwrite')).delete(String(videoId))); return true; }, null),
clear: () => safe(async () => { await done((await store('readwrite')).clear()); return true; }, null),
};
}());