--- id: 013-device-identity-and-holdings-3ba493 title: Register devices and report verified holdings to the server created: 2026-09-29 depends_on: [010-views-and-retention-d0c6ca, 012-device-file-registry-288d55] est_files: 11 --- # 013 — Device identity + holdings sync ## Objective Implements flow 3 of `docs/p2p-architecture.md`. After this plan, with default settings (P2P ON): - A device registers once (`POST /api/p2p/device` → `deviceId` + `secret`, kept in `localStorage.ytpDevice`; only `sha256(secret)` is stored server-side). - `P2PClient` reconciles `DeviceDB` with the files really in OPFS, hashes old/unhashed saves in a worker, reports the FULL holdings list, answers the server's range challenges, and marks accepted files `verified`. - The server keeps holder rows forever (no TTL): refreshed `last_verified_at` on every report, `removed` when a full report no longer lists them or a challenge fails. - Turning sharing off (`data.settings.p2pShare === false`) withdraws every holding. Pre-tested: server routes 6/6 (`bun:test`), and the whole client flow in Chromium via `plans/harness/p2p-client-*.js` (ghost record dropped, legacy file hashed, verified file accepted + challenge passed → `trust: challenged`, share off → 0 holders). ## Context the executor must NOT rediscover - Plans 008–012 are applied: `server/p2p-db.js`, `p2p-config.js` (`P2P`), `hash.js` (`sha256Range`), `p2p-admit.js`; `frontend/sha256.js`, `frontend/device-db.js`, and `opfs.js`/`opfs-worker.js` hash on save. `server/server.js` imports `P2P` and `* as p2pDb`. - `plans/patches/013-opfs-readrange.diff` adds `OPFS.readRange(videoId, offset, length)` to `frontend/opfs.js` (applies on top of plan 012's patch). - `server/server.js`: `const MEDIA_DIR = process.env.MEDIA_DIR || './data/media';` (~line 980). Plan 009's `const notes = registerNoteRoutes(app, {…});` (~line 1823) is a good neighbourhood for another `register…Routes(app, …)` call — add ours right after the uploads block (`const isUpload = (id) => uploads.isUploadId(id);`). - `frontend/app.js`: - `const DEFAULT_SETTINGS = {` (~line 326), first line `quality: 'auto', volume: 1, audioOnly: false, autoPreload: true,`. - end of `async function boot()` (~line 9822): ```js // Learn what's already cached, then top up any playlist videos that aren't. await refreshCachedIds(); startCacheResyncWatch(); ``` - `data.profile` is `{ name, syncedAt }` or null. - Script order in `frontend/index.html` after plan 012: `… stats-core.js, sha256.js, device-db.js, async-guard.js, sw-update.js, app.js`. ## Steps 1. `server/p2p-db.js`: a. In `initP2pSchema()`, directly after the `ALTER TABLE media_cache ADD COLUMN sha256` try/catch add: ` await db.execute('CREATE INDEX IF NOT EXISTS idx_media_sha256 ON media_cache (sha256)');` b. Append at the end of the file: ```js // The server's own ready copy whose mp4 has this content id (or null). export async function findMediaByCid(cid) { const r = await db.execute({ sql: "SELECT video_id, gen FROM media_cache WHERE sha256 = ? AND status = 'ready' LIMIT 1", args: [cid], }); return rowsOf(r)[0] || null; } ``` 2. Create `server/p2p-routes.js` — copy VERBATIM from Appendix A. 3. Create `server/p2p-routes.test.js` — copy VERBATIM from Appendix B. 4. `server/package.json` "test" script — append ` && bun test ./p2p-routes.test.js`. 5. `server/server.js`: a. imports: `import { registerP2pRoutes } from './p2p-routes.js';` and add `sha256Range` to an import from `./hash.js`: `import { sha256Range } from './hash.js';` b. after `const isUpload = (id) => uploads.isUploadId(id);` add: ```js // ============================================================================ // Peer-to-peer sharing — devices + holdings (docs/p2p-architecture.md) // ============================================================================ async function fileForCid(cid) { const row = await p2pDb.findMediaByCid(cid); if (!row) return null; const path = `${MEDIA_DIR}/${row.video_id}.${row.gen}.mp4`; const f = Bun.file(path); return (await f.exists()) ? { path, size: f.size } : null; } const p2p = registerP2pRoutes(app, { cfg: P2P, p2pDb, fileForCid, sha256Range }); ``` (`p2p` is used by plan 014.) 6. Create `frontend/hash-worker.js` — copy VERBATIM from Appendix C. 7. Create `frontend/p2p-client.js` — copy VERBATIM from Appendix D. 8. From the repo root: `git apply plans/patches/013-opfs-readrange.diff` (STOP on failure). 9. `frontend/index.html` — add ` ` directly after the `device-db.js` line. 10. `frontend/sw.js` `SHELL` — add `'/hash-worker.js',` and `'/p2p-client.js',` directly after `'/device-db.js',`. 11. `frontend/app.js` `DEFAULT_SETTINGS` — add a new line after the first line: ```js p2pShare: true, // P2P (docs/p2p-architecture.md): share my saved videos with other devices — ON by default p2pReceive: true, // fetch from other devices when YouTube and the server can't — ON by default ``` 12. `frontend/app.js` `boot()` — directly after `startCacheResyncWatch();` add: ```js // Peer-to-peer: report what this device holds (on by default; settings.p2pShare). if (WEB && window.P2PClient) { window.P2PClient.start({ getSettings: () => data.settings, getProfile: () => (data.profile && data.profile.name) || '' }); } ``` ## Out of scope / do NOT touch - Presence/websocket, holders endpoint, UI (plans 014/015). Intake of unknown files (016). - Do not change how saves download or play. ## Verification ```bash cd /home/user/ytplayer/server && bun install >/dev/null 2>&1 bun test ./p2p-routes.test.js 2>&1 | tail -4 bun run test 2>&1 | grep -E "^ *[0-9]+ (pass|fail)" bun build server.js --target=bun --outdir=/tmp/ytp-check >/dev/null && echo SERVER_OK cd .. && node --check frontend/p2p-client.js frontend/hash-worker.js frontend/opfs.js frontend/app.js && echo FRONT_OK node --test frontend/*.test.js 2>&1 | grep -E "^# (pass|fail)" # Browser check (Chromium + playwright; see plans/harness/device-db-check.mjs header). cd server bun ../plans/harness/p2p-client-server.js >/tmp/ytp013.log 2>&1 & SRV=$!; sleep 3 cd ../plans/harness && (npm ls playwright >/dev/null 2>&1 || npm i --no-save playwright >/dev/null 2>&1); timeout 90 node p2p-client-check.mjs kill $SRV; true ``` Expected: routes `6 pass`; every file `0 fail`; `SERVER_OK`; `FRONT_OK`; `# fail 0`; browser JSON `{"accepted":1,"unknown":1,"challenges":1,"recs":[["goodAAAAAAA","verified",true],["legacyAAAAA","unverified",true]],"trust":["challenged"],"afterShareOff":0,"device":true}`. ## Report format (executor: follow exactly) Output ONLY the following, no other prose: 1. `git diff` (unified) of all changes (new files may be summarised as "verbatim from appendix"). 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 A — server/p2p-routes.js ```js /* ============================================================================ * p2p-routes.js — device registration + holdings (docs/p2p-architecture.md * flows 3 and 5). Mounted by server.js; every route answers 404 when * P2P_ENABLED=0. * * GET /api/p2p/config { ok, enabled, staleDays } * POST /api/p2p/device { fingerprint?, profile? } → { ok, deviceId, secret } * POST /api/p2p/holdings (device) { items:[{cid,size,videoId}], share } → { ok, accepted, unknown, challenges } * POST /api/p2p/challenge (device) { answers:[{cid,offset,length,sha256}] } → { ok, passed, failed } * GET /api/p2p/holders?v=|cid= availability — added by plan 014 * * Device auth: header `X-Device: .`; only sha256(secret) is * stored. A holdings report is always the device's FULL list: anything it held * before and no longer lists is marked removed. Holder rows never expire by * time — last_verified_at is refreshed by each report (see the architecture doc). * ========================================================================== */ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'; const CID_RE = /^[0-9a-f]{64}$/; const DEV_RE = /^dev_[0-9a-f]{16}$/; const MAX_ITEMS = 5000; const MAX_CHALLENGES = 5; const CHALLENGE_LEN = 64 * 1024; const sha = (s) => createHash('sha256').update(String(s)).digest('hex'); const same = (a, b) => { const x = Buffer.from(String(a)), y = Buffer.from(String(b)); return x.length === y.length && timingSafeEqual(x, y); }; export const peerIdOf = (deviceId) => sha('peer:' + deviceId).slice(0, 12); export function registerP2pRoutes(app, deps) { const { cfg, p2pDb, fileForCid, sha256Range, now = () => Date.now(), log = console } = deps; const pending = new Map(); // `${deviceId}|${cid}` -> { offset, length, at } const regLog = new Map(); // ip -> [ms] const gate = async (c, next) => { if (!cfg.enabled) return c.json({ ok: false, error: 'p2p disabled' }, 404); await next(); }; async function deviceOf(c) { const m = String(c.req.header('x-device') || '').match(/^(dev_[0-9a-f]{16})\.([0-9a-f]{64})$/); if (!m) return null; const d = await p2pDb.getDevice(m[1]); if (!d || !same(d.secret_hash, sha(m[2]))) return null; return d; } const requireDevice = async (c, next) => { const d = await deviceOf(c); if (!d) return c.json({ ok: false, error: 'unknown device' }, 401); c.set('device', d); await next(); }; app.get('/api/p2p/config', (c) => c.json({ ok: true, enabled: cfg.enabled, staleDays: cfg.staleDays })); app.post('/api/p2p/device', gate, async (c) => { const ip = (c.req.header('x-forwarded-for') || '').split(',')[0].trim() || 'local'; const t = now(); const recent = (regLog.get(ip) || []).filter((x) => t - x < 3600_000); if (recent.length >= 20) return c.json({ ok: false, error: 'too many registrations' }, 429); recent.push(t); regLog.set(ip, recent); if (regLog.size > 10000) regLog.clear(); const body = await c.req.json().catch(() => ({})); const deviceId = 'dev_' + randomBytes(8).toString('hex'); const secret = randomBytes(32).toString('hex'); await p2pDb.createDevice({ deviceId, secretHash: sha(secret), fingerprint: String(body.fingerprint || '').slice(0, 128) || null, profile: String(body.profile || '').slice(0, 64) || null, now: t, }); return c.json({ ok: true, deviceId, secret }); }); app.post('/api/p2p/holdings', gate, requireDevice, async (c) => { const d = c.get('device'); const body = await c.req.json().catch(() => ({})); const t = now(); const share = body.share !== false; await p2pDb.touchDevice(d.device_id, { now: t, share, profile: String(body.profile || '').slice(0, 64) || undefined }); const raw = Array.isArray(body.items) ? body.items.slice(0, MAX_ITEMS) : []; const items = share ? raw.filter((x) => x && CID_RE.test(String(x.cid))) : []; const known = await p2pDb.knownCids(items.map((x) => x.cid)); const accepted = []; const unknown = []; for (const it of items) { if (!known.has(it.cid)) { unknown.push(it.cid); continue; } await p2pDb.upsertHolder({ cid: it.cid, deviceId: d.device_id, now: t }); accepted.push(it.cid); } await p2pDb.removeHoldersExcept({ deviceId: d.device_id, keep: accepted, now: t }); // Spot-check a few holdings against the server's own copy when it has one. const challenges = []; for (const cid of accepted) { if (challenges.length >= MAX_CHALLENGES) break; const f = await fileForCid(cid); if (!f || !(f.size > CHALLENGE_LEN)) continue; const offset = Math.floor(Math.random() * (f.size - CHALLENGE_LEN)); pending.set(d.device_id + '|' + cid, { offset, length: CHALLENGE_LEN, at: t }); challenges.push({ cid, offset, length: CHALLENGE_LEN }); } if (pending.size > 50000) pending.clear(); return c.json({ ok: true, accepted, unknown, challenges }); }); app.post('/api/p2p/challenge', gate, requireDevice, async (c) => { const d = c.get('device'); const body = await c.req.json().catch(() => ({})); const t = now(); const passed = []; const failed = []; for (const a of (Array.isArray(body.answers) ? body.answers : []).slice(0, MAX_CHALLENGES)) { const key = d.device_id + '|' + a.cid; const p = pending.get(key); if (!p || p.offset !== a.offset || p.length !== a.length || t - p.at > 10 * 60_000) continue; pending.delete(key); const f = await fileForCid(a.cid); if (!f) continue; const want = await sha256Range(f.path, p.offset, p.length); if (String(a.sha256) === want) { await p2pDb.setHolderTrust({ cid: a.cid, deviceId: d.device_id, trust: 'challenged', now: t }); passed.push(a.cid); } else { await p2pDb.removeHolder({ cid: a.cid, deviceId: d.device_id, now: t }); failed.push(a.cid); log.warn?.(`[p2p] ${d.device_id} failed challenge for ${a.cid.slice(0, 12)}`); } } return c.json({ ok: true, passed, failed }); }); return { deviceOf, peerIdOf, requireDevice, gate }; } ``` ## Appendix B — server/p2p-routes.test.js ```js // Device registration, holdings reports and range challenges (plan 013). import { test, expect, beforeAll } from 'bun:test'; import { mkdtempSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { createHash } from 'node:crypto'; import { Hono } from 'hono'; const root = mkdtempSync(join(tmpdir(), 'ytp-p2p-routes-')); process.env.DB_PATH = join(root, 'test.db'); const dbmod = await import('./db.js'); const p2pDb = await import('./p2p-db.js'); const { registerP2pRoutes } = await import('./p2p-routes.js'); const { sha256Range } = await import('./hash.js'); const file = join(root, 'copy.mp4'); const bytes = Buffer.from(Array.from({ length: 400000 }, (_, i) => (i * 13) % 256)); writeFileSync(file, bytes); const CID = createHash('sha256').update(bytes).digest('hex'); const OTHER = 'c'.repeat(64); // verified but the server has no file const UNKNOWN = 'd'.repeat(64); // not in p2p_content const quiet = { warn() {}, info() {} }; let app; let enabled = true; const req = (path, { method = 'GET', body, dev } = {}) => app.request(path, { method, headers: { 'Content-Type': 'application/json', ...(dev ? { 'X-Device': dev.deviceId + '.' + dev.secret } : {}) }, body: body ? JSON.stringify(body) : undefined, }); beforeAll(async () => { await dbmod.initDb(); await p2pDb.initP2pSchema(); await p2pDb.upsertContent({ cid: CID, videoId: 'dQw4w9WgXcQ', size: bytes.length, origin: 'server', now: 1 }); await p2pDb.upsertContent({ cid: OTHER, videoId: 'dQw4w9WgXcQ', size: 10, origin: 'server', now: 1 }); app = new Hono(); registerP2pRoutes(app, { cfg: { get enabled() { return enabled; }, staleDays: 7 }, p2pDb, fileForCid: async (cid) => (cid === CID ? { path: file, size: bytes.length } : null), sha256Range, log: quiet, }); }); async function newDevice() { const r = await req('/api/p2p/device', { method: 'POST', body: { fingerprint: 'fp1' } }); expect(r.status).toBe(200); const j = await r.json(); expect(j.deviceId).toMatch(/^dev_[0-9a-f]{16}$/); expect(j.secret).toMatch(/^[0-9a-f]{64}$/); return j; } test('config is public and says enabled + staleDays', async () => { expect(await (await req('/api/p2p/config')).json()).toEqual({ ok: true, enabled: true, staleDays: 7 }); }); test('holdings need a valid device secret', async () => { const dev = await newDevice(); expect((await req('/api/p2p/holdings', { method: 'POST', body: { items: [] } })).status).toBe(401); expect((await req('/api/p2p/holdings', { method: 'POST', body: { items: [] }, dev: { ...dev, secret: 'e'.repeat(64) } })).status).toBe(401); }); test('report accepts verified cids, returns unknown ones, and challenges the server-held file', async () => { const dev = await newDevice(); const r = await (await req('/api/p2p/holdings', { method: 'POST', dev, body: { share: true, items: [ { cid: CID, size: bytes.length, videoId: 'dQw4w9WgXcQ' }, { cid: OTHER, size: 10 }, { cid: UNKNOWN, size: 5 }, { cid: 'nothex' }, ] } })).json(); expect(r.accepted.sort()).toEqual([CID, OTHER].sort()); expect(r.unknown).toEqual([UNKNOWN]); expect(r.challenges.length).toBe(1); const ch = r.challenges[0]; expect(ch.cid).toBe(CID); const good = createHash('sha256').update(bytes.subarray(ch.offset, ch.offset + ch.length)).digest('hex'); const a = await (await req('/api/p2p/challenge', { method: 'POST', dev, body: { answers: [{ ...ch, sha256: good }] } })).json(); expect(a).toEqual({ ok: true, passed: [CID], failed: [] }); const hs = await p2pDb.listHolders(CID); expect(hs.find((h) => h.device_id === dev.deviceId).trust).toBe('challenged'); }); test('a wrong challenge answer removes the holder; a replayed answer is ignored', async () => { const dev = await newDevice(); const r = await (await req('/api/p2p/holdings', { method: 'POST', dev, body: { items: [{ cid: CID, size: bytes.length }] } })).json(); const ch = r.challenges[0]; const a = await (await req('/api/p2p/challenge', { method: 'POST', dev, body: { answers: [{ ...ch, sha256: '0'.repeat(64) }] } })).json(); expect(a.failed).toEqual([CID]); expect((await p2pDb.listHolders(CID)).some((h) => h.device_id === dev.deviceId)).toBe(false); const again = await (await req('/api/p2p/challenge', { method: 'POST', dev, body: { answers: [{ ...ch, sha256: '0'.repeat(64) }] } })).json(); expect(again).toEqual({ ok: true, passed: [], failed: [] }); }); test('a full report without an item removes it; share=false withdraws everything', async () => { const dev = await newDevice(); await req('/api/p2p/holdings', { method: 'POST', dev, body: { items: [{ cid: CID }, { cid: OTHER }] } }); expect((await p2pDb.activeHoldingsOf(dev.deviceId)).sort()).toEqual([CID, OTHER].sort()); await req('/api/p2p/holdings', { method: 'POST', dev, body: { items: [{ cid: OTHER }] } }); expect(await p2pDb.activeHoldingsOf(dev.deviceId)).toEqual([OTHER]); await req('/api/p2p/holdings', { method: 'POST', dev, body: { share: false, items: [{ cid: OTHER }] } }); expect(await p2pDb.activeHoldingsOf(dev.deviceId)).toEqual([]); expect((await p2pDb.getDevice(dev.deviceId)).share).toBe(0); }); test('P2P_ENABLED=0 → routes answer 404 (config still says disabled)', async () => { enabled = false; try { expect((await req('/api/p2p/device', { method: 'POST', body: {} })).status).toBe(404); expect((await (await req('/api/p2p/config')).json()).enabled).toBe(false); } finally { enabled = true; } }); ``` ## Appendix C — frontend/hash-worker.js ```js /* ============================================================================ * hash-worker.js — SHA-256 of a file already saved in OPFS, off the main * thread, read in 4 MiB slices (never the whole video in memory). * * In: { name } file name under OPFS videos/ (e.g. "abc.mp4") * Out: { ok: true, sha256, size } | { ok: false, error } * ========================================================================== */ 'use strict'; importScripts('/sha256.js'); self.onmessage = async (e) => { const { name } = e.data || {}; try { const root = await navigator.storage.getDirectory(); const dir = await root.getDirectoryHandle('videos'); const file = await (await dir.getFileHandle(String(name))).getFile(); const h = self.Sha256.create(); const STEP = 4 * 1024 * 1024; for (let pos = 0; pos < file.size; pos += STEP) { h.update(new Uint8Array(await file.slice(pos, pos + STEP).arrayBuffer())); } self.postMessage({ ok: true, sha256: h.hex(), size: file.size }); } catch (err) { self.postMessage({ ok: false, error: err && err.message ? err.message : String(err) }); } }; ``` ## Appendix D — frontend/p2p-client.js ```js /* ============================================================================ * p2p-client.js — this device's side of peer-to-peer sharing * (docs/p2p-architecture.md flows 2–3). window.P2PClient. * * start({ getSettings, getProfile }) called once from app.js boot() * changed() a save/delete happened → re-report soon * device() { deviceId, secret } or null * authHeaders() { 'X-Device': … } for other P2P calls * * What it does, in order, each sync: * 1. registers the device once (localStorage ytpDevice) * 2. reconciles DeviceDB with the files really in OPFS (a record without a * file is dropped; a file without a record is added as 'unhashed') * 3. hashes 'unhashed' files and files not re-checked for 30 days, one at a * time in hash-worker.js * 4. reports the FULL holdings list (empty when sharing is off), answers the * server's range challenges, and marks accepted files 'verified' * P2P is ON by default: sharing runs unless settings.p2pShare === false or the * server says P2P is disabled. * ========================================================================== */ (function () { 'use strict'; const KEY = 'ytpDevice'; const REHASH_MS = 30 * 24 * 3600_000; const RESYNC_MS = 6 * 3600_000; let hooks = { getSettings: () => ({}), getProfile: () => '' }; let serverCfg = null; let running = null; let again = false; let timer = null; let lastSync = 0; function device() { try { const d = JSON.parse(localStorage.getItem(KEY) || 'null'); return d && /^dev_[0-9a-f]{16}$/.test(d.deviceId) && /^[0-9a-f]{64}$/.test(d.secret) ? d : null; } catch { return null; } } const authHeaders = () => { const d = device(); return d ? { 'X-Device': d.deviceId + '.' + d.secret } : {}; }; async function config() { if (serverCfg) return serverCfg; try { const j = await (await fetch('/api/p2p/config')).json(); if (j && j.ok) serverCfg = j; } catch { /* offline — try again next sync */ } return serverCfg; } async function ensureDevice() { const have = device(); if (have) return have; const r = await fetch('/api/p2p/device', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fingerprint: window.getFingerprint ? window.getFingerprint() : '', profile: hooks.getProfile() || '' }), }); const j = await r.json(); if (!j || !j.ok) throw new Error((j && j.error) || 'device registration failed'); const d = { deviceId: j.deviceId, secret: j.secret }; try { localStorage.setItem(KEY, JSON.stringify(d)); } catch { /* storage blocked */ } return d; } function hashInWorker(name) { return new Promise((resolve) => { let w; try { w = new Worker('/hash-worker.js'); } catch { resolve(null); return; } w.onmessage = (e) => { w.terminate(); resolve(e.data && e.data.ok ? e.data : null); }; w.onerror = () => { w.terminate(); resolve(null); }; w.postMessage({ name }); }); } async function sha256Range(videoId, offset, length) { const bytes = window.OPFS && window.OPFS.readRange ? await window.OPFS.readRange(videoId, offset, length) : null; if (!bytes) return null; return window.Sha256.hex(bytes); } // DeviceDB ⇄ OPFS. Returns the records that describe a real file. async function reconcile() { const files = await window.OPFS.listVideos({ strict: true }); const byId = new Map(files.map((f) => [f.id, f])); const recs = await window.DeviceDB.listFiles(); const out = []; for (const r of recs) { const f = byId.get(r.videoId); if (!f) { await window.DeviceDB.deleteFile(r.videoId); continue; } if (f.size !== r.size && r.size) { r.cid = null; r.state = 'unhashed'; r.size = f.size; await window.DeviceDB.putFile(r); } r.name = f.name; out.push(r); byId.delete(r.videoId); } for (const f of byId.values()) { if (String(f.id).startsWith('edit_')) continue; // edited cuts are never shared const r = { videoId: f.id, cid: null, size: f.size, savedAt: Date.now(), lastCheckedAt: 0, state: 'unhashed' }; await window.DeviceDB.putFile(r); out.push({ ...r, name: f.name }); } return out; } async function hashPending(recs) { const now = Date.now(); for (const r of recs) { if (r.state !== 'unhashed' && now - (r.lastCheckedAt || 0) < REHASH_MS) continue; const h = await hashInWorker(r.name); if (!h) continue; const changedCid = h.sha256 !== r.cid; r.cid = h.sha256; r.size = h.size; r.lastCheckedAt = Date.now(); if (changedCid || r.state === 'unhashed') r.state = 'unverified'; await window.DeviceDB.putFile(r); } } async function report(recs, dev) { const share = hooks.getSettings().p2pShare !== false; const items = recs.filter((r) => r.cid).map((r) => ({ cid: r.cid, size: r.size, videoId: r.videoId })); const r = await fetch('/api/p2p/holdings', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Device': dev.deviceId + '.' + dev.secret }, body: JSON.stringify({ share, items: share ? items : [], profile: hooks.getProfile() || '' }), }); if (r.status === 401) { try { localStorage.removeItem(KEY); } catch { /* ignore */ } return null; } const j = await r.json(); if (!j || !j.ok) return null; const accepted = new Set(j.accepted || []); for (const rec of recs) { if (rec.cid && accepted.has(rec.cid) && rec.state !== 'verified') { rec.state = 'verified'; await window.DeviceDB.putFile(rec); } } const answers = []; for (const ch of j.challenges || []) { const rec = recs.find((x) => x.cid === ch.cid); if (!rec) continue; const hex = await sha256Range(rec.videoId, ch.offset, ch.length); if (hex) answers.push({ ...ch, sha256: hex }); } if (answers.length) { await fetch('/api/p2p/challenge', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Device': dev.deviceId + '.' + dev.secret }, body: JSON.stringify({ answers }), }).catch(() => {}); } return j; } async function syncOnce() { if (!window.OPFS || !window.OPFS.isSupported() || !window.DeviceDB || !window.Sha256) return null; if (navigator.onLine === false) return null; const cfg = await config(); if (!cfg || !cfg.enabled) return null; const dev = await ensureDevice(); const recs = await reconcile(); await hashPending(recs); const res = await report(recs, dev); lastSync = Date.now(); return res; } // One sync at a time; a request during a sync schedules exactly one more. function sync() { if (running) { again = true; return running; } running = syncOnce().catch(() => null).finally(() => { running = null; if (again) { again = false; sync(); } }); return running; } function changed() { clearTimeout(timer); timer = setTimeout(sync, 5000); } function start(h) { hooks = { ...hooks, ...(h || {}) }; const idle = window.requestIdleCallback || ((fn) => setTimeout(fn, 1)); setTimeout(() => idle(() => sync()), 8000); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible' && Date.now() - lastSync > RESYNC_MS) sync(); }); } window.P2PClient = { start, changed, sync, device, authHeaders, config }; }()); ```