nixamp 0.10.0 → 0.10.2

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.
@@ -100,6 +100,12 @@ export interface Ended {
100
100
  startedAt: number;
101
101
  /** The last heartbeat we saw, which is as close to "ended" as we can know. */
102
102
  endedAt: number;
103
+ /**
104
+ * The codes its channels had, so a server that stops and comes back -- an
105
+ * update restarts the daemon, and the daemon withdraws its listing on the
106
+ * way down -- puts each channel back under the code people were given.
107
+ */
108
+ channelCodes?: Record<string, string>;
103
109
  }
104
110
  /** How long an ended stream is still worth telling a caller about. */
105
111
  export declare const ENDED_TTL_MS: number;
@@ -149,6 +155,8 @@ export declare class Directory {
149
155
  */
150
156
  private readonly onLive;
151
157
  private readonly items;
158
+ /** Every code a channel has had on each listing, so a returning channel keeps its own. */
159
+ private readonly channelHistory;
152
160
  /** Streams that stopped, so the phone line can say when. */
153
161
  private readonly ended;
154
162
  private sequence;
package/dist/directory.js CHANGED
@@ -104,6 +104,8 @@ export class Directory {
104
104
  randomCode;
105
105
  onLive;
106
106
  items = new Map();
107
+ /** Every code a channel has had on each listing, so a returning channel keeps its own. */
108
+ channelHistory = new Map();
107
109
  /** Streams that stopped, so the phone line can say when. */
108
110
  ended = new Map();
109
111
  sequence = 0;
@@ -127,8 +129,14 @@ export class Directory {
127
129
  */
128
130
  seedEnded(items) {
129
131
  for (const item of items) {
130
- if (!this.ended.has(item.id) && !this.items.has(item.id))
132
+ if (!this.ended.has(item.id) && !this.items.has(item.id)) {
131
133
  this.ended.set(item.id, item);
134
+ // Its channels' codes are reserved too, so a server coming back after
135
+ // the directory restarted still finds them its own.
136
+ if (item.channelCodes && Object.keys(item.channelCodes).length > 0) {
137
+ this.channelHistory.set(item.id, { ...(this.channelHistory.get(item.id) ?? {}), ...item.channelCodes });
138
+ }
139
+ }
132
140
  }
133
141
  }
134
142
  constructor(ttl = TTL_MS, now = Date.now,
@@ -179,13 +187,23 @@ export class Directory {
179
187
  // listing always meant, and no channels is the honest empty list.
180
188
  playing: announcement.playing ?? true,
181
189
  channels: announcement.channels ?? [],
182
- channelCodes: this.codesFor(announcement.channels ?? [], existing?.channelCodes ?? (previously && "channelCodes" in previously ? previously.channelCodes : undefined), id),
190
+ channelCodes: this.codesFor(announcement.channels ?? [],
191
+ // What each channel has been called before, on this listing: a
192
+ // channel that was gone for a heartbeat -- a server restarting puts
193
+ // its channels back a moment after it announces -- comes back to the
194
+ // code people were given, not a new one.
195
+ {
196
+ ...(previously && "channelCodes" in previously ? previously.channelCodes ?? {} : {}),
197
+ ...(this.channelHistory.get(id) ?? {}),
198
+ ...(existing?.channelCodes ?? {}),
199
+ }, id),
183
200
  updatedAt: this.now(),
184
201
  // A stream that never stopped keeps its original start. One that did
185
202
  // starts again now, because that is what a caller is being told about.
186
203
  startedAt: existing?.startedAt ?? this.now(),
187
204
  };
188
205
  this.items.set(id, listing);
206
+ this.channelHistory.set(id, { ...(this.channelHistory.get(id) ?? {}), ...listing.channelCodes });
189
207
  // The transition, not the heartbeat: existing means it was already live.
190
208
  if (existing === undefined)
191
209
  this.onLive(listing);
@@ -196,6 +214,7 @@ export class Directory {
196
214
  if (item !== undefined)
197
215
  this.remember(item);
198
216
  this.items.delete(id);
217
+ // The channel history stays with the ended record, and goes when it goes.
199
218
  }
200
219
  list() {
201
220
  this.sweep();
@@ -250,7 +269,11 @@ export class Directory {
250
269
  for (const item of this.items.values()) {
251
270
  if (item.code === code)
252
271
  return true;
253
- if (item.id !== own && Object.values(item.channelCodes).includes(code))
272
+ }
273
+ // Every code a channel has ever had on a listing that is still up is
274
+ // that channel's to come back to, on every listing but the one asking.
275
+ for (const [id, codes] of this.channelHistory) {
276
+ if (id !== own && Object.values(codes).includes(code))
254
277
  return true;
255
278
  }
256
279
  return [...this.ended.values()].some((i) => i.code === code);
@@ -309,6 +332,9 @@ export class Directory {
309
332
  nowPlaying: item.nowPlaying,
310
333
  startedAt: item.startedAt,
311
334
  endedAt: item.updatedAt,
335
+ // Every code its channels have had, not only the ones on at the end:
336
+ // a restart announces before its channels are back.
337
+ channelCodes: { ...(this.channelHistory.get(item.id) ?? {}), ...item.channelCodes },
312
338
  };
313
339
  this.ended.set(item.id, record);
314
340
  this.mirror?.save(record);
@@ -337,6 +363,7 @@ export class Directory {
337
363
  for (const [id, item] of this.ended) {
338
364
  if (item.endedAt < forget) {
339
365
  this.ended.delete(id);
366
+ this.channelHistory.delete(id);
340
367
  this.mirror?.drop(id);
341
368
  }
342
369
  }
package/dist/durable.d.ts CHANGED
@@ -34,6 +34,8 @@ export interface StoredEnded {
34
34
  nowPlaying: string;
35
35
  startedAt: number;
36
36
  endedAt: number;
37
+ /** Each channel's phone code, by name, so a server coming back keeps them. */
38
+ channelCodes?: Record<string, string>;
37
39
  }
38
40
  export declare class Durable {
39
41
  private readonly db;
package/dist/durable.js CHANGED
@@ -9,6 +9,7 @@ const SCHEMA = `
9
9
  started_at BIGINT NOT NULL,
10
10
  ended_at BIGINT NOT NULL
11
11
  );
12
+ ALTER TABLE ended_streams ADD COLUMN IF NOT EXISTS channel_codes JSONB NOT NULL DEFAULT '{}'::jsonb;
12
13
  CREATE INDEX IF NOT EXISTS ended_streams_code ON ended_streams (code);
13
14
 
14
15
  CREATE TABLE IF NOT EXISTS stream_reminders (
@@ -18,6 +19,26 @@ const SCHEMA = `
18
19
  PRIMARY KEY (code, phone)
19
20
  );
20
21
  `;
22
+ /** A JSONB column, as pg hands it back: an object already, or a string on an odd driver. */
23
+ function codesFrom(value) {
24
+ let parsed = value;
25
+ if (typeof value === "string") {
26
+ try {
27
+ parsed = JSON.parse(value);
28
+ }
29
+ catch {
30
+ return {};
31
+ }
32
+ }
33
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
34
+ return {};
35
+ const codes = {};
36
+ for (const [name, code] of Object.entries(parsed)) {
37
+ if (typeof code === "string" && /^\d{6}$/.test(code))
38
+ codes[name] = code;
39
+ }
40
+ return codes;
41
+ }
21
42
  export class Durable {
22
43
  db;
23
44
  onEvent;
@@ -48,8 +69,8 @@ export class Durable {
48
69
  }
49
70
  async saveEnded(stream) {
50
71
  await this.quietly("an ended stream", () => this.db.query(`INSERT INTO ended_streams
51
- (id, code, name, owner_id, url, now_playing, started_at, ended_at)
52
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
72
+ (id, code, name, owner_id, url, now_playing, started_at, ended_at, channel_codes)
73
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb)
53
74
  ON CONFLICT (id) DO UPDATE
54
75
  SET code = EXCLUDED.code,
55
76
  name = EXCLUDED.name,
@@ -57,7 +78,8 @@ export class Durable {
57
78
  url = EXCLUDED.url,
58
79
  now_playing = EXCLUDED.now_playing,
59
80
  started_at = EXCLUDED.started_at,
60
- ended_at = EXCLUDED.ended_at`, [
81
+ ended_at = EXCLUDED.ended_at,
82
+ channel_codes = EXCLUDED.channel_codes`, [
61
83
  stream.id,
62
84
  stream.code,
63
85
  stream.name,
@@ -66,6 +88,7 @@ export class Durable {
66
88
  stream.nowPlaying,
67
89
  stream.startedAt,
68
90
  stream.endedAt,
91
+ JSON.stringify(stream.channelCodes ?? {}),
69
92
  ]));
70
93
  }
71
94
  /** A stream that came back, or one old enough to forget. */
@@ -88,6 +111,7 @@ export class Durable {
88
111
  // nothing like a number.
89
112
  startedAt: Number(r["started_at"] ?? 0),
90
113
  endedAt: Number(r["ended_at"] ?? 0),
114
+ channelCodes: codesFrom(r["channel_codes"]),
91
115
  }));
92
116
  }
93
117
  catch (error) {
package/dist/server.js CHANGED
@@ -2256,6 +2256,9 @@ export function createHandler(engine, options) {
2256
2256
  return;
2257
2257
  }
2258
2258
  options.channels.ephemeral(channelId);
2259
+ // Told to the directory now, so the room code arrives with the
2260
+ // channel rather than at the next heartbeat, ninety seconds on.
2261
+ void options.live?.announce?.();
2259
2262
  }
2260
2263
  json(response, 200, { kind: "live", channel: channelId, name: entry.title });
2261
2264
  return;
@@ -2354,6 +2357,9 @@ export function createHandler(engine, options) {
2354
2357
  return;
2355
2358
  }
2356
2359
  options.channels.ephemeral(channelId);
2360
+ // Told to the directory now, so the room code arrives with the
2361
+ // channel rather than at the next heartbeat, ninety seconds on.
2362
+ void options.live?.announce?.();
2357
2363
  }
2358
2364
  json(response, 200, shownLink(channelId, resolved));
2359
2365
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.10.0",
3
+ "version": "0.10.2",
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",
package/src/directory.ts CHANGED
@@ -105,6 +105,12 @@ export interface Ended {
105
105
  startedAt: number;
106
106
  /** The last heartbeat we saw, which is as close to "ended" as we can know. */
107
107
  endedAt: number;
108
+ /**
109
+ * The codes its channels had, so a server that stops and comes back -- an
110
+ * update restarts the daemon, and the daemon withdraws its listing on the
111
+ * way down -- puts each channel back under the code people were given.
112
+ */
113
+ channelCodes?: Record<string, string>;
108
114
  }
109
115
 
110
116
  /** How long an ended stream is still worth telling a caller about. */
@@ -210,6 +216,8 @@ export function parseAnnouncement(input: unknown): Announcement | null {
210
216
  */
211
217
  export class Directory {
212
218
  private readonly items = new Map<string, Listing>();
219
+ /** Every code a channel has had on each listing, so a returning channel keeps its own. */
220
+ private readonly channelHistory = new Map<string, Record<string, string>>();
213
221
  /** Streams that stopped, so the phone line can say when. */
214
222
  private readonly ended = new Map<string, Ended>();
215
223
  private sequence = 0;
@@ -235,7 +243,14 @@ export class Directory {
235
243
  */
236
244
  seedEnded(items: readonly Ended[]): void {
237
245
  for (const item of items) {
238
- if (!this.ended.has(item.id) && !this.items.has(item.id)) this.ended.set(item.id, item);
246
+ if (!this.ended.has(item.id) && !this.items.has(item.id)) {
247
+ this.ended.set(item.id, item);
248
+ // Its channels' codes are reserved too, so a server coming back after
249
+ // the directory restarted still finds them its own.
250
+ if (item.channelCodes && Object.keys(item.channelCodes).length > 0) {
251
+ this.channelHistory.set(item.id, { ...(this.channelHistory.get(item.id) ?? {}), ...item.channelCodes });
252
+ }
253
+ }
239
254
  }
240
255
  }
241
256
 
@@ -291,7 +306,15 @@ export class Directory {
291
306
  channels: announcement.channels ?? [],
292
307
  channelCodes: this.codesFor(
293
308
  announcement.channels ?? [],
294
- existing?.channelCodes ?? (previously && "channelCodes" in previously ? previously.channelCodes : undefined),
309
+ // What each channel has been called before, on this listing: a
310
+ // channel that was gone for a heartbeat -- a server restarting puts
311
+ // its channels back a moment after it announces -- comes back to the
312
+ // code people were given, not a new one.
313
+ {
314
+ ...(previously && "channelCodes" in previously ? previously.channelCodes ?? {} : {}),
315
+ ...(this.channelHistory.get(id) ?? {}),
316
+ ...(existing?.channelCodes ?? {}),
317
+ },
295
318
  id,
296
319
  ),
297
320
  updatedAt: this.now(),
@@ -300,6 +323,7 @@ export class Directory {
300
323
  startedAt: existing?.startedAt ?? this.now(),
301
324
  };
302
325
  this.items.set(id, listing);
326
+ this.channelHistory.set(id, { ...(this.channelHistory.get(id) ?? {}), ...listing.channelCodes });
303
327
  // The transition, not the heartbeat: existing means it was already live.
304
328
  if (existing === undefined) this.onLive(listing);
305
329
  return listing;
@@ -309,6 +333,7 @@ export class Directory {
309
333
  const item = this.items.get(id);
310
334
  if (item !== undefined) this.remember(item);
311
335
  this.items.delete(id);
336
+ // The channel history stays with the ended record, and goes when it goes.
312
337
  }
313
338
 
314
339
  list(): Listing[] {
@@ -362,7 +387,11 @@ export class Directory {
362
387
  if (Object.values(besides).includes(code)) return true;
363
388
  for (const item of this.items.values()) {
364
389
  if (item.code === code) return true;
365
- if (item.id !== own && Object.values(item.channelCodes).includes(code)) return true;
390
+ }
391
+ // Every code a channel has ever had on a listing that is still up is
392
+ // that channel's to come back to, on every listing but the one asking.
393
+ for (const [id, codes] of this.channelHistory) {
394
+ if (id !== own && Object.values(codes).includes(code)) return true;
366
395
  }
367
396
  return [...this.ended.values()].some((i) => i.code === code);
368
397
  }
@@ -423,6 +452,9 @@ export class Directory {
423
452
  nowPlaying: item.nowPlaying,
424
453
  startedAt: item.startedAt,
425
454
  endedAt: item.updatedAt,
455
+ // Every code its channels have had, not only the ones on at the end:
456
+ // a restart announces before its channels are back.
457
+ channelCodes: { ...(this.channelHistory.get(item.id) ?? {}), ...item.channelCodes },
426
458
  };
427
459
  this.ended.set(item.id, record);
428
460
  this.mirror?.save(record);
@@ -451,6 +483,7 @@ export class Directory {
451
483
  for (const [id, item] of this.ended) {
452
484
  if (item.endedAt < forget) {
453
485
  this.ended.delete(id);
486
+ this.channelHistory.delete(id);
454
487
  this.mirror?.drop(id);
455
488
  }
456
489
  }
package/src/durable.ts CHANGED
@@ -35,6 +35,8 @@ export interface StoredEnded {
35
35
  nowPlaying: string;
36
36
  startedAt: number;
37
37
  endedAt: number;
38
+ /** Each channel's phone code, by name, so a server coming back keeps them. */
39
+ channelCodes?: Record<string, string>;
38
40
  }
39
41
 
40
42
  const SCHEMA = `
@@ -48,6 +50,7 @@ const SCHEMA = `
48
50
  started_at BIGINT NOT NULL,
49
51
  ended_at BIGINT NOT NULL
50
52
  );
53
+ ALTER TABLE ended_streams ADD COLUMN IF NOT EXISTS channel_codes JSONB NOT NULL DEFAULT '{}'::jsonb;
51
54
  CREATE INDEX IF NOT EXISTS ended_streams_code ON ended_streams (code);
52
55
 
53
56
  CREATE TABLE IF NOT EXISTS stream_reminders (
@@ -58,6 +61,24 @@ const SCHEMA = `
58
61
  );
59
62
  `;
60
63
 
64
+ /** A JSONB column, as pg hands it back: an object already, or a string on an odd driver. */
65
+ function codesFrom(value: unknown): Record<string, string> {
66
+ let parsed: unknown = value;
67
+ if (typeof value === "string") {
68
+ try {
69
+ parsed = JSON.parse(value);
70
+ } catch {
71
+ return {};
72
+ }
73
+ }
74
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
75
+ const codes: Record<string, string> = {};
76
+ for (const [name, code] of Object.entries(parsed as Record<string, unknown>)) {
77
+ if (typeof code === "string" && /^\d{6}$/.test(code)) codes[name] = code;
78
+ }
79
+ return codes;
80
+ }
81
+
61
82
  export class Durable {
62
83
  private ready: Promise<void> | null = null;
63
84
 
@@ -91,8 +112,8 @@ export class Durable {
91
112
  await this.quietly("an ended stream", () =>
92
113
  this.db.query(
93
114
  `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)
115
+ (id, code, name, owner_id, url, now_playing, started_at, ended_at, channel_codes)
116
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb)
96
117
  ON CONFLICT (id) DO UPDATE
97
118
  SET code = EXCLUDED.code,
98
119
  name = EXCLUDED.name,
@@ -100,7 +121,8 @@ export class Durable {
100
121
  url = EXCLUDED.url,
101
122
  now_playing = EXCLUDED.now_playing,
102
123
  started_at = EXCLUDED.started_at,
103
- ended_at = EXCLUDED.ended_at`,
124
+ ended_at = EXCLUDED.ended_at,
125
+ channel_codes = EXCLUDED.channel_codes`,
104
126
  [
105
127
  stream.id,
106
128
  stream.code,
@@ -110,6 +132,7 @@ export class Durable {
110
132
  stream.nowPlaying,
111
133
  stream.startedAt,
112
134
  stream.endedAt,
135
+ JSON.stringify(stream.channelCodes ?? {}),
113
136
  ],
114
137
  ),
115
138
  );
@@ -141,6 +164,7 @@ export class Durable {
141
164
  // nothing like a number.
142
165
  startedAt: Number(r["started_at"] ?? 0),
143
166
  endedAt: Number(r["ended_at"] ?? 0),
167
+ channelCodes: codesFrom(r["channel_codes"]),
144
168
  }));
145
169
  } catch (error) {
146
170
  this.onEvent(` could not read ended streams: ${(error as Error).message}`);
package/src/server.ts CHANGED
@@ -2721,6 +2721,9 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2721
2721
  return;
2722
2722
  }
2723
2723
  options.channels.ephemeral(channelId);
2724
+ // Told to the directory now, so the room code arrives with the
2725
+ // channel rather than at the next heartbeat, ninety seconds on.
2726
+ void options.live?.announce?.();
2724
2727
  }
2725
2728
  json(response, 200, { kind: "live", channel: channelId, name: entry.title });
2726
2729
  return;
@@ -2826,6 +2829,9 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2826
2829
  return;
2827
2830
  }
2828
2831
  options.channels.ephemeral(channelId);
2832
+ // Told to the directory now, so the room code arrives with the
2833
+ // channel rather than at the next heartbeat, ninety seconds on.
2834
+ void options.live?.announce?.();
2829
2835
  }
2830
2836
  json(response, 200, shownLink(channelId, resolved));
2831
2837
  return;
package/web/dist/sw.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /* nixamp service worker — generated, do not edit */
2
- const CACHE = "nixamp-1789043647290";
2
+ const CACHE = "nixamp-1789044699569";
3
3
  const PRECACHE = [
4
4
  "/",
5
5
  "/apple-touch-icon.png",