aboutsummaryrefslogtreecommitdiffstats
path: root/live
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-19 11:15:11 -0700
committerAdam Soltys <[email protected]>2026-10-19 11:15:11 -0700
commit5bbc130102de0a3c0eff6727241d0f8029ab6572 (patch)
tree37b14de70f498aa0173bbffa6dc5ddf515c34c90 /live
parent926fe1f1958396deac929013d88847f6703665b6 (diff)
downloadcoinosv3-server-5bbc130102de0a3c0eff6727241d0f8029ab6572.tar.gz
coinosv3-server-5bbc130102de0a3c0eff6727241d0f8029ab6572.zip
live: a streamer can delete their own recording (POST /live/<id>/delete, NIP-98), ending the stream if it's on. Its files go, and stream.json stays as 'deleted' so the address answers 410 until the trim takes it. A stream someone reported is taken aside instead (.removed/, kept for reporting), so deleting can't destroy what was reported. Removed and deleted streams no longer count as live (toward MAX_STREAMS, or as exempt from the trim).
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_013ZDf1cyCQLkfjftYhymZAg
Diffstat (limited to 'live')
-rw-r--r--live/server.js42
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,