187 lines
9.2 KiB
Markdown
187 lines
9.2 KiB
Markdown
---
|
|
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.
|
|
|
|
## 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.
|