Show peer availability with stale markers and add Sharing settings

This commit is contained in:
Claude
2026-09-30 07:45:13 +00:00
parent 780624347f
commit 61d0274896
8 changed files with 188 additions and 1 deletions

View File

@@ -2434,10 +2434,42 @@ async function reportBroken(video) {
}
}
// P2P availability under the title (docs/p2p-architecture.md flow 5).
// Cached 60 s per video so the frequent updateNowPlayingActions() calls
// don't hammer the server.
const availCache = new Map(); // videoId -> { at, payload }
async function renderAvailability(id) {
const el = $('npAvail');
if (!el) return;
const off = data.settings.p2pShare === false && data.settings.p2pReceive === false;
if (!WEB || !window.P2PCore || off || navigator.onLine === false || String(id).startsWith('edit_')) {
el.classList.add('hidden');
return;
}
let hit = availCache.get(id);
if (!hit || Date.now() - hit.at > 60_000) {
hit = { at: Date.now(), payload: null };
availCache.set(id, hit);
try {
const r = await fetch(`/api/p2p/holders?v=${encodeURIComponent(id)}`);
hit.payload = r.ok ? await r.json() : null;
} catch { /* offline / disabled — no line */ }
if (availCache.size > 200) availCache.delete(availCache.keys().next().value);
}
if (!current || !current.meta || current.meta.id !== id) return; // moved on meanwhile
const f = window.P2PCore.formatAvailability(hit.payload, Date.now());
if (!f) { el.classList.add('hidden'); return; }
el.textContent = f.text;
el.title = f.title;
el.classList.toggle('stale', f.stale);
el.classList.remove('hidden');
}
// Reflect cache state on the now-playing Save button.
function updateNowPlayingActions() {
if (!current || !current.meta) return;
const id = current.meta.id;
renderAvailability(id);
// Custom (edited) videos are already a saved-offline cut of a source video;
// re-saving or re-editing them makes no sense, so hide those actions.
const isCustom = !!current.meta.custom;
@@ -7453,6 +7485,28 @@ async function renderSettings() {
</label>
</div>
<div class="set-group">
<div class="set-group-title">Sharing (peer-to-peer)</div>
<label class="set-row">
<span>
Share my saved videos
<small>Other devices can download videos you saved when YouTube and the server can't provide them. Files are verified by their hash. On by default.</small>
</span>
<input id="setP2pShare" type="checkbox" ${data.settings.p2pShare !== false ? 'checked' : ''} />
</label>
<label class="set-row">
<span>
Get videos from other devices
<small>When a video is gone from YouTube and the server, download a verified copy from a device that has it. On by default.</small>
</span>
<input id="setP2pReceive" type="checkbox" ${data.settings.p2pReceive !== false ? 'checked' : ''} />
</label>
<div class="set-row">
<span>This device<small id="p2pDeviceInfo">…</small></span>
<span id="p2pHoldingCount" class="set-stat">…</span>
</div>
</div>
<div class="set-group">
<div class="set-group-title">Offline cache</div>
<label class="set-row">
@@ -7621,6 +7675,27 @@ async function renderSettings() {
persist();
if (e.target.checked) data.playlists.forEach(preloadPlaylist);
});
$('setP2pShare').addEventListener('change', (e) => {
data.settings.p2pShare = e.target.checked;
persist();
if (window.P2PClient) window.P2PClient.sync(); // share off → withdraws every holding
});
$('setP2pReceive').addEventListener('change', (e) => {
data.settings.p2pReceive = e.target.checked;
persist();
if (window.P2PClient) window.P2PClient.sync();
});
(async () => {
const info = $('p2pDeviceInfo');
const cnt = $('p2pHoldingCount');
if (!info || !cnt) return;
const P = window.P2PClient;
const d = P && P.device();
info.textContent = !d ? 'Not registered yet'
: P.isConnected() ? `Online · peer ${P.peer()}` : 'Registered · not connected';
const files = window.DeviceDB ? await window.DeviceDB.listFiles() : [];
cnt.textContent = `${files.filter((f) => f.state === 'verified').length} verified / ${files.length} saved`;
})();
// ---- Cache management ----
$('clearCacheBtn').addEventListener('click', () => {

View File

@@ -221,6 +221,7 @@
<div class="np-text">
<div class="np-title" id="npTitle"></div>
<div class="np-channel" id="npChannel"></div>
<div class="np-avail hidden" id="npAvail" aria-live="polite"></div>
</div>
<div class="np-actions">
<button id="queueBtn" class="np-btn" title="Add to the temporary queue">+ Queue</button>
@@ -564,6 +565,7 @@
<script src="sha256.js"></script>
<script src="device-db.js"></script>
<script src="p2p-client.js"></script>
<script src="p2p-core.js"></script>
<script src="async-guard.js"></script>
<script src="sw-update.js"></script>
<script src="app.js"></script>

55
frontend/p2p-core.js Normal file
View File

@@ -0,0 +1,55 @@
/* ============================================================================
* p2p-core.js — pure helpers for peer-to-peer UI (window.P2PCore / node)
*
* formatAvailability(payload, now) turns GET /api/p2p/holders into the line
* under the now-playing title, e.g.
* "📡 On 3 devices · 1 online now · last checked 2d ago"
* Holders are persistent (docs/p2p-architecture.md): they are never hidden
* for age; when every holder is older than the server's staleDays the line
* says so and is marked stale.
* ========================================================================== */
(function (root) {
'use strict';
function ago(ms, now) {
const s = Math.max(0, Math.round((now - ms) / 1000));
if (s < 90) return 'just now';
const m = Math.round(s / 60);
if (m < 90) return m + 'm ago';
const h = Math.round(m / 60);
if (h < 48) return h + 'h ago';
return Math.round(h / 24) + 'd ago';
}
function formatAvailability(payload, now) {
if (!payload || !payload.ok || !Array.isArray(payload.cids)) return null;
const holders = [];
for (const c of payload.cids) for (const h of c.holders || []) holders.push(h);
if (!holders.length) return null;
const online = holders.filter((h) => h.online).length;
const fresh = holders.filter((h) => !h.stale).length;
const newest = Math.max(...holders.map((h) => Number(h.lastVerifiedAt) || 0));
const n = holders.length;
const allStale = fresh === 0;
const text = `📡 On ${n} device${n === 1 ? '' : 's'} · ${online ? online + ' online now' : 'none online'}`
+ ` · last checked ${ago(newest, now)}` + (allStale ? ' (not checked recently)' : '');
const title = holders.map((h) =>
`${h.peer} · ${h.online ? 'online' : 'offline'} · checked ${ago(Number(h.lastVerifiedAt) || 0, now)}`
+ `${h.stale ? ' (stale)' : ''}${h.trust === 'challenged' ? ' · spot-checked' : ''}`).join('\n');
return { text, title, stale: allStale, online, holders: n };
}
// Holders worth trying for a download: online first, then freshest check.
function pickPeers(payload, cid) {
if (!payload || !Array.isArray(payload.cids)) return [];
const c = payload.cids.find((x) => x.cid === cid) || payload.cids[0];
if (!c) return [];
return (c.holders || []).filter((h) => h.online)
.sort((a, b) => (b.trust === 'challenged') - (a.trust === 'challenged') || b.lastVerifiedAt - a.lastVerifiedAt)
.map((h) => ({ peer: h.peer, cid: c.cid, size: c.size }));
}
const P2PCore = { ago, formatAvailability, pickPeers };
if (typeof module !== 'undefined' && module.exports) module.exports = P2PCore;
else root.P2PCore = P2PCore;
})(typeof globalThis !== 'undefined' ? globalThis : this);

46
frontend/p2p-core.test.js Normal file
View File

@@ -0,0 +1,46 @@
'use strict';
const { test } = require('node:test');
const assert = require('node:assert');
const C = require('./p2p-core');
const NOW = Date.UTC(2026, 8, 29, 12);
const H = 3600_000;
const payload = (holders, extra = {}) => ({ ok: true, staleDays: 7, cids: [{ cid: 'a'.repeat(64), size: 10, holders, ...extra }] });
test('ago buckets', () => {
assert.strictEqual(C.ago(NOW - 30_000, NOW), 'just now');
assert.strictEqual(C.ago(NOW - 20 * 60_000, NOW), '20m ago');
assert.strictEqual(C.ago(NOW - 5 * H, NOW), '5h ago');
assert.strictEqual(C.ago(NOW - 72 * H, NOW), '3d ago');
});
test('no holders → nothing to show', () => {
assert.strictEqual(C.formatAvailability(payload([]), NOW), null);
assert.strictEqual(C.formatAvailability(null, NOW), null);
});
test('mixed holders: counts, newest check, not stale', () => {
const f = C.formatAvailability(payload([
{ peer: 'p1', online: true, lastVerifiedAt: NOW - 30 * 24 * H, stale: true, trust: 'reported' },
{ peer: 'p2', online: false, lastVerifiedAt: NOW - 48 * H, stale: false, trust: 'challenged' },
]), NOW);
assert.strictEqual(f.text, '📡 On 2 devices · 1 online now · last checked 2d ago');
assert.strictEqual(f.stale, false);
assert.match(f.title, /p1 · online · checked 30d ago \(stale\)/);
assert.match(f.title, /p2 · offline · checked 2d ago · spot-checked/);
});
test('every holder stale → still listed, flagged', () => {
const f = C.formatAvailability(payload([{ peer: 'p1', online: false, lastVerifiedAt: NOW - 20 * 24 * H, stale: true }]), NOW);
assert.strictEqual(f.text, '📡 On 1 device · none online · last checked 20d ago (not checked recently)');
assert.strictEqual(f.stale, true);
});
test('pickPeers: online only, spot-checked first', () => {
const p = payload([
{ peer: 'off', online: false, lastVerifiedAt: NOW, trust: 'challenged' },
{ peer: 'on1', online: true, lastVerifiedAt: NOW, trust: 'reported' },
{ peer: 'on2', online: true, lastVerifiedAt: NOW - H, trust: 'challenged' },
]);
assert.deepStrictEqual(C.pickPeers(p).map((x) => x.peer), ['on2', 'on1']);
});

View File

@@ -735,6 +735,8 @@ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); }
}
.np-text { min-width: 0; }
.np-actions { display: flex; gap: 8px; flex-shrink: 0; }
.np-avail { font-size: 12px; color: var(--text-dim); margin-top: 4px; cursor: default; }
.np-avail.stale { opacity: 0.6; font-style: italic; }
.np-btn {
background: var(--bg-3);
border: 1px solid var(--line);

View File

@@ -68,6 +68,7 @@ const SHELL = [
'/device-db.js',
'/hash-worker.js',
'/p2p-client.js',
'/p2p-core.js',
'/app.js',
'/manifest.webmanifest',
'/icons/icon-192.png',

View File

@@ -22,7 +22,7 @@ green, app boots with no JS errors, P2P on by default, offline boot works).
| 012 | 012-device-file-registry-288d55 | Add the on-device IndexedDB file registry and hash saves while downloading | done | Add the on-device IndexedDB file registry and hash saves while downloading | browser harness |
| 013 | 013-device-identity-and-holdings-3ba493 | Register devices and report verified holdings to the server | done | Register devices and report verified holdings to the server | persistent holders, no TTL |
| 014 | 014-p2p-presence-hub-ceced8 | Add the /ws/p2p presence and signalling hub and the holders endpoint | done | Add the /ws/p2p presence and signalling hub and the holders endpoint | stale flag, never hidden |
| 015 | 015-availability-ui-and-settings-3b9397 | Show peer availability with stale markers and add Sharing settings | in-progress | | |
| 015 | 015-availability-ui-and-settings-3b9397 | Show peer availability with stale markers and add Sharing settings | done | Show peer availability with stale markers and add Sharing settings | |
| 016 | 016-intake-and-server-verification-cfe031 | Let a device hand a file to the server for hashing and validation | queued | | needs ffmpeg for tests |
| 017 | 017-peer-transfer-1faaa7 | Download a verified file from another device over WebRTC | queued | | STUN only |
| 018 | 018-server-rehydrate-from-peer-4fb8bd | Restore an evicted server copy from an online holder | queued | | |

View File

@@ -182,3 +182,9 @@ Output ONLY the following, no other prose:
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-core.js` and its test byte-identical to the tested versions; all frontend files pass `node --check`; 61 frontend tests pass; app.js references 6; index.html and sw.js each reference `p2p-core.js` once. Real Chromium load against the patched server: no JS errors, both Sharing checkboxes checked by default, device row shows `Online · peer ...`. Only `P2PTransfer` is absent (plan 017).
- Executor Findings (verbatim): All 8 steps of plan 015 applied successfully. git apply of the p2p-core.js patch succeeded with no conflicts. All syntax checks pass, unit tests pass (61/61), and all grep verification counts match or exceed expectations. The p2p-core.js and p2p-core.test.js files were created by the git patch as expected (visible in git status as untracked but not modified by this executor). P2P Sharing settings UI group inserted before Offline cache group. Availability line HTML, CSS, JavaScript, and service worker cache entries all in place.