diff --git a/CLAUDE.md b/CLAUDE.md index f2e6916..79f34f4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,6 +38,14 @@ JSON shapes mirror the Tauri Rust bridge exactly — don't change one side alone - Videos over `transcode.maxSeconds` are never re-encoded (lane would be tied up for hours). - Deferred ideas (Cloudflare Worker fetch, Android device download): `docs/deferred-ideas.md`. +## Save to device + "On other devices" +- `exportToDevice(video)` (app.js, helpers in `frontend/export.js`): puts the file in Photos / Files / Downloads. + Source order: copy saved in OPFS → server copy (`GET /api/export/:id?name=`, attachment + Range; direct + download on desktop, via OPFS + share sheet on phones) → online P2P holder → normal save via server. + Buttons appear only once the file is ready: `navigator.share` needs a fresh tap. +- `GET /api/p2p/available` + view `{type:'p2p'}` = the "📡 On other devices" list (sidebar, web only): + verified files with ≥1 sharing holder, online first, stale flagged. Rows play, export, or go to a playlist. + ## Server media cache (`server/media-cache.js`) - Every played (`/api/streams`, LOW priority, ≤ `MEDIA_AUTO_MAX_SECONDS`, default 3 h) or saved (`/api/download`, HIGH, ≤ 3 h) video gets ONE copy: `$MEDIA_DIR/..mp4` diff --git a/frontend/app.js b/frontend/app.js index e009cdc..462f82e 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -7179,6 +7179,7 @@ function renderList() { setListPaneHidden(view.type === 'search' && !searchResults.length && !searchAttempted); if (view.type === 'settings') { renderSettings(); return; } if (view.type === 'saved') { renderSaved(); return; } + if (view.type === 'p2p') { renderP2pList(); return; } if (view.type === 'downloads') { renderDownloads(); return; } if (view.type === 'stats') { renderStats(); return; } if (view.type === 'notes') { renderNotesView(); return; } @@ -7391,6 +7392,180 @@ function addBrowseCta(empty, label) { // ============================================================================ // Saved videos page — every offline-cached file with sizes + totals // ============================================================================ + +// ============================================================================ +// Save to this device (gallery / Files / Downloads) — docs: export.js +// ============================================================================ +// Sources, best first: the copy already saved in this browser; the server's copy +// (direct download on desktop, via the saved copy on phones so the share sheet +// can put it in Photos); another device that holds it; finally a normal save +// through the server. Buttons appear only when the file is ready so each one +// runs inside its own tap (the share sheet needs a fresh user gesture). +async function exportToDevice(video) { + if (!video || !video.id || !window.DeviceExport) return; + const id = video.id; + const title = video.title || videoTitleById(id) || id; + const box = document.createElement('div'); + box.className = 'export-box'; + box.innerHTML = '
'; + const status = box.querySelector('.export-status'); + const bar = box.querySelector('.export-bar'); + const fill = bar.firstChild; + const actions = box.querySelector('.export-actions'); + const say = (t) => { status.textContent = t; }; + const pct = (p) => { bar.classList.remove('busy'); fill.style.width = Math.max(0, Math.min(100, p)) + '%'; }; + const busy = () => { bar.classList.add('busy'); fill.style.width = ''; }; + const button = (label, primary, fn) => { + const b = document.createElement('button'); + b.className = 'btn' + (primary ? ' primary' : ''); + b.textContent = label; + b.onclick = fn; + actions.appendChild(b); + return b; + }; + let open = true; + showModal('Save to this device', box, [{ label: 'Close', onClick: () => { open = false; closeModal(); } }]); + + const DE = window.DeviceExport; + const done = (msg) => { open = false; closeModal(); toast(msg, { duration: 4500 }); }; + const ready = (file) => { + const ext = (String(file.name || '').match(/\.([a-z0-9]{2,5})$/i) || [, 'mp4'])[1]; + const name = DE.fileName(title, ext, id); + pct(100); + say(`${name} · ${fmtBytes(file.size)}`); + actions.innerHTML = ''; + if (DE.canShareFile(file)) { + button('Save to Photos / Files', true, async () => { + try { if (await DE.share(file, name, title) === 'shared') done('Shared ✓ — choose “Save Video” or “Save to Files”'); } + catch (e) { say('Could not open the share sheet: ' + (e && e.message ? e.message : e)); } + }); + } + if (DE.canPickLocation()) { + button('Choose where to save…', !DE.canShareFile(file), async () => { + try { if (await DE.pick(file, name) === 'saved') done('Saved to your device ✓'); } + catch (e) { say('Could not save: ' + (e && e.message ? e.message : e)); } + }); + } + button('Download file', !DE.canShareFile(file) && !DE.canPickLocation(), () => { + DE.download(file, name); + done('Download started — see your Downloads folder'); + }); + }; + const localFile = async () => (cachedIds.has(id) && window.OPFS ? window.OPFS.getFileObject(id) : null); + + try { + say('Looking for the video…'); + busy(); + let file = await localFile(); + if (file) return ready(file); + + // Is it on the server? (a 1-byte ranged probe; nothing is downloaded) + let serverUrl = ''; + if (WEB && !video.custom) { + const url = `/api/export/${encodeURIComponent(id)}?name=${encodeURIComponent(title)}${hevcCapable() ? '&hevc=1' : ''}`; + const r = await fetch(url, { headers: { Range: 'bytes=0-0' } }).catch(() => null); + if (r && (r.status === 206 || r.ok)) serverUrl = url; + } + const phone = DE.canShareFile(new File([], 'x.mp4', { type: 'video/mp4' })); + if (serverUrl && !phone) { + say('The server has this video. It downloads straight to your Downloads folder.'); + pct(100); + actions.innerHTML = ''; + button('Download file', true, () => { DE.download(serverUrl, DE.fileName(title, 'mp4', id)); done('Download started — see your Downloads folder'); }); + return; + } + + // Other devices, only when the server can't help. + if (!serverUrl && WEB && window.P2PTransfer && window.P2PCore && data.settings.p2pReceive !== false && !video.custom) { + let payload = null; + try { const r = await fetch(`/api/p2p/holders?v=${encodeURIComponent(id)}`); payload = r.ok ? await r.json() : null; } catch { /* offline */ } + const c = payload && Array.isArray(payload.cids) ? payload.cids[0] : null; + const peers = c ? window.P2PCore.pickPeers(payload, c.cid) : []; + if (peers.length) { + say(`Getting it from another device (${peers.length} online)…`); + pct(0); + const res = await getFromPeers(video, c, peers, (got, total) => { if (open) { pct((got / total) * 100); say(`Getting it from another device… ${Math.round((got / total) * 100)}%`); } }); + if (res.ok) { file = await localFile(); if (file) return ready(file); } + say('That device could not send it — saving through the server instead…'); + } + } + + // Normal save (server copy or YouTube via the server), then export it. + say(serverUrl ? 'Copying it to this device…' : 'Saving the video first — long videos take a while…'); + busy(); + await preload(video, { quiet: true }); + file = await localFile(); + if (!file) { say('Could not get this video. Check your connection and try again.'); pct(0); return; } + ready(file); + } catch (e) { + say('Something went wrong: ' + (e && e.message ? e.message : e)); + } +} + +// ============================================================================ +// "On other devices" — videos held by sharing devices (P2P), as a playlist +// ============================================================================ +async function renderP2pList() { + els.listTitle.textContent = '📡 On other devices'; + els.listActions.innerHTML = ''; + els.status.classList.add('hidden'); + const c = els.cards; + c.innerHTML = '
Looking for videos on other devices…
'; + let res = null; + try { const r = await fetch('/api/p2p/available?limit=100'); res = r.ok ? await r.json() : null; } catch { /* offline */ } + if (view.type !== 'p2p') return; + c.innerHTML = ''; + if (!res || !res.ok) { + c.innerHTML = '
📡

