Add save-to-device export, server export route and an On other devices P2P list

This commit is contained in:
Claude
2026-10-01 18:53:00 +00:00
parent 08e95814e5
commit 02b337fcbc
10 changed files with 385 additions and 0 deletions

View File

@@ -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/<id>.<gen>.mp4`

View File

@@ -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 = '<div class="export-status" role="status"></div><div class="export-bar"><i></i></div><div class="export-actions"></div>';
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 = '<div class="status">Looking for videos on other devices…</div>';
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 = '<div class="empty-state"><div class="empty-icon">📡</div><h3 class="empty-title">Not available</h3><p class="empty-desc">Peer-to-peer sharing is off on this server, or you are offline.</p></div>';
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 = '<div class="empty-state"><div class="empty-icon">📡</div><h3 class="empty-title">Nothing shared yet</h3><p class="empty-desc">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.</p></div>';
return;
}
const online = items.filter((i) => i.online).length;
const sum = document.createElement('div');
sum.className = 'saved-summary';
sum.innerHTML = `<span class="saved-total">${items.length}</span><span class="saved-sub">video${items.length === 1 ? '' : 's'} on other devices · ${online} with a device online now</span>`;
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 ? `<b class="p2p-on">● ${it.online} online</b>` : '<span class="p2p-off">○ none online</span>';
row.innerHTML = `
<div class="thumb"><img loading="lazy" src="${escapeHtml(v.thumbnail)}" alt="" onerror="this.classList.add('no-art')" /></div>
<div class="card-info">
<div class="card-title"></div>
<div class="card-channel">${state} · ${it.holders} device${it.holders === 1 ? '' : 's'} · ${fmtBytes(it.size)}${it.height ? ' · ' + it.height + 'p' : ''}${it.stale ? ' · <i>not checked lately</i>' : ''}</div>
</div>
<button class="card-act" data-act="export" title="Save to this device" aria-label="Save to this device">⤓</button>
<button class="card-act" data-act="add" title="Add to a playlist" aria-label="Add to a playlist">+</button>`;
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() {
<div class="card-title"></div>
<div class="card-channel saved-size">${isCustom ? '✂ edited · ' : ''}${fmtBytes(it.size)}</div>
</div>
<button class="card-act" data-act="export" title="Save to this device" aria-label="Save to this device">⤓</button>
<button class="card-del" title="Delete saved file">✕</button>`;
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 = '<span class="pl-name">📡 On other devices</span>';
item.addEventListener('click', () => { view = { type: 'p2p' }; render(); });
list.appendChild(item);
}
}
function getSmartList(smartType) {
switch (smartType) {

75
frontend/export.js Normal file
View File

@@ -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);

10
frontend/export.test.js Normal file
View File

@@ -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');
});

View File

@@ -236,6 +236,7 @@
<div class="np-actions">
<button id="queueBtn" class="np-btn" title="Add to the temporary queue">+ Queue</button>
<button id="saveBtn" class="np-btn" title="Save this video for offline playback">⬇ Save</button>
<button id="exportBtn" class="np-btn" title="Put the video file in your gallery, Files app or Downloads folder">⤓ To device</button>
<button id="editBtn" class="np-btn" title="Cut parts out and save a custom edited copy">✂ Edit &amp; download</button>
<button id="addPlaylistBtn" class="np-btn" title="Add to a playlist">+ Playlist</button>
<button id="shareMomentBtn" class="np-btn" title="Share a link or GIF of this exact moment">🔗 Share moment</button>
@@ -592,6 +593,7 @@
<script src="video-edit.js"></script>
<script src="lyrics-core.js"></script>
<script src="flag-ui.js"></script>
<script src="export.js"></script>
<script src="stats-core.js"></script>
<script src="sha256.js"></script>
<script src="device-db.js"></script>

View File

@@ -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%; }

View File

@@ -59,6 +59,7 @@ const SHELL = [
'/service-ui.css',
'/refresh.css',
'/flag-ui.js',
'/export.js',
'/async-guard.js',
'/sw-update.js',
'/fingerprint.js',

View File

@@ -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 }));
}

View File

@@ -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 });
});

View File

@@ -1214,6 +1214,30 @@ app.get('/api/media/:id', async (c) => {
g ? 'public, max-age=31536000, immutable' : 'no-cache');
});
// GET /api/export/:id?name=<file 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, {