1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
|
// 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;
|