nixamp 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/share.js CHANGED
@@ -112,6 +112,18 @@ export function reachableAddresses(host, port) {
112
112
  export function shareLink(base, key) {
113
113
  return key === null ? base : `${base}/s/${key}`;
114
114
  }
115
+ /**
116
+ * The same stream, as bytes rather than as a page.
117
+ *
118
+ * A share link is for a browser: it answers 302, leaves a cookie behind and
119
+ * redirects to the player. Anything that cannot hold a cookie -- the phone
120
+ * line, curl, ffplay -- gets a 401 from it and no audio. This carries the key
121
+ * in the query instead, which keyFrom() accepts, so a single anonymous GET is
122
+ * enough to start hearing sound.
123
+ */
124
+ export function audioLink(base, key) {
125
+ return key === null ? `${base}/api/live` : `${base}/api/live?${KEY_QUERY}=${encodeURIComponent(key)}`;
126
+ }
115
127
  /**
116
128
  * What a key is allowed to do. An unknown key is allowed nothing, which is the
117
129
  * same answer as no key at all.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "It really whips the terminal's ass. A Winamp-shaped audio player for your terminal.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -42,10 +42,13 @@
42
42
  "@profullstack/auth-system": "^0.6.0",
43
43
  "@profullstack/hqtui": "^0.3.0",
44
44
  "@profullstack/x402-gateway": "^0.4.0",
45
- "pg": "^8.23.0"
45
+ "pg": "^8.23.0",
46
+ "web-push": "^3.6.7"
46
47
  },
47
48
  "devDependencies": {
48
49
  "@types/node": "^26",
50
+ "@types/pg": "^8.23.1",
51
+ "@types/web-push": "^3.6.4",
49
52
  "typescript": "^7.0.2"
50
53
  },
51
54
  "trustedDependencies": [
package/src/directory.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { randomInt } from "node:crypto";
2
+
1
3
  /**
2
4
  * The public directory.
3
5
  *
@@ -20,20 +22,79 @@ export const DEFAULT_DIRECTORY = "https://nixamp.com";
20
22
  export interface Listing {
21
23
  /** Assigned by the directory, so a publisher cannot claim someone else's. */
22
24
  id: string;
25
+ /**
26
+ * A six-digit code for this stream, stable across the whole run.
27
+ *
28
+ * This is what somebody keys into the phone line. It has to be short enough
29
+ * to read out and survive being remembered, which the id is not.
30
+ */
31
+ code: string;
23
32
  name: string;
33
+ /**
34
+ * The account that announced it.
35
+ *
36
+ * Set from the signed-in publisher, never from the announcement body -- a
37
+ * stream that could name its own owner could name somebody else's, and
38
+ * followers would be told about a broadcast that person is not making.
39
+ */
40
+ ownerId: string;
24
41
  /** The listen link, which is what a browser opens. */
25
42
  url: string;
43
+ /**
44
+ * The same stream as bytes, for something that is not a browser.
45
+ *
46
+ * `url` is a share link: it answers 302, sets a cookie and redirects to the
47
+ * player page. That is exactly right for a person and useless to anything
48
+ * that cannot hold a cookie -- the phone line hands this address to Telnyx
49
+ * to play into a call, and Telnyx fetches it once, anonymously, and expects
50
+ * audio back. Handed the share link it gets a 401 in JSON and the caller
51
+ * hears silence after being told the stream is about to start.
52
+ *
53
+ * So a publisher announces both: the link a person opens, and the address
54
+ * that answers with audio/mpeg to a plain GET. Empty when the publisher is
55
+ * an older nixamp that only knows about `url`.
56
+ */
57
+ audio: string;
26
58
  tracks: number;
27
59
  nowPlaying: string;
28
60
  /** Set by the directory from the request, never by the publisher. */
29
61
  updatedAt: number;
62
+ /** When this stream first announced itself: the "started at" a caller hears. */
63
+ startedAt: number;
64
+ }
65
+
66
+ /**
67
+ * A stream that has stopped, kept for a while after it fell out of the list.
68
+ *
69
+ * The directory proper forgets a stream the moment it stops renewing, which is
70
+ * right for a list of what is on -- but it means there is nobody left to say
71
+ * *when* it ended, and "call back later" with no time in it is not worth
72
+ * saying. So an ended stream leaves this behind: enough to answer the phone
73
+ * truthfully, and nothing anybody could listen to.
74
+ */
75
+ export interface Ended {
76
+ id: string;
77
+ code: string;
78
+ name: string;
79
+ ownerId: string;
80
+ /** Kept so a stream returning on the same url is recognised as the same one. */
81
+ url: string;
82
+ nowPlaying: string;
83
+ startedAt: number;
84
+ /** The last heartbeat we saw, which is as close to "ended" as we can know. */
85
+ endedAt: number;
30
86
  }
