Files
ytplayer/plans/done/017-peer-transfer-1faaa7.md

9.2 KiB

id, title, created, depends_on, est_files
id title created depends_on est_files
017-peer-transfer-1faaa7 Download a verified file from another device over WebRTC 2026-09-29
016-intake-and-server-verification-cfe031
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):
          } 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):
        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:
    /* 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:
        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:
    // 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

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.