// Live streams (NIP-53). A stream is a kind 30311 event: its title, cover, // where to watch (`streaming`, almost always an HLS playlist), whether it's // on (`status`), how many watch, and who's in it (p tags with a role). Its // chat is kind 1311, addressed to the stream with an `a` tag; zaps carry // that `a` too, which is how zap.stream and the rest count them. export const LIVE_KIND = 30311; export const LIVE_CHAT_KIND = 1311; // Where streams are announced: zap.stream, Shosho and the rest publish to // the big public relays (a 2026-10-18 survey found every live one on damus, // nos.lol and ditto, most on primal; relay.zap.stream held none). export const LIVE_RELAYS = ['wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net', 'wss://relay.ditto.pub']; // A stream still marked live that hasn't been refreshed in an hour is over: // plenty of publishers never say `ended` (NIP-53 allows reading it so). export const LIVE_STALE_S = 3600; // https, or a stream on this machine (a test's, a streamer's own preview) const WATCHABLE = /^(https:\/\/\S+|http:\/\/(localhost|127\.0\.0\.1)(:\d+)?\/\S*)$/; const tag = (ev, k) => ((ev && ev.tags) || []).find((x) => x[0] === k)?.[1] || ''; export const liveAddr = (ev) => LIVE_KIND + ':' + ev.pubkey + ':' + tag(ev, 'd'); export const liveTitle = (ev) => tag(ev, 'title').trim(); export const liveSummary = (ev) => tag(ev, 'summary').trim(); export const liveImage = (ev) => { const u = tag(ev, 'image').trim(); return /^https:\/\/\S+$/.test(u) ? u : ''; }; export const liveViewers = (ev) => { const n = parseInt(tag(ev, 'current_participants'), 10); return n >= 0 ? n : null; }; export const liveStarts = (ev) => +tag(ev, 'starts') || ev.created_at; // The person streaming: the host named in its p tags (zap.stream signs the // event with its own key and names the streamer as Host), else its author. export function liveHost(ev) { const p = (ev.tags || []).find((x) => x[0] === 'p' && /^[0-9a-f]{64}$/.test(x[1] || '') && /^host$/i.test(x[3] || '')); return p ? p[1] : ev.pubkey; } // Where to watch: an HLS playlist first (what browsers can play), else any https address. export function liveStream(ev) { const urls = (ev.tags || []).filter((x) => x[0] === 'streaming' && WATCHABLE.test(x[1] || '')).map((x) => x[1]); return urls.find((u) => /\.m3u8(\?|#|$)/i.test(u)) || urls[0] || ''; } export function liveRecording(ev) { const u = tag(ev, 'recording'); return WATCHABLE.test(u) ? u : ''; } export function liveStatus(ev, now = Date.now() / 1000) { const s = tag(ev, 'status').toLowerCase(); if (s === 'live') return now - ev.created_at > LIVE_STALE_S ? 'ended' : 'live'; return s === 'planned' ? 'planned' : 'ended'; } export const isLiveNow = (ev, now) => liveStatus(ev, now) === 'live' && !!liveStream(ev); // The relays the stream names for its chat, then the ones it's announced on. export function liveRelays(ev) { const named = (ev.tags || []).filter((x) => x[0] === 'relays').flatMap((x) => x.slice(1)).filter((r) => /^wss:\/\/\S+$/.test(r)); return [...new Set([...named.slice(0, 6), ...LIVE_RELAYS])]; } // Newest copy of each stream (it's replaceable: the address is the stream). export function newestByAddr(evs) { const out = new Map(); for (const ev of evs) { if (!ev || ev.kind !== LIVE_KIND) continue; const a = liveAddr(ev), cur = out.get(a); if (!cur || cur.created_at < ev.created_at) out.set(a, ev); } return out; } // The zap on a chat line: a 9735 receipt's amount from its bolt11, its // sender and words from the zap request inside it; coinos's own Ark receipt // (9737) says them in tags. `decode` is the bolt11 decoder (millisats). export function zapLine(ev, decode) { if (ev.kind === 9737) { return { pk: tag(ev, 'P') || ev.pubkey, sats: +tag(ev, 'amount') || 0, text: ev.content || '' }; } let req = null; try { req = JSON.parse(tag(ev, 'description')); } catch {} let sats = 0; try { sats = Math.floor(Number(decode(tag(ev, 'bolt11'))?.amountMsat || 0) / 1000); } catch {} if (!sats && req) sats = Math.floor((+((req.tags || []).find((x) => x[0] === 'amount') || [])[1] || 0) / 1000); return { pk: (req && /^[0-9a-f]{64}$/.test(req.pubkey || '') && req.pubkey) || tag(ev, 'P') || '', sats, text: (req && req.content) || '' }; } // Zaps aimed at a stream carry its address: the zap request (kind 9734) and // coinos's Ark receipt look the event they're zapping up here. const zapAddrs = new Map(); export const setZapAddr = (eventId, addr) => { if (eventId && addr) zapAddrs.set(eventId, addr); }; export const zapAddrOf = (eventId) => (eventId && zapAddrs.get(eventId)) || null;