Files
ytplayer/plans/queue/012-device-file-registry-288d55.md

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
009-server-content-hash-186e7f
011-browser-sha256-e1793d
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.diff patches frontend/opfs-worker.js (importScripts /sha256.js, hash in the write loop, compare with x-content-sha256, post { type:'done', ext, sha256, expectedSha, size }) and frontend/opfs.js (downloadVideo resolves { ok:true, sha256, expectedSha, size }).
  • frontend/app.js:157-198 async 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.js async 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.html after plan 011: … stats-core.js, sha256.js, async-guard.js, sw-update.js, app.js.
  • frontend/sw.js SHELL after plan 011 contains '/sha256.js',.

Steps

  1. Create frontend/device-db.js — copy VERBATIM from the Appendix.
  2. From the repo root: git apply plans/patches/012-opfs-hash.diff (STOP and report on failure).
  3. frontend/index.html — add <script src="device-db.js"></script> directly after the sha256.js line.
  4. frontend/sw.js SHELL — add '/device-db.js', directly after '/sha256.js',.
  5. frontend/app.js opfsDownload — the line if (w.ok) return { ok: true, cached: true }; appears TWICE in app.js; change ONLY the first one (inside async function opfsDownload, ~line 180), NOT the one inside opfsDownloadEdited (edited cuts are never shared). Change it to:
        if (w.ok) return { ok: true, cached: true, sha256: w.sha256 || null, expectedSha: w.expectedSha || null, size: w.size || 0 };
    
    (The main-thread fallback below keeps returning { ok: true, cached: true } — no hash.)
  6. frontend/app.js — directly ABOVE async 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(() => {});
    }
    
  7. frontend/app.js preload success branch — after cacheMutations++; add recordDeviceFile(id, res);
  8. frontend/app.js opfsDelete — after the try { await window.OPFS.deleteVideo(videoId); } … line add:
      if (window.DeviceDB) { await window.DeviceDB.deleteFile(videoId); if (window.P2PClient) window.P2PClient.changed(); }
    
    opfsClear — after its try { 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:

  1. git diff (unified) of all changes.
  2. Raw output of the Verification commands.
  3. 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),
  };
}());