Not available

Peer-to-peer sharing is off on this server, or you are offline.

'; + return; + } + const items = res.items || []; + const list = items.map((it) => ({ + id: it.id, title: it.title || videoTitleById(it.id) || it.id, channel: it.channel || '', + duration: it.duration || 0, thumbnail: `https://i.ytimg.com/vi/${it.id}/mqdefault.jpg`, + })); + const refresh = document.createElement('button'); + refresh.textContent = '↻ Refresh'; + refresh.onclick = () => renderP2pList(); + const playAll = document.createElement('button'); + playAll.textContent = '▶ Play all'; + playAll.className = 'la-primary'; + playAll.onclick = () => { if (list.length) playFromList(list, 0, 'p2p'); }; + els.listActions.append(playAll, refresh); + if (!items.length) { + c.innerHTML = '
📡

Nothing shared yet

Videos that other devices have saved and verified show up here. Save a video and keep sharing on, and yours will appear for everyone else.

'; + return; + } + const online = items.filter((i) => i.online).length; + const sum = document.createElement('div'); + sum.className = 'saved-summary'; + sum.innerHTML = `${items.length}video${items.length === 1 ? '' : 's'} on other devices · ${online} with a device online now`; + c.appendChild(sum); + items.forEach((it, idx) => { + const v = list[idx]; + const row = document.createElement('div'); + row.className = 'card saved-card p2p-card'; + row.dataset.id = it.id; + const state = it.online ? `● ${it.online} online` : '○ none online'; + row.innerHTML = ` +
+
+
+
${state} · ${it.holders} device${it.holders === 1 ? '' : 's'} · ${fmtBytes(it.size)}${it.height ? ' · ' + it.height + 'p' : ''}${it.stale ? ' · not checked lately' : ''}
+
+ + `; + row.querySelector('.card-title').textContent = v.title; + row.addEventListener('click', (e) => { + const act = e.target.closest('[data-act]'); + if (act) { e.stopPropagation(); if (act.dataset.act === 'export') exportToDevice(v); else openCardMenu(v); return; } + playFromList(list, idx, 'p2p'); + }); + c.appendChild(row); + }); + markPlayingCard(); +} + async function renderSaved() { els.listTitle.textContent = 'Saved videos'; els.listActions.innerHTML = ''; @@ -7480,9 +7655,11 @@ async function renderSaved() {
${isCustom ? '✂ edited · ' : ''}${fmtBytes(it.size)}
+ `; row.querySelector('.card-title').textContent = v.title || it.id; row.addEventListener('click', (e) => { + if (e.target.closest('[data-act="export"]')) { e.stopPropagation(); exportToDevice(v); return; } if (e.target.closest('.card-del')) return; playFromList([v], 0, 'saved'); }); @@ -8400,6 +8577,12 @@ function openCardMenu(video) { const isCustom = !!video.custom; + const exportBtn = document.createElement('button'); + exportBtn.textContent = '⤓ Save to this device'; + exportBtn.title = 'Put the video file in your gallery, Files app or Downloads folder'; + exportBtn.onclick = () => { closeModal(); exportToDevice(video); }; + body.appendChild(exportBtn); + // Quick: save / remove offline. For a custom (edited) video there's no // source to re-download, so "remove" deletes the edit entirely. const saveBtn = document.createElement('button'); @@ -9164,6 +9347,7 @@ document.querySelectorAll('.chip').forEach((c) => { await preload(current.meta); } }); + if ($('exportBtn')) $('exportBtn').addEventListener('click', () => { if (current && current.meta) exportToDevice(current.meta); }); els.addPlaylistBtn.addEventListener('click', () => { if (current && current.meta) openCardMenu(current.meta); }); @@ -9676,6 +9860,13 @@ function renderSmartSidebar() { item.addEventListener('click', () => { view = { type: 'smart', smartType: sp.id }; render(); }); list.appendChild(item); }); + if (WEB && data.settings.p2pReceive !== false) { + const item = document.createElement('div'); + item.className = 'playlist-item smart-item' + (view.type === 'p2p' ? ' active' : ''); + item.innerHTML = '📡 On other devices'; + item.addEventListener('click', () => { view = { type: 'p2p' }; render(); }); + list.appendChild(item); + } } function getSmartList(smartType) { switch (smartType) { diff --git a/frontend/export.js b/frontend/export.js new file mode 100644 index 0000000..d917ce1 --- /dev/null +++ b/frontend/export.js @@ -0,0 +1,75 @@ +/* Put a video file on the device itself (Photos / Files / Downloads). + * Pure helpers + the three ways a browser can do it; the app decides which + * source (saved copy, server copy, other devices) the bytes come from. + * DeviceExport.fileName(title, ext) → safe "Title.ext" + * DeviceExport.canShareFile(file) → Web Share with files works here + * DeviceExport.canPickLocation() → "Save as…" dialog exists (desktop Chromium) + * DeviceExport.share(file, name, title) → share sheet ("Save Video" → Photos / Files) + * DeviceExport.pick(file, name) → stream into a user-chosen file + * DeviceExport.download(urlOrFile, name) → plain browser download + */ +(function (root) { + 'use strict'; + const BAD = /[\u0000-\u001f\\/:*?"<>|]+/g; + + function fileName(title, ext, fallback) { + const base = String(title || '').replace(BAD, ' ').replace(/\s+/g, ' ').trim().slice(0, 120) + || String(fallback || 'video'); + const e = String(ext || 'mp4').replace(/^\./, '').toLowerCase().replace(/[^a-z0-9]/g, '') || 'mp4'; + return `${base}.${e}`; + } + const mimeFor = (name) => (/\.(m4a|mp3|aac|opus|ogg|flac|wav)$/i.test(name) ? 'audio/mp4' : /\.webm$/i.test(name) ? 'video/webm' : 'video/mp4'); + const typed = (file, name) => (file.type ? file : new File([file], name, { type: mimeFor(name) })); + + function canShareFile(file) { + try { + if (!navigator.canShare || !navigator.share || !file) return false; + return navigator.canShare({ files: [typed(file, file.name || 'video.mp4')] }); + } catch { return false; } + } + const canPickLocation = () => typeof root.showSaveFilePicker === 'function' && !/Android|iPhone|iPad/i.test(navigator.userAgent || ''); + + // Resolves 'shared' | 'cancelled'; rejects on a real failure. + async function share(file, name, title) { + // new File() over an OPFS file does not copy it — it stays disk-backed. + const f = new File([file], name, { type: file.type || mimeFor(name) }); + try { + await navigator.share({ files: [f], title: title || name }); + return 'shared'; + } catch (e) { + if (e && e.name === 'AbortError') return 'cancelled'; + throw e; + } + } + + async function pick(file, name) { + let handle; + try { + handle = await root.showSaveFilePicker({ suggestedName: name, types: [{ description: 'Video', accept: { [mimeFor(name)]: ['.' + name.split('.').pop()] } }] }); + } catch (e) { + if (e && e.name === 'AbortError') return 'cancelled'; + throw e; + } + const w = await handle.createWritable(); + await file.stream().pipeTo(w); // chunked: a multi-GB file is never held in memory + return 'saved'; + } + + // A File/Blob → object URL; a string → used as the href directly (server copy). + function download(src, name) { + const isStr = typeof src === 'string'; + const href = isStr ? src : URL.createObjectURL(src); + const a = document.createElement('a'); + a.href = href; + a.download = name; + a.rel = 'noopener'; + document.body.appendChild(a); + a.click(); + a.remove(); + if (!isStr) setTimeout(() => URL.revokeObjectURL(href), 10 * 60 * 1000); + return 'started'; + } + + root.DeviceExport = { fileName, canShareFile, canPickLocation, share, pick, download }; + if (typeof module !== 'undefined' && module.exports) module.exports = root.DeviceExport; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/frontend/export.test.js b/frontend/export.test.js new file mode 100644 index 0000000..7aeecdc --- /dev/null +++ b/frontend/export.test.js @@ -0,0 +1,10 @@ +const test = require('node:test'); +const assert = require('node:assert'); +const { fileName } = require('./export.js'); + +test('fileName strips path and reserved characters and keeps the extension', () => { + assert.strictEqual(fileName('AC/DC: Back "In" Black?', 'mp4'), 'AC DC Back In Black .mp4'.replace(' .', '.')); + assert.strictEqual(fileName(' ', '.M4A', 'abc123'), 'abc123.m4a'); + assert.strictEqual(fileName('x'.repeat(300), 'mp4').length, 124); + assert.strictEqual(fileName('Song', '../../x'), 'Song.x'); +}); diff --git a/frontend/index.html b/frontend/index.html index f706dba..4d277c3 100755 --- a/frontend/index.html +++ b/frontend/index.html @@ -236,6 +236,7 @@
+ @@ -592,6 +593,7 @@ + diff --git a/frontend/refresh.css b/frontend/refresh.css index 9d4542c..b0458af 100644 --- a/frontend/refresh.css +++ b/frontend/refresh.css @@ -370,3 +370,21 @@ border: 1px solid rgba(255, 180, 84, 0.5); } .lyr-line.flagged.mine::after { background: #ffb454; color: #1a1206; border-color: #ffb454; } + +/* ---- Save to device + On other devices ---- */ +.card-act { + background: var(--bg-3); border: 1px solid var(--line); color: var(--text-2); + width: 36px; height: 36px; border-radius: 11px; flex-shrink: 0; align-self: center; + font-size: 16px; line-height: 1; cursor: pointer; transition: all 0.16s var(--ease); +} +.card-act:hover { color: #fff; background: var(--accent); border-color: var(--accent); } +.p2p-card .p2p-on { color: #7ee0a8; font-weight: 700; } +.p2p-card .p2p-off { color: var(--text-dim); } +.export-box { display: grid; gap: 14px; min-width: min(420px, 78vw); } +.export-status { font-size: 14px; line-height: 1.45; color: var(--text-2); overflow-wrap: anywhere; } +.export-bar { height: 8px; border-radius: 99px; background: var(--bg-3); overflow: hidden; } +.export-bar i { display: block; height: 100%; width: 0; border-radius: inherit; background: linear-gradient(90deg, var(--accent-deep), var(--accent-bright)); transition: width 0.25s linear; } +.export-bar.busy i { width: 40%; animation: export-slide 1.2s ease-in-out infinite; } +@keyframes export-slide { 0% { margin-left: -40%; } 100% { margin-left: 100%; } } +.export-actions { display: grid; gap: 8px; } +.export-actions .btn { height: 48px; width: 100%; } diff --git a/frontend/sw.js b/frontend/sw.js index 6fbf4aa..ac42141 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -59,6 +59,7 @@ const SHELL = [ '/service-ui.css', '/refresh.css', '/flag-ui.js', + '/export.js', '/async-guard.js', '/sw-update.js', '/fingerprint.js', diff --git a/server/p2p-db.js b/server/p2p-db.js index d329f2a..5f73d23 100644 --- a/server/p2p-db.js +++ b/server/p2p-db.js @@ -279,3 +279,19 @@ export async function recentContent(limit = 30) { }); return rowsOf(r).map((x) => ({ ...x, holders: Number(x.holders) || 0 })); } + +// One verified file per video (newest), with how many sharing devices hold it — +// the "On other devices" playlist. Holders themselves come from listHolders. +export async function listSharedContent(limit = 100) { + const r = await db.execute({ + sql: `SELECT c.cid, c.video_id, c.size, c.height, c.vcodec, c.duration, c.meta, c.verified_at, + (SELECT COUNT(*) FROM p2p_holders h JOIN p2p_devices d ON d.device_id = h.device_id + WHERE h.cid = c.cid AND h.status = 'active' AND d.share = 1) AS holders + FROM p2p_content c + WHERE c.status = 'verified' + AND c.created_at = (SELECT MAX(c2.created_at) FROM p2p_content c2 WHERE c2.video_id = c.video_id AND c2.status = 'verified') + ORDER BY c.verified_at DESC LIMIT ?`, + args: [limit], + }); + return rowsOf(r).map((x) => ({ ...x, holders: Number(x.holders) || 0 })); +} diff --git a/server/p2p-db.test.js b/server/p2p-db.test.js index 01f4c8d..83b5c2a 100644 --- a/server/p2p-db.test.js +++ b/server/p2p-db.test.js @@ -69,3 +69,17 @@ test('views per day and window sums', async () => { expect(s.content).toBe(1); expect(s.devices).toBe(1); }); + +test('listSharedContent: newest verified file per video, only with sharing holders counted', async () => { + const V = 'sharedVid01', C1 = 'c'.repeat(64), C2 = 'd'.repeat(64); + await P.createDevice({ deviceId: 'dev_00000000000000a1', secretHash: 'x', fingerprint: null, profile: null, now: T0 }); + await P.createDevice({ deviceId: 'dev_00000000000000a2', secretHash: 'x', fingerprint: null, profile: null, now: T0 }); + await P.touchDevice('dev_00000000000000a2', { now: T0, share: 0 }); + await P.upsertContent({ cid: C1, videoId: V, size: 10, duration: 5, meta: { title: 'Old' }, origin: 'server', now: T0 }); + await P.upsertContent({ cid: C2, videoId: V, size: 20, duration: 5, meta: { title: 'New' }, origin: 'intake', now: T0 + 5000 }); + await P.upsertHolder({ cid: C2, deviceId: 'dev_00000000000000a1', now: T0 }); + await P.upsertHolder({ cid: C2, deviceId: 'dev_00000000000000a2', now: T0 }); // does not share + const rows = (await P.listSharedContent(50)).filter((r) => r.video_id === V); + expect(rows.length).toBe(1); + expect(rows[0]).toMatchObject({ cid: C2, holders: 1 }); +}); diff --git a/server/server.js b/server/server.js index 1bf8802..d5caef9 100644 --- a/server/server.js +++ b/server/server.js @@ -1214,6 +1214,30 @@ app.get('/api/media/:id', async (c) => { g ? 'public, max-age=31536000, immutable' : 'no-cache'); }); +// GET /api/export/:id?name=[&hevc=1] — the server's copy as a file +// download (Content-Disposition: attachment), Range-aware so a big file can +// resume. Serves a cached YouTube copy or one of the server's own uploads, so a +// device can put it in its gallery / Files app without going through OPFS. +app.get('/api/export/:id', async (c) => { + const id = c.req.param('id'); + let path = null, mime = 'video/mp4', ext = 'mp4'; + if (isUpload(id)) { + const u = await notesDb.getUpload(id); + if (u) { path = uploads.filePath(u); mime = u.mime; ext = u.ext; } + } else { + const row = await notesDb.getMedia(id); + // An HEVC copy only goes to a client that said it can play one. + if (row && row.status === 'ready' && (row.vcodec !== 'hevc' || c.req.query('hevc') === '1')) path = await media.filePath(id, null, 'mp4'); + } + if (!path) return c.json({ ok: false, error: 'not cached on the server' }, 404); + media.touch?.(id); + const base = String(c.req.query('name') || id).replace(/\.[a-z0-9]{1,5}$/i, '') + .replace(/[\u0000-\u001f\\/:*?"<>|]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 120) || id; + const res = rangeFileResponse(c, path, mime, 'no-store'); + res.headers.set('Content-Disposition', `attachment; filename="${base.replace(/[^\x20-\x7e]/g, '_').replace(/"/g, '')}.${ext}"; filename*=UTF-8''${encodeURIComponent(base)}.${ext}`); + return res; +}); + // GET /api/media/:id/status // Where the bytes for an id live: the validated YouTube copy in the media // cache, or one of the server's own uploads. @@ -1992,6 +2016,32 @@ app.get('/api/p2p/holders', p2p.gate, async (c) => { return c.json(payload, 200, { 'Cache-Control': 'no-store' }); }); +// GET /api/p2p/available — the "On other devices" playlist: every verified file +// that at least one sharing device holds, online ones first. Holders are never +// dropped for age; `stale` only flags an old check. +app.get('/api/p2p/available', p2p.gate, async (c) => { + const limit = Math.min(200, Math.max(1, Number(c.req.query('limit')) || 100)); + const rows = await p2pDb.listSharedContent(limit * 2); + const out = []; + for (const r of rows) { + if (!r.holders) continue; + const hs = (await p2pDb.listHolders(r.cid, 50)).filter((h) => Number(h.share) === 1); + const online = hs.filter((h) => p2pHub.isOnline(h.device_id)).length; + const newest = Math.max(0, ...hs.map((h) => Number(h.last_verified_at) || 0)); + let meta = {}; + try { meta = JSON.parse(r.meta || '{}'); } catch { /* no meta */ } + out.push({ + id: r.video_id, cid: r.cid, title: meta.title || '', channel: meta.channel || '', + duration: Number(r.duration) || 0, size: Number(r.size) || 0, height: Number(r.height) || 0, + holders: hs.length, online, lastVerifiedAt: newest, + stale: Date.now() - newest > P2P.staleDays * 86400_000, + serverHas: !!(await fileForCid(r.cid)), + }); + } + out.sort((a, b) => (b.online - a.online) || (b.lastVerifiedAt - a.lastVerifiedAt)); + return c.json({ ok: true, staleDays: P2P.staleDays, items: out.slice(0, limit) }, 200, { 'Cache-Control': 'no-store' }); +}); + // Device → server intake: the server hashes, validates (and scans when // P2P_MALWARE_SCAN=1) before a cid is admitted. docs/p2p-architecture.md flow 7. const intake = registerIntakeRoutes(app, {