Download a verified file from another device over WebRTC

This commit is contained in:
Claude
2026-09-30 07:54:34 +00:00
parent 458aff757a
commit c8837befed
8 changed files with 350 additions and 2 deletions

View File

@@ -1,179 +0,0 @@
---
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/<id>.p2p.part` while
hashing, and renames to `<id>.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 ` <script src="p2p-transfer.js"></script>` 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.