Add save-to-device export, server export route and an On other devices P2P list
This commit is contained in:
@@ -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 }));
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user