Add save-to-device export, server export route and an On other devices P2P list
This commit is contained in:
@@ -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