--- id: 017-peer-transfer-1faaa7 title: Download a verified file from another device over WebRTC created: 2026-09-29 depends_on: [016-intake-and-server-verification-cfe031] est_files: 6 --- # 017 — Peer download over WebRTC ## Objective Implements flow 6 of `docs/p2p-architecture.md`. After this plan: - `window.P2PTransfer` serves this device's saved files to other devices (only while `settings.p2pShare !== false`, one upload at a time) and downloads from them over a WebRTC data channel, signalled through `/ws/p2p` (plan 014). STUN only. - The receiver writes through `p2p-recv-worker.js` into `videos/.p2p.part` while hashing, and renames to `.mp4` ONLY when the SHA-256 equals the cid. A wrong, corrupted or short file leaves nothing behind. - When a video fails to load (YouTube and the server both failing), and `settings.p2pReceive !== false`, the player shows **📡 Get it from a device (N online)** next to **↻ Retry**. On success the video is saved offline, registered as `verified` (so this device becomes a holder at its next report) and plays. Pre-tested in Chromium with two browser contexts and a real hub (`plans/harness/transfer-*.js`): 5 MiB file delivered and verified in ~2.5 s; unknown cid → `peer declined: not here`; holder with a corrupted same-size file → `content hash mismatch` and no file kept. ## Context the executor must NOT rediscover - `plans/patches/017-p2p-transfer-new.diff` creates `frontend/p2p-transfer.js` and `frontend/p2p-recv-worker.js`. - Script order in `frontend/index.html` after plan 015: `… sha256.js, device-db.js, p2p-client.js, p2p-core.js, async-guard.js, sw-update.js, app.js`. - `frontend/sw.js` `SHELL` has `'/p2p-core.js',` (plan 015). - `frontend/app.js`: - `Player.loadVideo` catch block (~line 1735): ```js } catch (err) { showSpinner(false); toast('⚠ ' + err.message); // Show retry button in the player pane const retry = els.playerPane.querySelector('.retry-btn'); if (retry) retry.remove(); const btn = document.createElement('button'); btn.className = 'retry-btn'; btn.textContent = '↻ Retry'; btn.addEventListener('click', () => { btn.remove(); Player.loadVideo(videoObj, { preferStream, resume, reveal, nocache }); }); els.playerPane.appendChild(btn); } ``` - globals/helpers: `downloading` (Set, ~line 372), `cachedIds`, `cacheMutations`, `markCardCacheState(id, state)` (~1351), `updateDownloadBadge()` (~1313), `renderSidebar()`, `recordDeviceFile(id, res)` (plan 012), `toast()`. - `boot()` has (plan 013): ```js if (WEB && window.P2PClient) { window.P2PClient.start({ getSettings: () => data.settings, getProfile: () => (data.profile && data.profile.name) || '' }); } ``` - `window.P2PCore.pickPeers(payload, cid)` → `[{ peer, cid, size }]` online holders (plan 015). ## Steps 1. From the repo root: `git apply plans/patches/017-p2p-transfer-new.diff` (STOP on failure). 2. `frontend/index.html` — add ` ` directly after the `p2p-core.js` line. 3. `frontend/sw.js` `SHELL` — add `'/p2p-transfer.js',` and `'/p2p-recv-worker.js',` directly after `'/p2p-core.js',`. 4. `frontend/styles.css` — append at the end of the file: ```css /* P2P: "Get it from a device" next to Retry (plan 017) */ .peer-btn { margin-left: 8px; } ``` 5. `frontend/app.js` `boot()` — inside the `if (WEB && window.P2PClient) { … }` block, after the `start(...)` call add: ```js if (window.P2PTransfer) window.P2PTransfer.start({ canShare: () => data.settings.p2pShare !== false }); ``` 6. `frontend/app.js` — directly ABOVE `function updateNowPlayingActions() {` (next to plan 015's `renderAvailability`) add: ```js // Fetch a verified copy from another device (docs/p2p-architecture.md flow 6). // Download-then-play: on success the file is saved offline like any save. async function getFromPeers(videoObj, c, peers, onProgress) { const id = videoObj.id; downloading.add(id); markCardCacheState(id, 'downloading'); updateDownloadBadge(); try { const res = await window.P2PTransfer.download({ videoId: id, cid: c.cid, size: c.size, peers, onProgress }); if (res.ok) { cachedIds.add(id); cacheMutations++; recordDeviceFile(id, { sha256: res.sha256, expectedSha: c.cid, size: res.size }); toast(`Saved “${videoObj.title || id}” from another device ✓`); } return res; } finally { downloading.delete(id); markCardCacheState(id, cachedIds.has(id) ? 'cached' : 'none'); updateDownloadBadge(); renderSidebar(); } } // Offered when a video won't load: only if some device holding it is online now. async function offerPeerDownload(videoObj) { if (!WEB || !window.P2PTransfer || !window.P2PCore || data.settings.p2pReceive === false) return; const id = videoObj && videoObj.id; if (!id || videoObj.custom || cachedIds.has(id)) return; let payload = null; try { const r = await fetch(`/api/p2p/holders?v=${encodeURIComponent(id)}`); payload = r.ok ? await r.json() : null; } catch { return; } const c = payload && Array.isArray(payload.cids) ? payload.cids[0] : null; const peers = c ? window.P2PCore.pickPeers(payload, c.cid) : []; if (!peers.length || !els.playerPane.querySelector('.retry-btn')) return; // nobody online / user moved on const old = els.playerPane.querySelector('.peer-btn'); if (old) old.remove(); const btn = document.createElement('button'); btn.className = 'retry-btn peer-btn'; btn.textContent = `📡 Get it from a device (${peers.length} online)`; btn.addEventListener('click', async () => { btn.disabled = true; const res = await getFromPeers(videoObj, c, peers, (got, total) => { btn.textContent = `📡 ${Math.min(99, Math.round((got / total) * 100))}%…`; }); if (res.ok) { els.playerPane.querySelectorAll('.retry-btn').forEach((b) => b.remove()); Player.loadVideo(videoObj); } else { btn.disabled = false; btn.textContent = '📡 Try again'; toast('⚠ ' + res.error); } }); els.playerPane.appendChild(btn); } ``` 7. `frontend/app.js` `Player.loadVideo` catch block — directly after `els.playerPane.appendChild(btn);` add `offerPeerDownload(videoObj);`. Also, in the Retry button's click handler, change `btn.remove();` to `els.playerPane.querySelectorAll('.retry-btn').forEach((b) => b.remove());` so the peer button goes away with it. ## Out of scope / do NOT touch - No TURN server, no progressive (streaming) playback from peers, no partial seeding. - Don't change `p2p-client.js` or the server. ## Verification ```bash cd /home/user/ytplayer && node --check frontend/app.js frontend/p2p-transfer.js frontend/p2p-recv-worker.js && echo FRONT_OK node --test frontend/*.test.js 2>&1 | grep -E "^# (pass|fail)" grep -c "p2p-transfer.js\|p2p-recv-worker.js" frontend/index.html frontend/sw.js cd server && bun install >/dev/null 2>&1 bun ../plans/harness/transfer-server.js >/tmp/ytp017.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 120 node transfer-check.mjs 2>&1 | grep -v "status of 500" kill $SRV; true ``` Expected: `FRONT_OK`; `# fail 0`; index.html `1`, sw.js `2`; browser JSON containing `"good":{"ok":true`, `"list":[["gotAAAAAAAA",5243657]]`, `"bad":{"ok":false,"error":"peer declined: not here"}`, `"corrupt":{"r":{"ok":false,"error":"content hash mismatch"},"names":["gotAAAAAAAA.mp4"]}`. ## 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. ## Execution log - Executor: in-session Agent (haiku). Attempts: 1. Fix rounds: 0. - Orchestrator re-ran Verification: `p2p-transfer.js` and `p2p-recv-worker.js` byte-identical to the pre-tested versions; all frontend files pass `node --check`; 61 frontend tests pass; index.html references the new script once and sw.js twice; two-browser WebRTC check reproduced the expected JSON (5 MiB delivered and hash-verified, unknown cid `peer declined: not here`, corrupted holder `content hash mismatch` with no partial file left); full app load in Chromium: no JS errors, all globals present including `P2PTransfer`, share/receive on by default, device online. No leftover processes. - Orchestrator note: the executor's `npm`/`bun install` left an untracked root `bun.lock`; deleted before commit (not part of the plan). - Executor Findings (verbatim): All syntax checks passed (FRONT_OK). All unit tests passed (61 pass, 0 fail). Script inclusions correct: index.html has 1 entry, sw.js has 2 entries. P2P transfer test executed successfully with expected JSON output containing good/bad/corrupt test cases. New functions getFromPeers and offerPeerDownload added correctly to app.js. P2P transfer and recv worker files created by git patch successfully.