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

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