nixamp 0.57.1 → 0.58.1
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/channels.d.ts +20 -0
- package/dist/channels.js +38 -0
- package/dist/guide-rooms.d.ts +42 -0
- package/dist/guide-rooms.js +157 -7
- package/dist/radio-pages.d.ts +2 -2
- package/dist/radio-pages.js +5 -5
- package/dist/server.js +52 -1
- package/dist/station-routes.d.ts +9 -1
- package/dist/station-routes.js +63 -3
- package/package.json +1 -1
- package/src/channels.ts +43 -0
- package/src/guide-rooms.ts +182 -8
- package/src/radio-pages.ts +5 -5
- package/src/server.ts +52 -1
- package/src/station-routes.ts +69 -4
- package/web/dist/assets/{ar-HteUbQ_m.js → ar-CBn9fObz.js} +1 -1
- package/web/dist/assets/{cs-CjN_4DAJ.js → cs-BvEScz92.js} +1 -1
- package/web/dist/assets/{da-CzsSXVfk.js → da-uKElVtgi.js} +1 -1
- package/web/dist/assets/{de-D7zVt1UT.js → de-DXEoI1GG.js} +1 -1
- package/web/dist/assets/{es-D8duNQiq.js → es-Q-QEZFh2.js} +1 -1
- package/web/dist/assets/{fi-CSKCYE5E.js → fi-ClJkdfXs.js} +1 -1
- package/web/dist/assets/{fr-DnjuIwaN.js → fr-DWmNoP1h.js} +1 -1
- package/web/dist/assets/{hi-ByZe82O5.js → hi-DGQOrveg.js} +1 -1
- package/web/dist/assets/{hu-B3iwlAuW.js → hu-DGcKM8DB.js} +1 -1
- package/web/dist/assets/{id-B58CS7rH.js → id-D18Cz5RW.js} +1 -1
- package/web/dist/assets/{it-CaVLbop_.js → it-WQxuRjlI.js} +1 -1
- package/web/dist/assets/{main-yTRNIxIl.js → main-cry6C8KD.js} +2 -2
- package/web/dist/assets/{nl-Dk0ss8Tf.js → nl-BvOA9QBr.js} +1 -1
- package/web/dist/assets/{ru-CGuA41ji.js → ru-Cx4tgP1k.js} +1 -1
- package/web/dist/assets/{station-CSnjPeod.css → station-6b_smJHe.css} +1 -1
- package/web/dist/assets/station-BzLUgyQ0.js +3 -0
- package/web/dist/assets/{sv-B309Td6Z.js → sv-f0zj1AdU.js} +1 -1
- package/web/dist/assets/{uk-CCCKvR-U.js → uk-DpGYtFFI.js} +1 -1
- package/web/dist/assets/{vi-DfVTbK_t.js → vi-Cxf7VqJQ.js} +1 -1
- package/web/dist/assets/watch-Bygo4bPY.js +1 -0
- package/web/dist/assets/{zh-BU9_CD0f.js → zh-DyuYbygO.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/station.html +2 -2
- package/web/dist/sw.js +24 -24
- package/web/dist/vod.html +2 -2
- package/web/dist/watch.html +2 -2
- package/web/dist/assets/station-D6sd4DqP.js +0 -3
- package/web/dist/assets/watch-Dxsz5gHd.js +0 -1
- /package/web/dist/assets/{vod-Br9Ooz6-.js → vod-Bbg_gWbl.js} +0 -0
package/dist/channels.d.ts
CHANGED
|
@@ -215,6 +215,8 @@ export interface ChannelOptions {
|
|
|
215
215
|
/** Whether a pulled source is read here and piped to ffmpeg. See `Channels.setThrough`. */
|
|
216
216
|
through?: ThroughProvider;
|
|
217
217
|
}
|
|
218
|
+
/** The byte count a chunk began at, for one a list channel put out; undefined for any other. */
|
|
219
|
+
export declare function chunkStart(chunk: Buffer): number | undefined;
|
|
218
220
|
/**
|
|
219
221
|
* One live source, and its audience.
|
|
220
222
|
*
|
|
@@ -257,6 +259,20 @@ export declare class Channel {
|
|
|
257
259
|
} | null;
|
|
258
260
|
/** Whether listeners would get sound (and, for a picture, its opening) if they joined now. */
|
|
259
261
|
get flowing(): boolean;
|
|
262
|
+
/**
|
|
263
|
+
* What was going out at this point of the byte count: the list's file (""
|
|
264
|
+
* in a break) and how far into it, with its tags. Null for a byte this
|
|
265
|
+
* channel no longer knows, or for a channel that is not a radio list.
|
|
266
|
+
*/
|
|
267
|
+
placeAt(bytes: number): {
|
|
268
|
+
file: string;
|
|
269
|
+
position: number;
|
|
270
|
+
tags?: {
|
|
271
|
+
title: string;
|
|
272
|
+
artist: string;
|
|
273
|
+
album: string;
|
|
274
|
+
};
|
|
275
|
+
} | null;
|
|
260
276
|
/** An entry's tags, as read when it was put on. */
|
|
261
277
|
tagsOf(file: string): {
|
|
262
278
|
title: string;
|
|
@@ -290,6 +306,8 @@ export declare class Channel {
|
|
|
290
306
|
private breakOn;
|
|
291
307
|
/** The last couple of minutes of a radio list without its breaks, for listenWithoutBreaks. */
|
|
292
308
|
private music;
|
|
309
|
+
/** The last few dials of a radio list, newest last, for placeAt(). */
|
|
310
|
+
private dials;
|
|
293
311
|
private musicBytes;
|
|
294
312
|
private fragmentClock;
|
|
295
313
|
private fragmentTimes;
|
|
@@ -543,6 +561,8 @@ export declare class Channels {
|
|
|
543
561
|
setThrough(provider: ThroughProvider | null): void;
|
|
544
562
|
/** The kind of a channel, for a relay to say what it is carrying. */
|
|
545
563
|
kindOf(id: string): "audio" | "video" | undefined;
|
|
564
|
+
/** What a radio list channel was putting out at a point of its byte count (Channel.placeAt). */
|
|
565
|
+
placeAt(id: string, bytes: number): ReturnType<Channel["placeAt"]>;
|
|
546
566
|
/** An entry's tags on a channel playing a list of files. */
|
|
547
567
|
tagsOf(id: string, file: string): {
|
|
548
568
|
title: string;
|
package/dist/channels.js
CHANGED
|
@@ -122,6 +122,16 @@ export function cleanId(value, fallback = "main") {
|
|
|
122
122
|
const id = value.trim().toLowerCase().replace(/[^a-z0-9_-]/g, "-").replace(/^-+|-+$/g, "");
|
|
123
123
|
return id.slice(0, 40) || fallback;
|
|
124
124
|
}
|
|
125
|
+
/**
|
|
126
|
+
* Where each chunk a channel put out begins in its byte count (ChannelInfo.bytes),
|
|
127
|
+
* so a listener's own bytes can be laid onto the channel's: a backlog, a
|
|
128
|
+
* skipped break and a redial all keep the chunk objects they were sent as.
|
|
129
|
+
*/
|
|
130
|
+
const chunkStarts = new WeakMap();
|
|
131
|
+
/** The byte count a chunk began at, for one a list channel put out; undefined for any other. */
|
|
132
|
+
export function chunkStart(chunk) {
|
|
133
|
+
return chunkStarts.get(chunk);
|
|
134
|
+
}
|
|
125
135
|
/**
|
|
126
136
|
* One live source, and its audience.
|
|
127
137
|
*
|
|
@@ -179,6 +189,20 @@ export class Channel {
|
|
|
179
189
|
get flowing() {
|
|
180
190
|
return this.info.bytes > 0 && (!this.fragments || this.fragments.ready);
|
|
181
191
|
}
|
|
192
|
+
/**
|
|
193
|
+
* What was going out at this point of the byte count: the list's file (""
|
|
194
|
+
* in a break) and how far into it, with its tags. Null for a byte this
|
|
195
|
+
* channel no longer knows, or for a channel that is not a radio list.
|
|
196
|
+
*/
|
|
197
|
+
placeAt(bytes) {
|
|
198
|
+
const dial = this.dials.findLast((one) => one.base <= bytes);
|
|
199
|
+
if (!dial)
|
|
200
|
+
return null;
|
|
201
|
+
const place = dial.input.place(((bytes - dial.base) * 8) / (dial.kbps * 1000));
|
|
202
|
+
const file = place.at >= 0 ? place.list[place.at] ?? "" : "";
|
|
203
|
+
const tags = file ? dial.input.tags.get(file) : undefined;
|
|
204
|
+
return { file, position: place.position, ...(tags ? { tags } : {}) };
|
|
205
|
+
}
|
|
182
206
|
/** An entry's tags, as read when it was put on. */
|
|
183
207
|
tagsOf(file) {
|
|
184
208
|
return this.playlistInput?.tags.get(file);
|
|
@@ -210,6 +234,8 @@ export class Channel {
|
|
|
210
234
|
breakOn = false;
|
|
211
235
|
/** The last couple of minutes of a radio list without its breaks, for listenWithoutBreaks. */
|
|
212
236
|
music = [];
|
|
237
|
+
/** The last few dials of a radio list, newest last, for placeAt(). */
|
|
238
|
+
dials = [];
|
|
213
239
|
musicBytes = 0;
|
|
214
240
|
fragmentClock = new FragmentClock();
|
|
215
241
|
fragmentTimes = new WeakMap();
|
|
@@ -452,9 +478,17 @@ export class Channel {
|
|
|
452
478
|
if (!sent)
|
|
453
479
|
this.info.error = undefined;
|
|
454
480
|
sent = true;
|
|
481
|
+
const at = this.info.bytes;
|
|
455
482
|
this.info.bytes += chunk.byteLength;
|
|
456
483
|
this.rearm(child);
|
|
457
484
|
if (kbps > 0 && playlistInput) {
|
|
485
|
+
// Constant-bitrate MP3: a byte of this dial is exactly a time on its list's clock.
|
|
486
|
+
if (written === 0) {
|
|
487
|
+
this.dials.push({ base: at, input: playlistInput, kbps });
|
|
488
|
+
if (this.dials.length > 8)
|
|
489
|
+
this.dials.shift();
|
|
490
|
+
}
|
|
491
|
+
chunkStarts.set(chunk, at);
|
|
458
492
|
this.breakOn = playlistInput.place((written * 8) / (kbps * 1000)).at === -1;
|
|
459
493
|
written += chunk.byteLength;
|
|
460
494
|
}
|
|
@@ -1291,6 +1325,10 @@ export class Channels {
|
|
|
1291
1325
|
kindOf(id) {
|
|
1292
1326
|
return this.open.get(id)?.info.kind;
|
|
1293
1327
|
}
|
|
1328
|
+
/** What a radio list channel was putting out at a point of its byte count (Channel.placeAt). */
|
|
1329
|
+
placeAt(id, bytes) {
|
|
1330
|
+
return this.open.get(id)?.placeAt(bytes) ?? null;
|
|
1331
|
+
}
|
|
1294
1332
|
/** An entry's tags on a channel playing a list of files. */
|
|
1295
1333
|
tagsOf(id, file) {
|
|
1296
1334
|
return this.open.get(id)?.tagsOf(file);
|
package/dist/guide-rooms.d.ts
CHANGED
|
@@ -32,6 +32,18 @@ export declare function roomAddress(input: string): {
|
|
|
32
32
|
/** Where the guide links a room's page: config `watchBase`, NIXAMP_WATCH_BASE, else this server. */
|
|
33
33
|
export declare function watchBase(fallback: string, env?: Record<string, string | undefined>): string;
|
|
34
34
|
export declare const watchPage: (base: string, play: string) => string;
|
|
35
|
+
/** A room's page under its name, on this server. */
|
|
36
|
+
export declare const roomPage: (base: string, slug: string) => string;
|
|
37
|
+
export declare const ROOM_PAGE: RegExp;
|
|
38
|
+
export declare const ROOM_SUB: RegExp;
|
|
39
|
+
export declare function isRoomPath(path: string): boolean;
|
|
40
|
+
/**
|
|
41
|
+
* The rooms by name: "Dropkick Murphys" is /room/dropkick-murphys. Oldest
|
|
42
|
+
* first, so a name stays with the room that had it; a second one is -2.
|
|
43
|
+
*/
|
|
44
|
+
export declare function roomSlugs(rooms: GuideRoom[]): Map<string, GuideRoom>;
|
|
45
|
+
/** A room's name here, or "". */
|
|
46
|
+
export declare function roomSlugOf(slugs: Map<string, GuideRoom>, id: string): string;
|
|
35
47
|
export declare class RoomStore {
|
|
36
48
|
private readonly file;
|
|
37
49
|
private rooms;
|
|
@@ -53,6 +65,9 @@ export interface RoomState {
|
|
|
53
65
|
title: string;
|
|
54
66
|
artist: string;
|
|
55
67
|
} | null;
|
|
68
|
+
/** A folder or a playlist (more than one file): HLS plays it better than the endless MP4. */
|
|
69
|
+
folder?: boolean;
|
|
70
|
+
album?: string;
|
|
56
71
|
}
|
|
57
72
|
/** Asks a room's own server what is on it: its channel list for a channel, the stream's headers for the live stream. */
|
|
58
73
|
export declare class RoomProbe {
|
|
@@ -89,3 +104,30 @@ export interface RoomContext {
|
|
|
89
104
|
}
|
|
90
105
|
/** Answers /api/v1/guide/rooms; false when the path is not one. */
|
|
91
106
|
export declare function handleRooms(request: IncomingMessage, response: ServerResponse, path: string, ctx: RoomContext): Promise<boolean>;
|
|
107
|
+
export interface RoomProxyContext {
|
|
108
|
+
store: RoomStore;
|
|
109
|
+
probe: RoomProbe;
|
|
110
|
+
/** Why an address may not be fetched from this machine, or "" (as the probe's). */
|
|
111
|
+
refuse: (link: string) => Promise<string>;
|
|
112
|
+
json: (response: ServerResponse, status: number, body: unknown) => void;
|
|
113
|
+
fetcher?: typeof fetch;
|
|
114
|
+
}
|
|
115
|
+
/** What /room/<name>/state answers: the room as its page shows it, no address or key. */
|
|
116
|
+
export interface RoomView {
|
|
117
|
+
name: string;
|
|
118
|
+
kind: "audio" | "video";
|
|
119
|
+
live: boolean;
|
|
120
|
+
folder: boolean;
|
|
121
|
+
hls: boolean;
|
|
122
|
+
title: string;
|
|
123
|
+
artist: string;
|
|
124
|
+
album: string;
|
|
125
|
+
}
|
|
126
|
+
/** The room's origin and key, gone from a text answer (a playlist, an error). */
|
|
127
|
+
export declare function roomRewriter(play: string, slug: string): (text: string) => string;
|
|
128
|
+
/**
|
|
129
|
+
* Answers /room/<name>/state, /stream and /hls/<file>, asked of the room's
|
|
130
|
+
* server with its key; false when the path is not one. The page itself is
|
|
131
|
+
* the caller's (it is the built watch page).
|
|
132
|
+
*/
|
|
133
|
+
export declare function handleRoomProxy(request: IncomingMessage, response: ServerResponse, path: string, ctx: RoomProxyContext): Promise<boolean>;
|
package/dist/guide-rooms.js
CHANGED
|
@@ -5,16 +5,20 @@
|
|
|
5
5
|
* a Share or a "?play=" link carries, `https://host:port/api/channels/<id>?k=KEY`
|
|
6
6
|
* or `https://host:port/api/live?k=KEY`. A nixamp.com member publishes one with
|
|
7
7
|
* a rights basis, as a station is published; the guide lists it beside the
|
|
8
|
-
* stations
|
|
9
|
-
*
|
|
8
|
+
* stations; its card opens /room/<name>, which plays it as a film or as
|
|
9
|
+
* radio, whichever it carries.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* /room/<name> is the room under a readable name on this server, which asks
|
|
12
|
+
* the room's own server for it with the key held here: the page, its state,
|
|
13
|
+
* the stream and HLS all come from this origin, so no page names the machine
|
|
14
|
+
* the room is on or carries its key. The old /watch?play=<address> links
|
|
15
|
+
* still play, straight from the room's server.
|
|
13
16
|
*/
|
|
14
17
|
import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
15
18
|
import { join } from "node:path";
|
|
16
19
|
import { randomBytes } from "node:crypto";
|
|
17
20
|
import { readConfig } from "./library.js";
|
|
21
|
+
import { slugify } from "./radio-slug.js";
|
|
18
22
|
/** Why a room may be broadcast, the same five reasons a station gives. */
|
|
19
23
|
export const ROOM_BASES = ["owned", "permission", "licensed", "creative-commons", "public-domain"];
|
|
20
24
|
/** Rooms one member may have on the guide at once, and rooms on it in all. */
|
|
@@ -62,6 +66,36 @@ export function watchBase(fallback, env = process.env) {
|
|
|
62
66
|
return /^https?:\/\/[^\s"<>]+$/.test(chosen) ? chosen : fallback;
|
|
63
67
|
}
|
|
64
68
|
export const watchPage = (base, play) => `${base}/watch?play=${encodeURIComponent(play)}`;
|
|
69
|
+
/** A room's page under its name, on this server. */
|
|
70
|
+
export const roomPage = (base, slug) => `${base}/room/${slug}`;
|
|
71
|
+
export const ROOM_PAGE = /^\/room\/([A-Za-z0-9_-]{1,60})\/?$/;
|
|
72
|
+
export const ROOM_SUB = /^\/room\/([a-z0-9-]{1,60})\/(state|stream|hls\/[A-Za-z0-9._-]{1,64})$/;
|
|
73
|
+
export function isRoomPath(path) {
|
|
74
|
+
return ROOM_PAGE.test(path) || ROOM_SUB.test(path);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The rooms by name: "Dropkick Murphys" is /room/dropkick-murphys. Oldest
|
|
78
|
+
* first, so a name stays with the room that had it; a second one is -2.
|
|
79
|
+
*/
|
|
80
|
+
export function roomSlugs(rooms) {
|
|
81
|
+
const out = new Map();
|
|
82
|
+
const sorted = [...rooms].sort((a, b) => (a.publishedAt - b.publishedAt) || a.id.localeCompare(b.id));
|
|
83
|
+
for (const room of sorted) {
|
|
84
|
+
const base = slugify(room.name) || "room";
|
|
85
|
+
let slug = base;
|
|
86
|
+
for (let n = 2; out.has(slug); n += 1)
|
|
87
|
+
slug = `${base}-${n}`;
|
|
88
|
+
out.set(slug, room);
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
/** A room's name here, or "". */
|
|
93
|
+
export function roomSlugOf(slugs, id) {
|
|
94
|
+
for (const [slug, one] of slugs)
|
|
95
|
+
if (one.id === id)
|
|
96
|
+
return slug;
|
|
97
|
+
return "";
|
|
98
|
+
}
|
|
65
99
|
export class RoomStore {
|
|
66
100
|
file;
|
|
67
101
|
rooms = [];
|
|
@@ -149,6 +183,8 @@ export class RoomProbe {
|
|
|
149
183
|
name: String(row.name ?? address.channel).replace(/\/+$/, "").slice(0, 120),
|
|
150
184
|
kind: row.kind === "video" ? "video" : "audio",
|
|
151
185
|
nowPlaying: tags && (tags.title || tags.artist) ? { title: String(tags.title ?? ""), artist: String(tags.artist ?? "") } : null,
|
|
186
|
+
folder: address.channel.startsWith("folder-") || (Array.isArray(row.playlist) && row.playlist.length > 1),
|
|
187
|
+
album: String(tags?.album ?? "").slice(0, 200),
|
|
152
188
|
};
|
|
153
189
|
}
|
|
154
190
|
// The live stream: on the air when it answers with media.
|
|
@@ -160,7 +196,7 @@ export class RoomProbe {
|
|
|
160
196
|
controller.abort();
|
|
161
197
|
if (!answer.ok)
|
|
162
198
|
return null;
|
|
163
|
-
return { live: true, name: "Live", kind: type.startsWith("video/") ? "video" : "audio", nowPlaying: null };
|
|
199
|
+
return { live: true, name: "Live", kind: type.startsWith("video/") ? "video" : "audio", nowPlaying: null, folder: false, album: "" };
|
|
164
200
|
}
|
|
165
201
|
finally {
|
|
166
202
|
clearTimeout(timer);
|
|
@@ -169,6 +205,7 @@ export class RoomProbe {
|
|
|
169
205
|
}
|
|
170
206
|
/** The rooms as guide cards: the page to open, on the air or not, and what is on. */
|
|
171
207
|
export async function roomEntries(store, probe, base) {
|
|
208
|
+
const slugs = roomSlugs(store.list());
|
|
172
209
|
return Promise.all(store.list().map(async (room) => {
|
|
173
210
|
const state = await probe.state(room.play);
|
|
174
211
|
return {
|
|
@@ -177,7 +214,7 @@ export async function roomEntries(store, probe, base) {
|
|
|
177
214
|
kind: state?.kind ?? room.kind,
|
|
178
215
|
group: "rooms",
|
|
179
216
|
state: state?.live ? "live" : "offline",
|
|
180
|
-
page:
|
|
217
|
+
page: roomPage(base, roomSlugOf(slugs, room.id) || room.id),
|
|
181
218
|
nowPlaying: state?.nowPlaying ?? null,
|
|
182
219
|
};
|
|
183
220
|
}));
|
|
@@ -245,7 +282,7 @@ export async function handleRooms(request, response, path, ctx) {
|
|
|
245
282
|
const title = typeof body?.title === "string" ? body.title.trim().slice(0, 120) : "";
|
|
246
283
|
const note = typeof body?.note === "string" ? body.note.trim().slice(0, 300) : "";
|
|
247
284
|
const room = ctx.store.put({ play: address.play, name: title || state.name, kind: state.kind, basis, note, by: taken?.by ?? by });
|
|
248
|
-
ctx.json(response, 200, { room: { id: room.id, name: room.name, kind: room.kind, basis: room.basis }, page:
|
|
285
|
+
ctx.json(response, 200, { room: { id: room.id, name: room.name, kind: room.kind, basis: room.basis }, page: roomPage(ctx.base, roomSlugOf(roomSlugs(ctx.store.list()), room.id) || room.id) });
|
|
249
286
|
return true;
|
|
250
287
|
}
|
|
251
288
|
const one = /^\/api\/v1\/guide\/rooms\/([A-Za-z0-9_-]{1,32})$/.exec(path);
|
|
@@ -262,3 +299,116 @@ export async function handleRooms(request, response, path, ctx) {
|
|
|
262
299
|
ctx.json(response, 405, { error: "method not allowed", code: "method_not_allowed" });
|
|
263
300
|
return true;
|
|
264
301
|
}
|
|
302
|
+
/** The room's origin and key, gone from a text answer (a playlist, an error). */
|
|
303
|
+
export function roomRewriter(play, slug) {
|
|
304
|
+
const address = roomAddress(play);
|
|
305
|
+
if (!address)
|
|
306
|
+
return (text) => text;
|
|
307
|
+
const channelPath = address.channel !== null ? `/api/channels/${address.channel}` : "/api/live";
|
|
308
|
+
return (text) => text
|
|
309
|
+
.split(`${address.origin}${channelPath}/hls/`).join(`/room/${slug}/hls/`)
|
|
310
|
+
.split(`${address.origin}${channelPath}`).join(`/room/${slug}/stream`)
|
|
311
|
+
.split(address.origin).join("")
|
|
312
|
+
.split(`?k=${address.key}&`).join("?").split(`&k=${address.key}`).join("").split(`?k=${address.key}`).join("")
|
|
313
|
+
.split(address.key).join("");
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Answers /room/<name>/state, /stream and /hls/<file>, asked of the room's
|
|
317
|
+
* server with its key; false when the path is not one. The page itself is
|
|
318
|
+
* the caller's (it is the built watch page).
|
|
319
|
+
*/
|
|
320
|
+
export async function handleRoomProxy(request, response, path, ctx) {
|
|
321
|
+
const sub = ROOM_SUB.exec(path);
|
|
322
|
+
if (!sub)
|
|
323
|
+
return false;
|
|
324
|
+
const method = request.method ?? "GET";
|
|
325
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
326
|
+
ctx.json(response, 405, { error: "a room's page can watch, not drive", code: "method_not_allowed" });
|
|
327
|
+
return true;
|
|
328
|
+
}
|
|
329
|
+
const slug = sub[1];
|
|
330
|
+
const room = roomSlugs(ctx.store.list()).get(slug);
|
|
331
|
+
const address = room ? roomAddress(room.play) : null;
|
|
332
|
+
if (!room || !address) {
|
|
333
|
+
ctx.json(response, 404, { error: "no room by that name", code: "not_found" });
|
|
334
|
+
return true;
|
|
335
|
+
}
|
|
336
|
+
const what = sub[2];
|
|
337
|
+
if (what === "state") {
|
|
338
|
+
const state = await ctx.probe.state(room.play);
|
|
339
|
+
if (!state) {
|
|
340
|
+
ctx.json(response, 404, { error: "that room is not on the air", code: "unavailable" });
|
|
341
|
+
return true;
|
|
342
|
+
}
|
|
343
|
+
const view = {
|
|
344
|
+
name: room.name || state.name,
|
|
345
|
+
kind: state.kind,
|
|
346
|
+
live: state.live,
|
|
347
|
+
folder: state.folder === true,
|
|
348
|
+
hls: address.channel !== null,
|
|
349
|
+
title: state.nowPlaying?.title ?? "",
|
|
350
|
+
artist: state.nowPlaying?.artist ?? "",
|
|
351
|
+
album: state.album ?? "",
|
|
352
|
+
};
|
|
353
|
+
response.setHeader("cache-control", "no-store");
|
|
354
|
+
ctx.json(response, 200, view);
|
|
355
|
+
return true;
|
|
356
|
+
}
|
|
357
|
+
let upstream;
|
|
358
|
+
if (what === "stream")
|
|
359
|
+
upstream = address.play;
|
|
360
|
+
else {
|
|
361
|
+
if (address.channel === null) {
|
|
362
|
+
ctx.json(response, 404, { error: "the live stream has no HLS", code: "not_found" });
|
|
363
|
+
return true;
|
|
364
|
+
}
|
|
365
|
+
upstream = `${address.origin}/api/channels/${address.channel}/${what}?k=${address.key}`;
|
|
366
|
+
}
|
|
367
|
+
if ((await ctx.refuse(upstream).catch(() => "refused")) !== "") {
|
|
368
|
+
ctx.json(response, 502, { error: "that room's server cannot be reached from here", code: "upstream" });
|
|
369
|
+
return true;
|
|
370
|
+
}
|
|
371
|
+
const stop = new AbortController();
|
|
372
|
+
response.on("close", () => stop.abort());
|
|
373
|
+
const answer = await (ctx.fetcher ?? fetch)(upstream, {
|
|
374
|
+
method, signal: stop.signal, redirect: "error",
|
|
375
|
+
headers: { ...(request.headers.range ? { range: String(request.headers.range) } : {}), accept: String(request.headers.accept ?? "*/*") },
|
|
376
|
+
}).catch(() => null);
|
|
377
|
+
if (!answer) {
|
|
378
|
+
ctx.json(response, 502, { error: "that room's server did not answer", code: "upstream" });
|
|
379
|
+
return true;
|
|
380
|
+
}
|
|
381
|
+
const type = answer.headers.get("content-type") ?? "";
|
|
382
|
+
const text = /json|mpegurl|^text\//i.test(type);
|
|
383
|
+
const headers = { "cache-control": "no-store", "x-content-type-options": "nosniff" };
|
|
384
|
+
for (const name of ["content-type", "content-length", "content-range", "accept-ranges", "x-accel-buffering", "retry-after"]) {
|
|
385
|
+
const value = answer.headers.get(name);
|
|
386
|
+
if (value && !(text && name === "content-length"))
|
|
387
|
+
headers[name] = value;
|
|
388
|
+
}
|
|
389
|
+
if (text) {
|
|
390
|
+
// A playlist (or an error) is small: read whole, the room's address and key taken out.
|
|
391
|
+
const body = roomRewriter(room.play, slug)(await answer.text().catch(() => ""));
|
|
392
|
+
response.writeHead(answer.status, { ...headers, "content-length": String(Buffer.byteLength(body)) });
|
|
393
|
+
response.end(method === "HEAD" ? undefined : body);
|
|
394
|
+
return true;
|
|
395
|
+
}
|
|
396
|
+
response.writeHead(answer.status, headers);
|
|
397
|
+
if (!answer.body || method === "HEAD") {
|
|
398
|
+
response.end();
|
|
399
|
+
return true;
|
|
400
|
+
}
|
|
401
|
+
try {
|
|
402
|
+
for await (const chunk of answer.body) {
|
|
403
|
+
if (response.destroyed)
|
|
404
|
+
break;
|
|
405
|
+
if (!response.write(chunk))
|
|
406
|
+
await new Promise((done) => { response.once("drain", done); response.once("close", done); });
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
catch {
|
|
410
|
+
// The viewer left, or the room's server did.
|
|
411
|
+
}
|
|
412
|
+
response.end();
|
|
413
|
+
return true;
|
|
414
|
+
}
|
package/dist/radio-pages.d.ts
CHANGED
|
@@ -188,9 +188,9 @@ export interface StationFace {
|
|
|
188
188
|
description?: string;
|
|
189
189
|
video?: boolean;
|
|
190
190
|
}
|
|
191
|
-
export declare function stationHead(station: StationFace, slug: string, base: string): string;
|
|
191
|
+
export declare function stationHead(station: StationFace, slug: string, base: string, path?: string): string;
|
|
192
192
|
/** The built station page (or the inline one) as /radio/<name> serves it: its head, the header, indexable. */
|
|
193
|
-
export declare function asRadioPage(html: string, station: StationFace, slug: string, base: string, nav: string): string;
|
|
193
|
+
export declare function asRadioPage(html: string, station: StationFace, slug: string, base: string, nav: string, path?: string): string;
|
|
194
194
|
/** A station another server lists on this guide (a guide peer), as /radio reaches it. */
|
|
195
195
|
export interface PeerRadio {
|
|
196
196
|
origin: string;
|
package/dist/radio-pages.js
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
import { createHash } from "node:crypto";
|
|
22
22
|
import { albumSlug, artistSlug, stationSlug } from "./radio-slug.js";
|
|
23
23
|
/** A station's own paths, as the station link opens them. */
|
|
24
|
-
export const RADIO_SUB = /^\/radio\/([a-z0-9-]{1,60})\/(stream|state|listen\.m3u|events|now|art|lyrics)$/;
|
|
24
|
+
export const RADIO_SUB = /^\/radio\/([a-z0-9-]{1,60})\/(stream|state|listen\.m3u|events|now|art|lyrics|clock)$/;
|
|
25
25
|
export const RADIO_STATION = /^\/radio\/([a-z0-9-]{1,60})\/?$/;
|
|
26
26
|
export const RADIO_ARTIST = /^\/radio\/artist\/([a-z0-9-]{1,90})\/?$/;
|
|
27
27
|
export const RADIO_ALBUM = /^\/radio\/album\/([a-z0-9-]{1,100})(\/cover\.jpg)?\/?$/;
|
|
@@ -388,8 +388,8 @@ export function renderAlbum(catalogue, album, ctx) {
|
|
|
388
388
|
export function renderMissing(what, base, nav) {
|
|
389
389
|
return document({ title: `Not here · live.streaming`, description: "Nothing by that name is on the air here.", url: `${base}/radio` }, nav, `<h1>No ${escape(what)} by that name</h1><p class="lead">Nothing by that name is on the air here now. <a href="/radio">See every station</a>.</p>`);
|
|
390
390
|
}
|
|
391
|
-
export function stationHead(station, slug, base) {
|
|
392
|
-
const url = `${base}
|
|
391
|
+
export function stationHead(station, slug, base, path = `/radio/${slug}`) {
|
|
392
|
+
const url = `${base}${path}`;
|
|
393
393
|
const description = station.description || `${station.name}, live now. Free to listen; tap the artist or album on the air to see more.`;
|
|
394
394
|
return `<title>${escape(station.name)} · live.streaming</title>
|
|
395
395
|
<meta name="description" content="${escape(description)}" />
|
|
@@ -403,9 +403,9 @@ export function stationHead(station, slug, base) {
|
|
|
403
403
|
<style>${NAV_STYLE}</style>`;
|
|
404
404
|
}
|
|
405
405
|
/** The built station page (or the inline one) as /radio/<name> serves it: its head, the header, indexable. */
|
|
406
|
-
export function asRadioPage(html, station, slug, base, nav) {
|
|
406
|
+
export function asRadioPage(html, station, slug, base, nav, path) {
|
|
407
407
|
return html
|
|
408
|
-
.replace(/<title>[^<]*<\/title>/, stationHead(station, slug, base))
|
|
408
|
+
.replace(/<title>[^<]*<\/title>/, stationHead(station, slug, base, path))
|
|
409
409
|
.replace(/\s*<meta name="robots" content="noindex"\s*\/?>/, "")
|
|
410
410
|
.replace(/<body>/, `<body>\n${nav}`);
|
|
411
411
|
}
|
package/dist/server.js
CHANGED
|
@@ -73,7 +73,7 @@ import { LyricsFinder } from "./lyrics.js";
|
|
|
73
73
|
import { LyricsPublisher } from "./lyrics-publish.js";
|
|
74
74
|
import { aiKey } from "./ai-keys.js";
|
|
75
75
|
import { STATION_APP_CSP, STATION_PAGE, STATION_PAGE_CSP, STATION_PAGE_HTML, WATCH_APP_CSP } from "./station-page.js";
|
|
76
|
-
import { handleRooms, RoomProbe, roomEntries, RoomStore, watchBase } from "./guide-rooms.js";
|
|
76
|
+
import { handleRoomProxy, handleRooms, isRoomPath, ROOM_PAGE, roomAddress, RoomProbe, roomEntries, roomSlugOf, roomSlugs, RoomStore, watchBase } from "./guide-rooms.js";
|
|
77
77
|
import { handleVods, VodProbe, vodEntries, VodStore, vodView } from "./guide-vods.js";
|
|
78
78
|
import { withPitFooter } from "./pit-footer.js";
|
|
79
79
|
import { handleFollowPage } from "./follow-page.js";
|
|
@@ -2317,6 +2317,8 @@ export function createHandler(engine, options) {
|
|
|
2317
2317
|
!/^\/watch\/[a-z0-9]{10}$/.test(path) &&
|
|
2318
2318
|
// A live room's page: the room's own key travels in ?play=, checked by its server.
|
|
2319
2319
|
!(path === "/watch" && Boolean(options.stations?.rooms)) &&
|
|
2320
|
+
// A live room under its name here, asked of its server with the key held here (guide-rooms.ts).
|
|
2321
|
+
!(isRoomPath(path) && Boolean(options.stations?.rooms)) &&
|
|
2320
2322
|
// Live rooms on the guide: listed for anybody, published by a member (guide-rooms.ts).
|
|
2321
2323
|
!((path === "/api/v1/guide/rooms" || path.startsWith("/api/v1/guide/rooms/")) && Boolean(options.stations?.rooms)) &&
|
|
2322
2324
|
// On demand: a file's page and the guide's list of them (guide-vods.ts); the file's own key travels in its play address.
|
|
@@ -2546,7 +2548,56 @@ export function createHandler(engine, options) {
|
|
|
2546
2548
|
// A live room's page: /watch?play=<a nixamp server's channel or live
|
|
2547
2549
|
// stream address>. The built page plays it in @profullstack/player, as a
|
|
2548
2550
|
// film or as radio; nothing about the room passes through here.
|
|
2551
|
+
if (options.stations?.rooms && isRoomPath(path)) {
|
|
2552
|
+
const rooms = options.stations.rooms;
|
|
2553
|
+
if (await handleRoomProxy(request, response, path, { store: rooms.store, probe: rooms.probe, refuse: (link) => refusedDestination(link), json }))
|
|
2554
|
+
return;
|
|
2555
|
+
const asked = ROOM_PAGE.exec(path)[1];
|
|
2556
|
+
const slugs = roomSlugs(rooms.store.list());
|
|
2557
|
+
const room = slugs.get(asked);
|
|
2558
|
+
if (!room) {
|
|
2559
|
+
// A room's id finds its name: /room/<id> goes to /room/<name>.
|
|
2560
|
+
const named = roomSlugOf(slugs, asked);
|
|
2561
|
+
if (named) {
|
|
2562
|
+
response.writeHead(301, { location: `/room/${named}`, "cache-control": "public, max-age=300" });
|
|
2563
|
+
response.end();
|
|
2564
|
+
return;
|
|
2565
|
+
}
|
|
2566
|
+
}
|
|
2567
|
+
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
2568
|
+
json(response, 405, { error: "method not allowed", code: "method_not_allowed" });
|
|
2569
|
+
return;
|
|
2570
|
+
}
|
|
2571
|
+
const built = web !== null && isFile(join(web, "watch.html")) ? readFileSync(join(web, "watch.html"), "utf8") : null;
|
|
2572
|
+
if (!built) {
|
|
2573
|
+
json(response, 404, { error: "this server was started without its web pages" });
|
|
2574
|
+
return;
|
|
2575
|
+
}
|
|
2576
|
+
const base = radioBaseFor(request, options.stations.publicBase?.() ?? "");
|
|
2577
|
+
const nav = radioNav(guideHosts().has(requestHost) ? "/" : "/guide", "live");
|
|
2578
|
+
const face = room ? { name: room.name, description: room.note || `${room.name}, live now on live.streaming.`, video: room.kind === "video" } : { name: "No such room" };
|
|
2579
|
+
response.writeHead(room ? 200 : 404, {
|
|
2580
|
+
"content-type": "text/html; charset=utf-8",
|
|
2581
|
+
"cache-control": "no-cache",
|
|
2582
|
+
"content-security-policy": adCsp(WATCH_APP_CSP, guideAds()),
|
|
2583
|
+
"referrer-policy": "strict-origin-when-cross-origin",
|
|
2584
|
+
"x-content-type-options": "nosniff",
|
|
2585
|
+
});
|
|
2586
|
+
response.end(request.method === "HEAD" ? undefined : withPageAd(asRadioPage(built, face, asked, base, nav, `/room/${asked}`), guideAds()));
|
|
2587
|
+
return;
|
|
2588
|
+
}
|
|
2549
2589
|
if (options.stations?.rooms && path === "/watch" && (request.method === "GET" || request.method === "HEAD")) {
|
|
2590
|
+
// A room published here plays under its name: the old link keeps working and lands there.
|
|
2591
|
+
const play = roomAddress(url.searchParams.get("play") ?? "");
|
|
2592
|
+
if (play) {
|
|
2593
|
+
const slugs = roomSlugs(options.stations.rooms.store.list());
|
|
2594
|
+
const published = [...slugs].find(([, one]) => one.play === play.play)?.[0];
|
|
2595
|
+
if (published) {
|
|
2596
|
+
response.writeHead(302, { location: `/room/${published}`, "cache-control": "no-store", "referrer-policy": "no-referrer" });
|
|
2597
|
+
response.end();
|
|
2598
|
+
return;
|
|
2599
|
+
}
|
|
2600
|
+
}
|
|
2550
2601
|
const built = web !== null && isFile(join(web, "watch.html")) ? withPageAd(readFileSync(join(web, "watch.html"), "utf8"), guideAds()) : null;
|
|
2551
2602
|
if (!built) {
|
|
2552
2603
|
json(response, 404, { error: "this server was started without its web pages" });
|
package/dist/station-routes.d.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* reading English.
|
|
14
14
|
*/
|
|
15
15
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
16
|
-
import type
|
|
16
|
+
import { type Channels } from "./channels.ts";
|
|
17
17
|
import type { StationRunner } from "./station-runner.ts";
|
|
18
18
|
import { type Limits, type StationUsage } from "./station-metering.ts";
|
|
19
19
|
import { type TrackFacts } from "./track-facts.ts";
|
|
@@ -98,6 +98,14 @@ declare function links(ctx: StationContext, station: Station, base?: string): {
|
|
|
98
98
|
m3u: string;
|
|
99
99
|
state: string;
|
|
100
100
|
};
|
|
101
|
+
/** Records a connection's bytes onto its channel's, through the listener it writes to. */
|
|
102
|
+
export declare function clockedListener<T extends {
|
|
103
|
+
write(chunk: Buffer): boolean;
|
|
104
|
+
}>(key: string, out: T): T & {
|
|
105
|
+
forget(): void;
|
|
106
|
+
};
|
|
107
|
+
/** Which byte of its channel a connection's player is on, `seconds` into what it was sent; null when unknown. */
|
|
108
|
+
export declare function heardByte(key: string, seconds: number, kbps: number): number | null;
|
|
101
109
|
export declare function publicView(ctx: StationContext, station: Station): PublicStation;
|
|
102
110
|
/** Where the guide puts a station: composed by AI, shared by listeners, or the owner's radio or TV. */
|
|
103
111
|
export type GuideGroup = "ai" | "shared" | "radio" | "tv";
|
package/dist/station-routes.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { chunkStart } from "./channels.js";
|
|
1
2
|
import { alertsFor } from "./station-metering.js";
|
|
2
3
|
import { aiFromTags } from "./track-facts.js";
|
|
3
4
|
import { quietDuringBreaks } from "./break-silence.js";
|
|
@@ -6,7 +7,7 @@ import { watchBase } from "./guide-rooms.js";
|
|
|
6
7
|
import { radioPeers, radioStations } from "./radio-pages.js";
|
|
7
8
|
import { StationError, channelOf, tokensMatch, } from "./stations.js";
|
|
8
9
|
/** The three things a station's link opens. */
|
|
9
|
-
const LISTEN = /^\/api\/v1\/stations\/([a-z0-9]{10})\/(stream|state|listen\.m3u|events|now|art|lyrics)$/;
|
|
10
|
+
const LISTEN = /^\/api\/v1\/stations\/([a-z0-9]{10})\/(stream|state|listen\.m3u|events|now|art|lyrics|clock)$/;
|
|
10
11
|
/** Whether this path is one a station's own link may open, without the server's key. */
|
|
11
12
|
export function isStationListenPath(path) {
|
|
12
13
|
return LISTEN.test(path) || path === "/api/v1/stations/guide";
|
|
@@ -28,6 +29,41 @@ function links(ctx, station, base = ctx.base) {
|
|
|
28
29
|
state: `${at}/state?${query}`,
|
|
29
30
|
};
|
|
30
31
|
}
|
|
32
|
+
const clocks = new Map();
|
|
33
|
+
const MAX_CLOCKS = 5000;
|
|
34
|
+
const MAX_RUNS = 500;
|
|
35
|
+
/** Records a connection's bytes onto its channel's, through the listener it writes to. */
|
|
36
|
+
export function clockedListener(key, out) {
|
|
37
|
+
const clock = { runs: [], local: 0, next: -1 };
|
|
38
|
+
if (clocks.size >= MAX_CLOCKS)
|
|
39
|
+
clocks.delete(clocks.keys().next().value);
|
|
40
|
+
clocks.set(key, clock);
|
|
41
|
+
return Object.assign(Object.create(out), {
|
|
42
|
+
write: (chunk) => {
|
|
43
|
+
const at = chunkStart(chunk);
|
|
44
|
+
// Silence written in a break's place carries no start: it stands for the break's bytes.
|
|
45
|
+
if (at !== undefined && at !== clock.next) {
|
|
46
|
+
clock.runs.push({ local: clock.local, global: at });
|
|
47
|
+
if (clock.runs.length > MAX_RUNS)
|
|
48
|
+
clock.runs.shift();
|
|
49
|
+
}
|
|
50
|
+
clock.local += chunk.length;
|
|
51
|
+
clock.next = (at ?? clock.next) + chunk.length;
|
|
52
|
+
return out.write(chunk);
|
|
53
|
+
},
|
|
54
|
+
forget: () => { if (clocks.get(key) === clock)
|
|
55
|
+
clocks.delete(key); },
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
/** Which byte of its channel a connection's player is on, `seconds` into what it was sent; null when unknown. */
|
|
59
|
+
export function heardByte(key, seconds, kbps) {
|
|
60
|
+
const clock = clocks.get(key);
|
|
61
|
+
if (!clock || !(seconds >= 0) || kbps <= 0)
|
|
62
|
+
return null;
|
|
63
|
+
const local = Math.min(clock.local, (seconds * kbps * 1000) / 8);
|
|
64
|
+
const run = clock.runs.findLast((one) => one.local <= local);
|
|
65
|
+
return run ? run.global + (local - run.local) : null;
|
|
66
|
+
}
|
|
31
67
|
/** The file on the air now, as the channel's own encoder last reported it. */
|
|
32
68
|
function nowPlayingFile(ctx, station) {
|
|
33
69
|
const info = ctx.channels.info(channelOf(station.id));
|
|
@@ -247,6 +283,26 @@ export async function handleStations(request, response, path, url, ctx) {
|
|
|
247
283
|
ctx.json(response, 200, { lyrics: { title: lyrics.title, artist: lyrics.artist, text: lyrics.text, source: lyrics.source, language: lyrics.language, ...(lyrics.synced ? { synced: lyrics.synced } : {}) } });
|
|
248
284
|
return true;
|
|
249
285
|
}
|
|
286
|
+
if (what === "clock") {
|
|
287
|
+
// ?n= the stream's nonce, ?ct= the player's currentTime: the song and second being heard.
|
|
288
|
+
const key = `${station.id}:${url.searchParams.get("n") ?? ""}`;
|
|
289
|
+
const byte = heardByte(key, Number(url.searchParams.get("ct")), station.bitrate);
|
|
290
|
+
const place = byte === null ? null : ctx.channels.placeAt(channelOf(station.id), byte);
|
|
291
|
+
if (!place) {
|
|
292
|
+
ctx.json(response, 404, { error: "no clock for that stream", code: "not_found" });
|
|
293
|
+
return true;
|
|
294
|
+
}
|
|
295
|
+
const track = place.file ? (place.tags ?? ctx.trackFor(place.file)) : null;
|
|
296
|
+
const duration = place.file && ctx.durationOf ? ctx.durationOf(place.file) : 0;
|
|
297
|
+
ctx.json(response, 200, {
|
|
298
|
+
station: station.id,
|
|
299
|
+
break: place.file === "",
|
|
300
|
+
position: Math.round(place.position * 1000) / 1000,
|
|
301
|
+
duration: Math.round(duration * 10) / 10,
|
|
302
|
+
nowPlaying: track && (track.title || track.artist) ? { title: track.title, artist: track.artist, album: track.album } : null,
|
|
303
|
+
});
|
|
304
|
+
return true;
|
|
305
|
+
}
|
|
250
306
|
if (what === "now") {
|
|
251
307
|
const file = ctx.runner.observed(station.id) === "live" || ctx.runner.observed(station.id) === "recovering" ? nowPlayingFile(ctx, station) : "";
|
|
252
308
|
ctx.json(response, 200, { station: station.id, facts: file && ctx.facts ? await ctx.facts(station, file).catch(() => null) : null });
|
|
@@ -315,9 +371,12 @@ export async function handleStations(request, response, path, url, ctx) {
|
|
|
315
371
|
pending: () => response.writableLength,
|
|
316
372
|
}
|
|
317
373
|
: response;
|
|
374
|
+
const nonce = /^[A-Za-z0-9_-]{1,40}$/.test(url.searchParams.get("n") ?? "") ? url.searchParams.get("n") : "";
|
|
375
|
+
const clocked = nonce && !station.video ? clockedListener(`${station.id}:${nonce}`, metered) : null;
|
|
376
|
+
const out = clocked ?? metered;
|
|
318
377
|
const detach = spared
|
|
319
|
-
? ctx.channels.listenWithoutBreaks(channelId,
|
|
320
|
-
: ctx.channels.listen(channelId,
|
|
378
|
+
? ctx.channels.listenWithoutBreaks(channelId, out, station.bitrate, (quiet) => quietDuringBreaks(quiet, station.bitrate, () => true))
|
|
379
|
+
: ctx.channels.listen(channelId, out);
|
|
321
380
|
if (detach === null) {
|
|
322
381
|
response.end();
|
|
323
382
|
return true;
|
|
@@ -332,6 +391,7 @@ export async function handleStations(request, response, path, url, ctx) {
|
|
|
332
391
|
detach();
|
|
333
392
|
untrack();
|
|
334
393
|
left?.();
|
|
394
|
+
clocked?.forget();
|
|
335
395
|
};
|
|
336
396
|
request.on("close", leave);
|
|
337
397
|
response.on("close", leave);
|