diff options
Diffstat (limited to 'live/server.js')
| -rw-r--r-- | live/server.js | 42 |
1 files changed, 34 insertions, 8 deletions
diff --git a/live/server.js b/live/server.js index dfe122e..d246c58 100644 --- a/live/server.js +++ b/live/server.js @@ -132,12 +132,12 @@ function load(id) { try { const s = JSON.parse(readFileSync(metaPath(id), 'utf8')); s.viewers = new Map(); - if (s.state !== 'ended') { s.state = 'ended'; s.ended = s.ended || Date.now(); save(s); } // a restart ended it + if (s.state !== 'ended' && s.state !== 'deleted') { s.state = 'ended'; s.ended = s.ended || Date.now(); save(s); } // a restart ended it streams.set(id, s); return s; } catch { return null; } } -const live = () => [...streams.values()].filter((s) => s.state !== 'ended'); +const live = () => [...streams.values()].filter((s) => s.state === 'live' || s.state === 'waiting'); // A run's playlist as ffmpeg wrote it: [{dur, uri}] function runSegments(s, run) { @@ -247,7 +247,7 @@ function trim() { const info = dirs.map((id) => { let bytes = 0, at = 0; try { for (const f of readdirSync(`${DATA}/${id}`)) { const st = statSync(`${DATA}/${id}/${f}`); bytes += st.size; at = Math.max(at, st.mtimeMs); } } catch {} - return { id, bytes, at, live: streams.get(id) && streams.get(id).state !== 'ended' }; + return { id, bytes, at, live: ['live', 'waiting'].includes(streams.get(id)?.state) }; }).sort((a, b) => a.at - b.at); let total = info.reduce((n, x) => n + x.bytes, 0); for (const x of info) { @@ -334,7 +334,7 @@ export async function handle(req, server) { const rtc = /^\/([0-9a-f]{16})\/rtc\/(publish|watch|answer)$/.exec(path); if (rtc && req.method === 'OPTIONS') return new Response(null, { headers: { 'access-control-allow-origin': '*', 'access-control-allow-headers': 'authorization, content-type', 'access-control-allow-methods': 'POST' } }); if (rtc && req.method === 'POST') return rtcRoute(rtc[1], rtc[2], req, url, server); - const act = /^\/([0-9a-f]{16})\/(report|remove)$/.exec(path); + const act = /^\/([0-9a-f]{16})\/(report|remove|delete)$/.exec(path); if (act && req.method === 'OPTIONS') return new Response(null, { headers: { 'access-control-allow-origin': '*', 'access-control-allow-headers': 'authorization, content-type', 'access-control-allow-methods': 'POST' } }); if (act && req.method === 'POST') { const a = checkAuth(url, req, `/live/${act[1]}/${act[2]}`, 'POST'); @@ -342,13 +342,13 @@ export async function handle(req, server) { const s = load(act[1]); if (!s) return json({ error: 'no such stream' }, 404); const body = await req.json().catch(() => ({})); - return act[2] === 'report' ? report(s, a.pubkey, body, ipOf(req, server)) : remove(s, a.pubkey, body); + return act[2] === 'report' ? report(s, a.pubkey, body, ipOf(req, server)) : act[2] === 'delete' ? deleteOwn(s, a.pubkey) : remove(s, a.pubkey, body); } const m = /^\/([0-9a-f]{16})\/(live\.m3u8|index\.m3u8|stats|\d+-\d{5}\.ts)$/.exec(path); if (!m || req.method !== 'GET') return json({ error: 'not found' }, 404); const s = load(m[1]); if (!s) return json({ error: 'no such stream' }, 404); - if (s.state === 'removed') return json({ error: 'removed', state: 'removed' }, 410); + if (s.state === 'removed' || s.state === 'deleted') return json({ error: s.state, state: s.state }, 410); if (m[2] === 'stats') { // a viewer on WebRTC never asks for the playlist: it says it's watching here if (url.searchParams.has('watching') && s.state !== 'ended') s.viewers.set(ipOf(req, server), Date.now()); @@ -372,7 +372,7 @@ export async function handle(req, server) { async function rtcRoute(id, what, req, url, server) { if (!SFU) return json({ error: 'no webrtc here' }, 501); const s = load(id); - if (!s || s.state === 'removed' || s.state === 'ended') return json({ error: 'not live' }, 404); + if (!s || s.state !== 'live' && s.state !== 'waiting') return json({ error: 'not live' }, 404); const body = await req.json().catch(() => ({})); try { if (what === 'publish') { @@ -456,7 +456,7 @@ async function alertAdmins(text) { const streamLink = (s) => 'https://v3.coinos.io/' + naddrEncode({ kind: 30311, pubkey: s.pubkey, identifier: s.id }); const reportsBy = new Map(); // reporter or ip -> [times] function report(s, pubkey, body, ip) { - if (s.state === 'removed') return json({ ok: true }); + if (s.state === 'removed' || s.state === 'deleted') return json({ ok: true }); const now = Date.now(); for (const k of ['pk:' + pubkey, 'ip:' + ip]) { const list = (reportsBy.get(k) || []).filter((t) => now - t < 3600_000); @@ -491,6 +491,32 @@ function remove(s, by, body) { return json({ ok: true, removed: true, banned: !!(body && body.ban) }); } +// The streamer deletes their own recording (ending the stream if it's on). +// Its files go; stream.json stays as 'deleted' so the address says gone (410) +// until the trim takes it. A stream someone reported isn't destroyed: it's +// taken aside as an admin's takedown would (kept for reporting). +function deleteOwn(s, by) { + if (s.state === 'removed' || s.state === 'deleted') return json({ ok: true, deleted: true }); + if (by !== s.pubkey) return json({ error: 'not your stream' }, 403); + if (s.ws) { try { s.ws.close(4004, 'deleted'); } catch {} } + end(s, 'deleted by its sender'); + if (s.reports) { + s.removed = { at: Date.now(), by, self: true }; + save(s); + try { renameSync(`${DATA}/${s.id}`, `${REMOVED}/${s.id}`); } catch (e) { log('delete: move failed', e.message); } + streams.set(s.id, { id: s.id, state: 'removed', viewers: new Map() }); + log('deleted (reported, kept aside)', s.id); + return json({ ok: true, deleted: true }); + } + // after ffmpeg lets go (endRun gives it up to 8 s to finish) + const wipe = () => { try { for (const f of readdirSync(`${DATA}/${s.id}`)) if (f !== 'stream.json') rmSync(`${DATA}/${s.id}/${f}`, { force: true }); } catch {} }; + s.state = 'deleted'; s.deleted = Date.now(); + save(s); + wipe(); setTimeout(wipe, 9000); + log('deleted', s.id); + return json({ ok: true, deleted: true }); +} + export const websocket = { maxPayloadLength: 8 * 1024 * 1024, idleTimeout: 60, |