31
87
 
88
+ /** How long an ended stream is still worth telling a caller about. */
89
+ export const ENDED_TTL_MS = 24 * 60 * 60 * 1000;
90
+
32
91
  /** What a publisher sends. Everything else about a listing is ours to decide. */
33
92
  export interface Announcement {
34
93
  id?: string;
35
94
  name: string;
36
95
  url: string;
96
+ /** Where the audio actually is. See `Listing.audio`. */
97
+ audio?: string;
37
98
  tracks: number;
38
99
  nowPlaying: string;
39
100
  }
@@ -75,7 +136,16 @@ export function parseAnnouncement(input: unknown): Announcement | null {
75
136
  const record = input as Record<string, unknown>;
76
137
 
77
138
  const url = typeof record["url"] === "string" ? record["url"] : "";
78
- if (publishable(url) === null) return null;
139
+ const listen = publishable(url);
140
+ if (listen === null) return null;
141
+
142
+ // The audio address has to be the same server as the listen link. This one
143
+ // is played into a telephone call that somebody pays for by the minute, and
144
+ // an announcement that could name any address on the internet could point
145
+ // the phone line at any of them. Same origin, or we do not take it.
146
+ const offered = typeof record["audio"] === "string" ? record["audio"] : "";
147
+ const parsed = offered ? publishable(offered) : null;
148
+ const audio = parsed !== null && parsed.origin === listen.origin ? offered : "";
79
149
 
80
150
  const name = clean(record["name"], MAX_NAME);
81
151
  const tracks = Number(record["tracks"]);
@@ -83,6 +153,7 @@ export function parseAnnouncement(input: unknown): Announcement | null {
83
153
  ...(typeof record["id"] === "string" ? { id: clean(record["id"], 40) } : {}),
84
154
  name: name || "a nixamp",
85
155
  url,
156
+ ...(audio ? { audio } : {}),
86
157
  tracks: Number.isFinite(tracks) && tracks >= 0 ? Math.min(1_000_000, Math.floor(tracks)) : 0,
87
158
  nowPlaying: clean(record["nowPlaying"], MAX_TRACK),
88
159
  };
@@ -95,30 +166,94 @@ export function parseAnnouncement(input: unknown): Announcement | null {
95
166
  */
96
167
  export class Directory {
97
168
  private readonly items = new Map<string, Listing>();
169
+ /** Streams that stopped, so the phone line can say when. */
170
+ private readonly ended = new Map<string, Ended>();
98
171
  private sequence = 0;
172
+ /**
173
+ * Somewhere to echo the ended list, so it survives a restart.
174
+ *
175
+ * Attached after construction rather than taken as a constructor argument:
176
+ * this is a mirror, not a dependency, and the directory works exactly as it
177
+ * did without one.
178
+ */
179
+ private mirror: { save: (item: Ended) => void; drop: (id: string) => void } | null = null;
180
+
181
+ /** Start echoing ended streams somewhere durable. */
182
+ persistTo(mirror: { save: (item: Ended) => void; drop: (id: string) => void }): void {
183
+ this.mirror = mirror;
184
+ }
185
+
186
+ /**
187
+ * Put back what a previous process knew.
188
+ *
189
+ * Only fills gaps: anything already here was announced since we started and
190
+ * is newer than a row written before the restart.
191
+ */
192
+ seedEnded(items: readonly Ended[]): void {
193
+ for (const item of items) {
194
+ if (!this.ended.has(item.id) && !this.items.has(item.id)) this.ended.set(item.id, item);
195
+ }
196
+ }
99
197
 
100
198
  constructor(
101
199
  private readonly ttl = TTL_MS,
102
200
  private readonly now: () => number = Date.now,
201
+ /** Injected so a test can make a code predictable rather than guess it. */
202
+ private readonly randomCode: () => string = () =>
203
+ String(randomInt(0, 1_000_000)).padStart(6, "0"),
204
+ /**
205
+ * Called when a stream starts, and only then.
206
+ *
207
+ * A publisher announces every ninety seconds for as long as it is up, so
208
+ * "announced" is not "went live" -- telling followers on every heartbeat
209
+ * would be telling them forty times an hour. This fires on the transition
210
+ * and not on the renewals that follow it.
211
+ */
212
+ private readonly onLive: (listing: Listing) => void = () => {},
103
213
  ) {}
104
214
 
105
- announce(announcement: Announcement): Listing {
215
+ announce(announcement: Announcement, ownerId = ""): Listing {
106
216
  this.sweep();
107
217
  const existing = [...this.items.values()].find((item) => item.url === announcement.url);
108
- const id = existing?.id ?? `s${++this.sequence}${this.now().toString(36)}`;
218
+
219
+ // A stream coming back after a gap keeps the code it had, so a caller who
220
+ // was told "call back later" can key the same six digits and get through.
221
+ const previously = existing ?? this.endedByUrl(announcement.url);
222
+ const id = previously?.id ?? `s${++this.sequence}${this.now().toString(36)}`;
223
+ const code = previously?.code ?? this.freeCode();
224
+ if (this.ended.has(id)) {
225
+ this.ended.delete(id);
226
+ this.mirror?.drop(id);
227
+ }
228
+
109
229
  const listing: Listing = {
110
230
  id,
231
+ code,
111
232
  name: announcement.name,
233
+ // A returning stream keeps the owner it had, so a heartbeat that omits
234
+ // it cannot orphan a listing people are following.
235
+ ownerId: ownerId || existing?.ownerId || previously?.ownerId || "",
112
236
  url: announcement.url,
237
+ // A heartbeat that omits it keeps what we had, the same as the owner: an
238
+ // older publisher renewing an entry should not blank the address the
239
+ // phone line is playing from.
240
+ audio: announcement.audio ?? existing?.audio ?? "",
113
241
  tracks: announcement.tracks,
114
242
  nowPlaying: announcement.nowPlaying,
115
243
  updatedAt: this.now(),
244
+ // A stream that never stopped keeps its original start. One that did
245
+ // starts again now, because that is what a caller is being told about.
246
+ startedAt: existing?.startedAt ?? this.now(),
116
247
  };
117
248
  this.items.set(id, listing);
249
+ // The transition, not the heartbeat: existing means it was already live.
250
+ if (existing === undefined) this.onLive(listing);
118
251
  return listing;
119
252
  }
120
253
 
121
254
  withdraw(id: string): void {
255
+ const item = this.items.get(id);
256
+ if (item !== undefined) this.remember(item);
122
257
  this.items.delete(id);
123
258
  }
124
259
 
@@ -127,9 +262,101 @@ export class Directory {
127
262
  return [...this.items.values()].sort((a, b) => b.updatedAt - a.updatedAt || a.id.localeCompare(b.id));
128
263
  }
129
264
 
130
- /** Forget anything that stopped renewing. */
265
+ /** The live stream on this code, if there is one. */
266
+ liveByCode(code: string): Listing | undefined {
267
+ this.sweep();
268
+ return [...this.items.values()].find((item) => item.code === code);
269
+ }
270
+
271
+ /**
272
+ * Streams that stopped recently, most recent first.
273
+ *
274
+ * Kept for the phone line, which has to say when a stream ended -- but they
275
+ * answer a second question the live list cannot: who is there to follow.
276
+ * Following exists to hear about broadcasts you would otherwise miss, and a
277
+ * directory that only lists what is on can only be used to follow somebody
278
+ * during a broadcast you did not miss.
279
+ */
280
+ recentlyEnded(): Ended[] {
281
+ this.sweep();
282
+ const live = new Set([...this.items.values()].map((item) => item.id));
283
+ return [...this.ended.values()]
284
+ .filter((item) => !live.has(item.id))
285
+ .sort((a, b) => b.endedAt - a.endedAt);
286
+ }
287
+
288
+ /** The name last used by an account, live or recently ended. */
289
+ nameOf(ownerId: string): string {
290
+ if (!ownerId) return "";
291
+ this.sweep();
292
+ const live = [...this.items.values()].find((item) => item.ownerId === ownerId);
293
+ if (live) return live.name;
294
+ const ended = [...this.ended.values()]
295
+ .filter((item) => item.ownerId === ownerId)
296
+ .sort((a, b) => b.endedAt - a.endedAt)[0];
297
+ return ended?.name ?? "";
298
+ }
299
+
300
+ /** Whether this account is streaming right now. */
301
+ isLive(ownerId: string): boolean {
302
+ if (!ownerId) return false;
303
+ this.sweep();
304
+ return [...this.items.values()].some((item) => item.ownerId === ownerId);
305
+ }
306
+
307
+ /** The stream that used to be on this code, if it stopped recently. */
308
+ endedByCode(code: string): Ended | undefined {
309
+ this.sweep();
310
+ return [...this.ended.values()].find((item) => item.code === code);
311
+ }
312
+
313
+ private endedByUrl(url: string): Ended | undefined {
314
+ return [...this.ended.values()].find((item) => item.url === url);
315
+ }
316
+
317
+ private remember(item: Listing): void {
318
+ const record: Ended = {
319
+ id: item.id,
320
+ code: item.code,
321
+ name: item.name,
322
+ ownerId: item.ownerId,
323
+ url: item.url,
324
+ nowPlaying: item.nowPlaying,
325
+ startedAt: item.startedAt,
326
+ endedAt: item.updatedAt,
327
+ };
328
+ this.ended.set(item.id, record);
329
+ this.mirror?.save(record);
330
+ }
331
+
332
+ /** A code no live and no recently-ended stream is using. */
333
+ private freeCode(): string {
334
+ for (let tries = 0; tries < 40; tries += 1) {
335
+ const code = this.randomCode();
336
+ if (code.length !== 6) continue;
337
+ const taken =
338
+ [...this.items.values()].some((i) => i.code === code) ||
339
+ [...this.ended.values()].some((i) => i.code === code);
340
+ if (!taken) return code;
341
+ }
342
+ return "";
343
+ }
344
+
345
+ /** Forget anything that stopped renewing, keeping a note of when it did. */
131
346
  private sweep(): void {
132
347
  const cutoff = this.now() - this.ttl;
133
- for (const [id, item] of this.items) if (item.updatedAt < cutoff) this.items.delete(id);
348
+ for (const [id, item] of this.items) {
349
+ if (item.updatedAt < cutoff) {
350
+ this.remember(item);
351
+ this.items.delete(id);
352
+ }
353
+ }
354
+ const forget = this.now() - ENDED_TTL_MS;
355
+ for (const [id, item] of this.ended) {
356
+ if (item.endedAt < forget) {
357
+ this.ended.delete(id);
358
+ this.mirror?.drop(id);
359
+ }
360
+ }
134
361
  }
135
362
  }
package/src/durable.ts ADDED
@@ -0,0 +1,215 @@
1
+ /**
2
+ * The two pieces of state that were promises, and were only in memory.
3
+ *
4
+ * Most of nixamp is deliberately ephemeral. The directory is a four-minute TTL
5
+ * and a heartbeat, because a stream that stops is a stream nobody is hearing,
6
+ * and a restart costs one heartbeat rather than a migration. That reasoning is
7
+ * right for what is on. It is wrong for two things that outlived their stream
8
+ * on purpose:
9
+ *
10
+ * A caller who pressed 1 was told "we will text you when they are live
11
+ * again". That subscription lived in a Map, so a deploy dropped it and the
12
+ * text never came -- and nothing anywhere said so. A promise made on a phone
13
+ * call and quietly forgotten is worse than never offering it.
14
+ *
15
+ * A stream that ended is what the phone line reads back ("ended at 9:27 PM
16
+ * Pacific") and what the directory offers to follow when nobody is on. After
17
+ * a deploy the code a caller had been told to key would find nothing and
18
+ * open an empty room instead.
19
+ *
20
+ * This is a mirror rather than a replacement. The in-memory maps stay exactly
21
+ * as they were -- so every caller stays synchronous and every existing test
22
+ * still describes the same object -- and each write is echoed here, with the
23
+ * contents read back once at boot. The cost of that choice is that two
24
+ * instances would each hold their own copy; nixamp.com runs one, and a second
25
+ * would need this to become the source of truth rather than the mirror.
26
+ */
27
+ import type { Queryable } from "./follows.ts";
28
+
29
+ export interface StoredEnded {
30
+ id: string;
31
+ code: string;
32
+ name: string;
33
+ ownerId: string;
34
+ url: string;
35
+ nowPlaying: string;
36
+ startedAt: number;
37
+ endedAt: number;
38
+ }
39
+
40
+ const SCHEMA = `
41
+ CREATE TABLE IF NOT EXISTS ended_streams (
42
+ id TEXT PRIMARY KEY,
43
+ code TEXT NOT NULL,
44
+ name TEXT NOT NULL DEFAULT '',
45
+ owner_id TEXT NOT NULL DEFAULT '',
46
+ url TEXT NOT NULL DEFAULT '',
47
+ now_playing TEXT NOT NULL DEFAULT '',
48
+ started_at BIGINT NOT NULL,
49
+ ended_at BIGINT NOT NULL
50
+ );
51
+ CREATE INDEX IF NOT EXISTS ended_streams_code ON ended_streams (code);
52
+
53
+ CREATE TABLE IF NOT EXISTS stream_reminders (
54
+ code TEXT NOT NULL,
55
+ phone TEXT NOT NULL,
56
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
57
+ PRIMARY KEY (code, phone)
58
+ );
59
+ `;
60
+
61
+ export class Durable {
62
+ private ready: Promise<void> | null = null;
63
+
64
+ constructor(
65
+ private readonly db: Queryable,
66
+ private readonly onEvent: (message: string) => void = () => {},
67
+ ) {}
68
+
69
+ private async ensure(): Promise<void> {
70
+ this.ready ??= this.db.query(SCHEMA).then(() => undefined);
71
+ await this.ready;
72
+ }
73
+
74
+ /**
75
+ * Nothing here is worth taking a request down for.
76
+ *
77
+ * These are all mirror writes: the in-memory copy is what the request is
78
+ * answered from, so a database that is briefly unreachable should cost the
79
+ * durability and not the feature.
80
+ */
81
+ private async quietly(what: string, run: () => Promise<unknown>): Promise<void> {
82
+ try {
83
+ await this.ensure();
84
+ await run();
85
+ } catch (error) {
86
+ this.onEvent(` ${what} did not persist: ${(error as Error).message}`);
87
+ }
88
+ }
89
+
90
+ async saveEnded(stream: StoredEnded): Promise<void> {
91
+ await this.quietly("an ended stream", () =>
92
+ this.db.query(
93
+ `INSERT INTO ended_streams
94
+ (id, code, name, owner_id, url, now_playing, started_at, ended_at)
95
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
96
+ ON CONFLICT (id) DO UPDATE
97
+ SET code = EXCLUDED.code,
98
+ name = EXCLUDED.name,
99
+ owner_id = EXCLUDED.owner_id,
100
+ url = EXCLUDED.url,
101
+ now_playing = EXCLUDED.now_playing,
102
+ started_at = EXCLUDED.started_at,
103
+ ended_at = EXCLUDED.ended_at`,
104
+ [
105
+ stream.id,
106
+ stream.code,
107
+ stream.name,
108
+ stream.ownerId,
109
+ stream.url,
110
+ stream.nowPlaying,
111
+ stream.startedAt,
112
+ stream.endedAt,
113
+ ],
114
+ ),
115
+ );
116
+ }
117
+
118
+ /** A stream that came back, or one old enough to forget. */
119
+ async dropEnded(id: string): Promise<void> {
120
+ await this.quietly("dropping an ended stream", () =>
121
+ this.db.query("DELETE FROM ended_streams WHERE id = $1", [id]),
122
+ );
123
+ }
124
+
125
+ /** What ended since `since`, oldest first so replaying it rebuilds the order. */
126
+ async loadEnded(since: number): Promise<StoredEnded[]> {
127
+ try {
128
+ await this.ensure();
129
+ const { rows } = await this.db.query(
130
+ "SELECT * FROM ended_streams WHERE ended_at >= $1 ORDER BY ended_at",
131
+ [since],
132
+ );
133
+ return rows.map((r) => ({
134
+ id: String(r["id"] ?? ""),
135
+ code: String(r["code"] ?? ""),
136
+ name: String(r["name"] ?? ""),
137
+ ownerId: String(r["owner_id"] ?? ""),
138
+ url: String(r["url"] ?? ""),
139
+ nowPlaying: String(r["now_playing"] ?? ""),
140
+ // BIGINT comes back as a string from pg, which sorts and compares
141
+ // nothing like a number.
142
+ startedAt: Number(r["started_at"] ?? 0),
143
+ endedAt: Number(r["ended_at"] ?? 0),
144
+ }));
145
+ } catch (error) {
146
+ this.onEvent(` could not read ended streams: ${(error as Error).message}`);
147
+ return [];
148
+ }
149
+ }
150
+
151
+ async addReminder(code: string, phone: string): Promise<void> {
152
+ if (!code || !phone) return;
153
+ await this.quietly("a reminder", () =>
154
+ this.db.query(
155
+ `INSERT INTO stream_reminders (code, phone) VALUES ($1, $2)
156
+ ON CONFLICT DO NOTHING`,
157
+ [code, phone],
158
+ ),
159
+ );
160
+ }
161
+
162
+ /**
163
+ * Take everyone waiting on a code, and stop them waiting, in one statement.
164
+ *
165
+ * `DELETE ... RETURNING` rather than a select and then a delete: the rows
166
+ * come back as they are removed, so two goings-live at once cannot both read
167
+ * the same list and text everybody twice.
168
+ */
169
+ async takeReminders(code: string): Promise<string[]> {
170
+ if (!code) return [];
171
+ try {
172
+ await this.ensure();
173
+ const { rows } = await this.db.query(
174
+ "DELETE FROM stream_reminders WHERE code = $1 RETURNING phone",
175
+ [code],
176
+ );
177
+ return rows.map((r) => String(r["phone"] ?? "")).filter(Boolean);
178
+ } catch (error) {
179
+ this.onEvent(` could not take reminders: ${(error as Error).message}`);
180
+ return [];
181
+ }
182
+ }
183
+
184
+ /** Everyone waiting, by code, to seed a process that has just started. */
185
+ async loadReminders(): Promise<Map<string, Set<string>>> {
186
+ const waiting = new Map<string, Set<string>>();
187
+ try {
188
+ await this.ensure();
189
+ const { rows } = await this.db.query("SELECT code, phone FROM stream_reminders", []);
190
+ for (const row of rows) {
191
+ const code = String(row["code"] ?? "");
192
+ const phone = String(row["phone"] ?? "");
193
+ if (!code || !phone) continue;
194
+ const set = waiting.get(code) ?? new Set<string>();
195
+ set.add(phone);
196
+ waiting.set(code, set);
197
+ }
198
+ } catch (error) {
199
+ this.onEvent(` could not read reminders: ${(error as Error).message}`);
200
+ }
201
+ return waiting;
202
+ }
203
+
204
+ /** Forget what is too old to be worth telling anybody about. */
205
+ async sweep(endedBefore: number, remindersBefore: Date): Promise<void> {
206
+ await this.quietly("sweeping", async () => {
207
+ await this.db.query("DELETE FROM ended_streams WHERE ended_at < $1", [endedBefore]);
208
+ // A reminder nobody has collected in a month is somebody who has long
209
+ // since stopped expecting a text.
210
+ await this.db.query("DELETE FROM stream_reminders WHERE created_at < $1", [
211
+ remindersBefore.toISOString(),
212
+ ]);
213
+ });
214
+ }
215
+ }