--- a/frontend/p2p-client.js +++ b/frontend/p2p-client.js @@ -6,6 +6,9 @@ * changed() a save/delete happened → re-report soon * device() { deviceId, secret } or null * authHeaders() { 'X-Device': … } for other P2P calls + * contribute(videoId) / unknownVideos() + * hand a saved file the server doesn't + * know yet to /api/p2p/intake (plan 016) * onMessage(type, fn) / signal(to, data) / peer() * live /ws/p2p socket (plan 014): the * device is "online" while it is open @@ -33,6 +36,7 @@ let again = false; let timer = null; let lastSync = 0; + let lastUnknown = []; // cids the server did not recognise at the last report function device() { try { @@ -130,6 +134,7 @@ if (r.status === 401) { try { localStorage.removeItem(KEY); } catch { /* ignore */ } return null; } const j = await r.json(); if (!j || !j.ok) return null; + lastUnknown = Array.isArray(j.unknown) ? j.unknown : []; 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); } @@ -151,6 +156,33 @@ return j; } + // ---- intake (plan 016) --------------------------------------------------------- + // Send one saved video to the server so it can hash + validate it and add its + // cid to the catalog. Uploads the whole file: only on the user's request + // ("Verify & share") or when the server asks for a copy (plan 018). + async function contribute(videoId, { cid = null, title = '', channel = '' } = {}) { + const dev = await ensureDevice(); + const rec = await window.DeviceDB.getFile(videoId); + const file = window.OPFS.getFileObject ? await window.OPFS.getFileObject(videoId) : null; + if (!file) return { ok: false, error: 'not saved on this device' }; + const h = { 'X-Device': dev.deviceId + '.' + dev.secret }; + const t = await (await fetch('/api/p2p/intake', { + method: 'POST', headers: { ...h, 'Content-Type': 'application/json' }, + body: JSON.stringify({ videoId, cid: cid || (rec && rec.cid) || undefined, size: file.size, title, channel }), + })).json().catch(() => ({ ok: false, error: 'server unreachable' })); + if (!t.ok || t.known) { if (t.known) changed(); return t; } + const r = await (await fetch(t.url, { method: 'PUT', headers: h, body: file })) + .json().catch(() => ({ ok: false, error: 'upload failed' })); + if (r.ok) changed(); + return r; + } + + async function unknownVideos() { + if (!lastUnknown.length || !window.DeviceDB) return []; + const recs = await window.DeviceDB.listFiles(); + return recs.filter((r) => r.cid && lastUnknown.includes(r.cid)).map((r) => r.videoId); + } + // ---- presence socket (/ws/p2p) — plan 014 ------------------------------------ // Open while sharing OR receiving is on. Credentials go in the first message, // never the URL. Reconnects with backoff (5 s … 5 min). @@ -258,7 +290,7 @@ } window.P2PClient = { - start, changed, sync, device, authHeaders, config, + start, changed, sync, device, authHeaders, config, contribute, unknownVideos, onMessage, signal, peer: () => myPeer, isConnected: () => !!(ws && ws.readyState === 1 && myPeer), }; }()); --- a/frontend/opfs.js +++ b/frontend/opfs.js @@ -108,6 +108,17 @@ } }, + // The saved File itself (disk-backed, not read into memory) — used as an + // upload body by P2P intake. null when not saved. + async getFileObject(videoId) { + try { + const found = await findHandle(videoId); + return found ? await found[0].getFile() : null; + } catch { + return null; + } + }, + // Bytes [offset, offset+length) of a saved video — answers the server's // P2P range challenges (p2p-client.js). null when the file is missing. async readRange(videoId, offset, length) {