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

@@ -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, {