nixamp 0.29.1 → 0.30.3
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 +12 -0
- package/dist/channels.js +12 -1
- package/dist/directory.d.ts +3 -1
- package/dist/directory.js +8 -2
- package/dist/main.d.ts +0 -8
- package/dist/main.js +9 -0
- package/dist/mcp.d.ts +3 -0
- package/dist/mcp.js +7 -1
- package/dist/playlist-input.d.ts +10 -0
- package/dist/playlist-input.js +9 -0
- package/dist/server.d.ts +27 -1
- package/dist/server.js +162 -6
- package/dist/station-cli.d.ts +3 -0
- package/dist/station-cli.js +201 -0
- package/dist/station-mcp.d.ts +294 -0
- package/dist/station-mcp.js +173 -0
- package/dist/station-page.d.ts +5 -0
- package/dist/station-page.js +138 -0
- package/dist/station-routes.d.ts +60 -0
- package/dist/station-routes.js +250 -0
- package/dist/station-runner.d.ts +67 -0
- package/dist/station-runner.js +219 -0
- package/dist/station-usage.d.ts +2 -0
- package/dist/station-usage.js +20 -0
- package/dist/stations.d.ts +148 -0
- package/dist/stations.js +331 -0
- package/package.json +1 -1
- package/src/channels.ts +14 -1
- package/src/directory.ts +10 -2
- package/src/main.ts +8 -0
- package/src/mcp.ts +9 -1
- package/src/playlist-input.ts +9 -0
- package/src/server.ts +169 -7
- package/src/station-cli.ts +195 -0
- package/src/station-mcp.ts +183 -0
- package/src/station-page.ts +145 -0
- package/src/station-routes.ts +302 -0
- package/src/station-runner.ts +244 -0
- package/src/station-usage.ts +20 -0
- package/src/stations.ts +407 -0
- package/web/dist/assets/{ar-D6_ZFDan.js → ar-ClCIWtVb.js} +1 -1
- package/web/dist/assets/{cs-DEPGOGEV.js → cs-CVG4U823.js} +1 -1
- package/web/dist/assets/{da-CQw7SPC9.js → da-igdiQUk8.js} +1 -1
- package/web/dist/assets/{de-OWnobbah.js → de-CHPsc6lc.js} +1 -1
- package/web/dist/assets/{es-qMpyH9bM.js → es-AvbMspQX.js} +1 -1
- package/web/dist/assets/{fi-DpqqwJTD.js → fi-5JGUOm3W.js} +1 -1
- package/web/dist/assets/{fr-BiO_ceen.js → fr-YfMwk1FR.js} +1 -1
- package/web/dist/assets/{hi-D-q5wZuY.js → hi-BUZuQTkp.js} +1 -1
- package/web/dist/assets/{hls-3VKVEQE3-D2W4K8VR.js → hls-3VKVEQE3-BCXawNaW.js} +1 -1
- package/web/dist/assets/{hu-FIjHyhVC.js → hu-C7on-yUl.js} +1 -1
- package/web/dist/assets/{id-gmB6lu56.js → id-pIrzVeOi.js} +1 -1
- package/web/dist/assets/{index-UmGvzuun.js → index-CDBIr8Tz.js} +2 -2
- package/web/dist/assets/{it-Dxfo713A.js → it-Bl9xk5B4.js} +1 -1
- package/web/dist/assets/{mpegts-BT4XGr_E.js → mpegts-63ylMmge.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-CpZnlq0W.js → mpegts-LO6RVLD6-DBakOXU5.js} +1 -1
- package/web/dist/assets/{nl-mk5eN3Io.js → nl-b5BUih3i.js} +1 -1
- package/web/dist/assets/{ru-B2psU0kN.js → ru-BlAkbEPp.js} +1 -1
- package/web/dist/assets/{sv-Cg6waoNZ.js → sv-BEKvqELG.js} +1 -1
- package/web/dist/assets/{uk-BcFkukLd.js → uk-JY8Dh56p.js} +1 -1
- package/web/dist/assets/{vi-CnqIC1Sz.js → vi-DbtxuQPL.js} +1 -1
- package/web/dist/assets/{zh-CvnXIFvN.js → zh-4x0DI5jO.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/sw.js +22 -22
package/dist/channels.d.ts
CHANGED
|
@@ -232,6 +232,12 @@ export declare class Channel {
|
|
|
232
232
|
private playlistInput;
|
|
233
233
|
/** On to the next entry of a list of files. False when this is not one. */
|
|
234
234
|
skip(): boolean;
|
|
235
|
+
/** An entry's tags, as read when it was put on. */
|
|
236
|
+
tagsOf(file: string): {
|
|
237
|
+
title: string;
|
|
238
|
+
artist: string;
|
|
239
|
+
album: string;
|
|
240
|
+
} | undefined;
|
|
235
241
|
private failures;
|
|
236
242
|
private timer;
|
|
237
243
|
/** Fires when a pulled source has said nothing for STALL. */
|
|
@@ -497,6 +503,12 @@ export declare class Channels {
|
|
|
497
503
|
setThrough(provider: ThroughProvider | null): void;
|
|
498
504
|
/** The kind of a channel, for a relay to say what it is carrying. */
|
|
499
505
|
kindOf(id: string): "audio" | "video" | undefined;
|
|
506
|
+
/** An entry's tags on a channel playing a list of files. */
|
|
507
|
+
tagsOf(id: string, file: string): {
|
|
508
|
+
title: string;
|
|
509
|
+
artist: string;
|
|
510
|
+
album: string;
|
|
511
|
+
} | undefined;
|
|
500
512
|
/** The next entry, now, on a channel playing a list of files. */
|
|
501
513
|
skip(id: string): boolean;
|
|
502
514
|
stop(id: string): boolean;
|
package/dist/channels.js
CHANGED
|
@@ -141,6 +141,10 @@ export class Channel {
|
|
|
141
141
|
skip() {
|
|
142
142
|
return this.playlistInput?.skip() ?? false;
|
|
143
143
|
}
|
|
144
|
+
/** An entry's tags, as read when it was put on. */
|
|
145
|
+
tagsOf(file) {
|
|
146
|
+
return this.playlistInput?.tags.get(file);
|
|
147
|
+
}
|
|
144
148
|
failures = 0;
|
|
145
149
|
timer = null;
|
|
146
150
|
/** Fires when a pulled source has said nothing for STALL. */
|
|
@@ -1168,6 +1172,10 @@ export class Channels {
|
|
|
1168
1172
|
kindOf(id) {
|
|
1169
1173
|
return this.open.get(id)?.info.kind;
|
|
1170
1174
|
}
|
|
1175
|
+
/** An entry's tags on a channel playing a list of files. */
|
|
1176
|
+
tagsOf(id, file) {
|
|
1177
|
+
return this.open.get(id)?.tagsOf(file);
|
|
1178
|
+
}
|
|
1171
1179
|
/** The next entry, now, on a channel playing a list of files. */
|
|
1172
1180
|
skip(id) {
|
|
1173
1181
|
return this.open.get(id)?.skip() ?? false;
|
|
@@ -1252,7 +1260,10 @@ export function rememberedChannels(dir, port) {
|
|
|
1252
1260
|
*/
|
|
1253
1261
|
export function rememberedNow(channels) {
|
|
1254
1262
|
return channels.list()
|
|
1255
|
-
|
|
1263
|
+
// A station's channel is put back by its station, which knows whether it
|
|
1264
|
+
// is still wanted; remembering it here too would bring back one its
|
|
1265
|
+
// owner had stopped.
|
|
1266
|
+
.filter((one) => one.via === "pull" && one.source && !channels.isEphemeral(one.id) && !one.id.startsWith("station-"))
|
|
1256
1267
|
.map((one) => {
|
|
1257
1268
|
const kept = { id: one.id, name: one.name, source: one.source };
|
|
1258
1269
|
if (one.kind)
|
package/dist/directory.d.ts
CHANGED
|
@@ -146,6 +146,8 @@ export interface LineupEntry {
|
|
|
146
146
|
kind: "audio" | "video";
|
|
147
147
|
art: string;
|
|
148
148
|
about: string;
|
|
149
|
+
/** A station's own listening page, on the same server; only for a station its owner listed. */
|
|
150
|
+
listen?: string;
|
|
149
151
|
}
|
|
150
152
|
/** Trim and flatten, so one publisher cannot draw a box in someone's terminal. */
|
|
151
153
|
export declare function clean(value: unknown, max: number): string;
|
|
@@ -158,7 +160,7 @@ export declare function clean(value: unknown, max: number): string;
|
|
|
158
160
|
export declare function publishable(raw: string): URL | null;
|
|
159
161
|
export declare function parseAnnouncement(input: unknown): Announcement | null;
|
|
160
162
|
/** One lineup entry as a publisher sent it, cleaned, or null when it is not one. */
|
|
161
|
-
export declare function parseLineupEntry(input: unknown): LineupEntry | null;
|
|
163
|
+
export declare function parseLineupEntry(input: unknown, origin?: string): LineupEntry | null;
|
|
162
164
|
/**
|
|
163
165
|
* The registry. In memory on purpose: see the note at the top of the file.
|
|
164
166
|
* One entry per URL, so a publisher restarting does not leave a ghost of
|
package/dist/directory.js
CHANGED
|
@@ -95,12 +95,12 @@ export function parseAnnouncement(input) {
|
|
|
95
95
|
}
|
|
96
96
|
: {}),
|
|
97
97
|
...(Array.isArray(record["lineup"])
|
|
98
|
-
? { lineup: record["lineup"].map(parseLineupEntry).filter((one) => one !== null).slice(0, MAX_CHANNELS) }
|
|
98
|
+
? { lineup: record["lineup"].map((one) => parseLineupEntry(one, listen.origin)).filter((one) => one !== null).slice(0, MAX_CHANNELS) }
|
|
99
99
|
: {}),
|
|
100
100
|
};
|
|
101
101
|
}
|
|
102
102
|
/** One lineup entry as a publisher sent it, cleaned, or null when it is not one. */
|
|
103
|
-
export function parseLineupEntry(input) {
|
|
103
|
+
export function parseLineupEntry(input, origin = "") {
|
|
104
104
|
if (typeof input !== "object" || input === null)
|
|
105
105
|
return null;
|
|
106
106
|
const record = input;
|
|
@@ -120,6 +120,12 @@ export function parseLineupEntry(input) {
|
|
|
120
120
|
// page in a tag, and javascript: is a page in a tag that runs.
|
|
121
121
|
art: publishable(art) !== null ? art : "",
|
|
122
122
|
about: clean(record["about"], MAX_ABOUT),
|
|
123
|
+
// The same server as the listing, or nothing, like the audio address.
|
|
124
|
+
...(() => {
|
|
125
|
+
const offered = typeof record["listen"] === "string" ? record["listen"].slice(0, MAX_ART) : "";
|
|
126
|
+
const parsed = offered && origin ? publishable(offered) : null;
|
|
127
|
+
return parsed !== null && parsed.origin === origin ? { listen: offered } : {};
|
|
128
|
+
})(),
|
|
123
129
|
};
|
|
124
130
|
}
|
|
125
131
|
/**
|
package/dist/main.d.ts
CHANGED
|
@@ -1,11 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* nixamp — it really whips the terminal's ass.
|
|
3
|
-
*
|
|
4
|
-
* bunx nixamp ~/Music
|
|
5
|
-
* bunx nixamp track.flac
|
|
6
|
-
*
|
|
7
|
-
* ffmpeg decodes; we read every sample on its way to the speakers and draw it.
|
|
8
|
-
*/
|
|
9
1
|
import { type BrailleCanvas, type Container, type Theme } from "@profullstack/hqtui";
|
|
10
2
|
import { type Track } from "./audio.ts";
|
|
11
3
|
export declare const BAND_COUNT = 24;
|
package/dist/main.js
CHANGED
|
@@ -7,6 +7,7 @@ import { t as uiMessage, i18n, terminalUiLanguage, uiLanguage } from "./i18n.js"
|
|
|
7
7
|
*
|
|
8
8
|
* ffmpeg decodes; we read every sample on its way to the speakers and draw it.
|
|
9
9
|
*/
|
|
10
|
+
import { USAGE as STATION_USAGE } from "./station-usage.js";
|
|
10
11
|
import { createApp, themes } from "@profullstack/hqtui";
|
|
11
12
|
import { fileURLToPath } from "node:url";
|
|
12
13
|
import { resolve } from "node:path";
|
|
@@ -206,6 +207,7 @@ keeps serving its browser remote. One per user.
|
|
|
206
207
|
|
|
207
208
|
nixamp attach put the player back in front of it
|
|
208
209
|
nixamp admin who is connected, and re-stream to them
|
|
210
|
+
nixamp station make a folder into a station others can join (see: nixamp station help)
|
|
209
211
|
|
|
210
212
|
From inside the player, d hands the music to a daemon without stopping it.
|
|
211
213
|
`,
|
|
@@ -367,6 +369,8 @@ q or d leaves; neither stops anything. \`nixamp daemon stop\` is what stops it.
|
|
|
367
369
|
`,
|
|
368
370
|
};
|
|
369
371
|
export function helpFor(topic) {
|
|
372
|
+
if (topic === "station" || topic === "stations")
|
|
373
|
+
return STATION_USAGE;
|
|
370
374
|
return (topic ? TOPICS[topic] : undefined) ?? HELP;
|
|
371
375
|
}
|
|
372
376
|
/**
|
|
@@ -524,6 +528,11 @@ export async function main() {
|
|
|
524
528
|
process.exitCode = await runDaemon(rest);
|
|
525
529
|
return;
|
|
526
530
|
}
|
|
531
|
+
if (first === "station" || first === "stations") {
|
|
532
|
+
const { station } = await import("./station-cli.js");
|
|
533
|
+
process.exitCode = await station(rest);
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
527
536
|
if (first === "admin") {
|
|
528
537
|
const { admin } = await import("./admin.js");
|
|
529
538
|
await admin(rest);
|
package/dist/mcp.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { wavOf, type Window } from "./transcribe.ts";
|
|
2
2
|
import { type Described } from "./media-local.ts";
|
|
3
|
+
import { type StationToolOptions } from "./station-mcp.ts";
|
|
3
4
|
export declare const PROTOCOL_VERSION = "2025-06-18";
|
|
4
5
|
interface Request {
|
|
5
6
|
jsonrpc: "2.0";
|
|
@@ -15,6 +16,8 @@ interface ToolDefinition {
|
|
|
15
16
|
export declare const TOOLS: ToolDefinition[];
|
|
16
17
|
export interface McpOptions {
|
|
17
18
|
fetcher?: typeof fetch;
|
|
19
|
+
/** Where stations are asked; the tests hand in a local daemon. */
|
|
20
|
+
stations?: StationToolOptions;
|
|
18
21
|
/** Injected by the tests; the session on disk otherwise. */
|
|
19
22
|
session?: {
|
|
20
23
|
site: string;
|
package/dist/mcp.js
CHANGED
|
@@ -30,6 +30,7 @@ import { describeRecord } from "./hash.js";
|
|
|
30
30
|
import { describeFile, keepFile } from "./media-local.js";
|
|
31
31
|
import { fileFingerprint, idFrom, languageCode, mediaOfUrl, transcriptIdOf } from "./transcripts.js";
|
|
32
32
|
import { personaLines, readPersona, readVoices, writePersona } from "./profile.js";
|
|
33
|
+
import { STATION_TOOLS, callStationTool, isStationTool } from "./station-mcp.js";
|
|
33
34
|
export const PROTOCOL_VERSION = "2025-06-18";
|
|
34
35
|
const STRING = { type: "string" };
|
|
35
36
|
export const TOOLS = [
|
|
@@ -215,6 +216,7 @@ export const TOOLS = [
|
|
|
215
216
|
required: ["server"],
|
|
216
217
|
},
|
|
217
218
|
},
|
|
219
|
+
...STATION_TOOLS,
|
|
218
220
|
];
|
|
219
221
|
function said(line) {
|
|
220
222
|
return `${line.createdAt} ${line.handle}: ${line.body}`;
|
|
@@ -246,6 +248,10 @@ function describe(row) {
|
|
|
246
248
|
* pipe, and so the same call is reachable from anywhere else that wants it.
|
|
247
249
|
*/
|
|
248
250
|
export async function callTool(name, args, options = {}) {
|
|
251
|
+
// Stations first: on this machine they need the daemon, not a sign-in.
|
|
252
|
+
if (isStationTool(name)) {
|
|
253
|
+
return callStationTool(name, args, { ...(options.fetcher ? { fetcher: options.fetcher } : {}), ...(options.session !== undefined ? { session: options.session } : {}), ...(options.stations ?? {}) });
|
|
254
|
+
}
|
|
249
255
|
const session = options.session === undefined ? readSession() : options.session;
|
|
250
256
|
if (!session) {
|
|
251
257
|
return failed("This machine is not signed in to nixamp. Run `nixamp login`, or set NIXAMP_TOKEN.");
|
|
@@ -551,7 +557,7 @@ export async function handleMessage(message, options = {}) {
|
|
|
551
557
|
return reply({
|
|
552
558
|
protocolVersion: PROTOCOL_VERSION,
|
|
553
559
|
capabilities: { tools: { listChanged: false } },
|
|
554
|
-
serverInfo: { name: "nixamp", title: "nixamp: watch parties, rooms and transcripts", version: "2" },
|
|
560
|
+
serverInfo: { name: "nixamp", title: "nixamp: stations, watch parties, rooms and transcripts", version: "2" },
|
|
555
561
|
instructions: "Watch parties on nixamp: a party lives on the site hosting the film and is bridged here as a room every nixamp client can join; codes are the ones that site shows, positions are seconds into the film. Rooms: say and read trollbox lines, hear a recording. Transcripts: a file, a link or a live is written down once by nixamp.com's own ear and kept under what it is; ask for it in another language and it is translated once and kept too.",
|
|
556
562
|
});
|
|
557
563
|
}
|
package/dist/playlist-input.d.ts
CHANGED
|
@@ -51,6 +51,16 @@ export declare class PlaylistInputStream {
|
|
|
51
51
|
private entries;
|
|
52
52
|
error: Error | null;
|
|
53
53
|
complete: boolean;
|
|
54
|
+
/**
|
|
55
|
+
* What each entry's own tags say, read when it was probed to be played --
|
|
56
|
+
* so a station can say what is on before the library has got round to
|
|
57
|
+
* reading the file, which on a big library is a while.
|
|
58
|
+
*/
|
|
59
|
+
readonly tags: Map<string, {
|
|
60
|
+
title: string;
|
|
61
|
+
artist: string;
|
|
62
|
+
album: string;
|
|
63
|
+
}>;
|
|
54
64
|
constructor(options: PlaylistInput);
|
|
55
65
|
/** Every entry that has started, in the order it started, with where it sits in the stream. */
|
|
56
66
|
get history(): ReadonlyArray<{
|
package/dist/playlist-input.js
CHANGED
|
@@ -16,6 +16,12 @@ export class PlaylistInputStream {
|
|
|
16
16
|
entries = [];
|
|
17
17
|
error = null;
|
|
18
18
|
complete = false;
|
|
19
|
+
/**
|
|
20
|
+
* What each entry's own tags say, read when it was probed to be played --
|
|
21
|
+
* so a station can say what is on before the library has got round to
|
|
22
|
+
* reading the file, which on a big library is a while.
|
|
23
|
+
*/
|
|
24
|
+
tags = new Map();
|
|
19
25
|
constructor(options) {
|
|
20
26
|
this.options = options;
|
|
21
27
|
}
|
|
@@ -102,6 +108,9 @@ export class PlaylistInputStream {
|
|
|
102
108
|
return;
|
|
103
109
|
if (!codecs.audio && !codecs.video)
|
|
104
110
|
throw new Error(`Cannot decode playlist entry ${at + 1}`);
|
|
111
|
+
if (codecs.tags && (codecs.tags.title || codecs.tags.artist)) {
|
|
112
|
+
this.tags.set(file, { title: codecs.tags.title ?? "", artist: codecs.tags.artist ?? "", album: codecs.tags.album ?? "" });
|
|
113
|
+
}
|
|
105
114
|
const duration = (codecs.duration ?? 0) - position;
|
|
106
115
|
if (duration <= 0)
|
|
107
116
|
throw new Error(`No playable duration for playlist entry ${at + 1}`);
|
package/dist/server.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { LiveVoice } from "./live-voice.ts";
|
|
|
5
5
|
import { Connections } from "./connections.ts";
|
|
6
6
|
import { Broadcaster, type Destination, type EncoderSettings } from "./broadcast.ts";
|
|
7
7
|
import { Ingest } from "./ingest.ts";
|
|
8
|
-
import { Channels, type Channel, type ChannelInfo, type RememberedChannel } from "./channels.ts";
|
|
8
|
+
import { Channels, type Channel, type ChannelInfo, type PullResume, type RememberedChannel } from "./channels.ts";
|
|
9
9
|
import { Accounts } from "./accounts.ts";
|
|
10
10
|
import { type ResetMail } from "./password-reset.ts";
|
|
11
11
|
import { Handles } from "./handles.ts";
|
|
@@ -21,6 +21,8 @@ import { Directory, type LineupEntry } from "./directory.ts";
|
|
|
21
21
|
import { ArtCache } from "./art.ts";
|
|
22
22
|
import { PartyLine } from "./partyline.ts";
|
|
23
23
|
import { HlsPackagers } from "./hls.ts";
|
|
24
|
+
import { StationRunner } from "./station-runner.ts";
|
|
25
|
+
import { StationStore } from "./stations.ts";
|
|
24
26
|
import { CompressionService } from "./compression/service.ts";
|
|
25
27
|
import { Enricher } from "./enrich.ts";
|
|
26
28
|
import { Follows } from "./follows.ts";
|
|
@@ -438,7 +440,26 @@ export interface KnownSource {
|
|
|
438
440
|
/** A picture and a line about it, from the site or the list that named it. */
|
|
439
441
|
art?: string;
|
|
440
442
|
about?: string;
|
|
443
|
+
/** A station's rotation (see PullResume.rotation). */
|
|
444
|
+
rotation?: PullResume["rotation"];
|
|
445
|
+
/** kbps for an audio rendition; 192 when unset. */
|
|
446
|
+
audioBitrate?: number;
|
|
441
447
|
}
|
|
448
|
+
/**
|
|
449
|
+
* A station's files, chosen by track index (as /api/folders/live takes them)
|
|
450
|
+
* or by a folder under the library; null when neither was given. Only files
|
|
451
|
+
* on this machine and inside the library: a station never reaches past it.
|
|
452
|
+
*/
|
|
453
|
+
export declare function selectStationFiles(engine: Engine, body: {
|
|
454
|
+
items?: unknown;
|
|
455
|
+
folder?: unknown;
|
|
456
|
+
}): string[] | null;
|
|
457
|
+
/** What the library knows about one file: its tags, for a now-playing line. */
|
|
458
|
+
export declare function trackFor(engine: Engine, file: string): {
|
|
459
|
+
title: string;
|
|
460
|
+
artist: string;
|
|
461
|
+
album: string;
|
|
462
|
+
} | null;
|
|
442
463
|
/** How many times a source that answers nothing is asked, and how far apart. */
|
|
443
464
|
export declare const PROBE_TRIES = 3;
|
|
444
465
|
export declare const PROBE_RETRY_MS = 1500;
|
|
@@ -545,6 +566,11 @@ export declare function joinSubject(url: URL, options: Pick<HandlerOptions, "dir
|
|
|
545
566
|
/** The shell, titled for what a join link opens, with its picture on the card. */
|
|
546
567
|
export declare function joinDocument(shell: string, subject: JoinSubject, site: string): string;
|
|
547
568
|
export interface HandlerOptions {
|
|
569
|
+
/** This server's stations and what keeps them on the air. */
|
|
570
|
+
stations?: {
|
|
571
|
+
store: StationStore;
|
|
572
|
+
runner: StationRunner;
|
|
573
|
+
};
|
|
548
574
|
web: string | null;
|
|
549
575
|
/** Branded clients on this same backend, keyed by an explicitly configured host. */
|
|
550
576
|
webSites?: ReadonlyMap<string, import("./web-sites.ts").WebSite>;
|
package/dist/server.js
CHANGED
|
@@ -54,6 +54,10 @@ import { ArtCache, coverArtOf, stillFrom } from "./art.js";
|
|
|
54
54
|
import { PartyLine, telnyxSms } from "./partyline.js";
|
|
55
55
|
import { HlsPackagers, segmentType, withKey } from "./hls.js";
|
|
56
56
|
import { refusedDestination } from "./destinations.js";
|
|
57
|
+
import { handleStations, isStationListenPath } from "./station-routes.js";
|
|
58
|
+
import { STATION_PAGE, STATION_PAGE_CSP, STATION_PAGE_HTML } from "./station-page.js";
|
|
59
|
+
import { StationRunner } from "./station-runner.js";
|
|
60
|
+
import { StationError, StationStore, stationOfChannel } from "./stations.js";
|
|
57
61
|
import { CompressionService } from "./compression/service.js";
|
|
58
62
|
import { handleChannelCompression, handleCompressionApi, handleStaticRelay } from "./compression/routes.js";
|
|
59
63
|
import { DEFAULT_SITE as NICHEDB, Enricher, FIXTURE_TTL_MS } from "./enrich.js";
|
|
@@ -876,6 +880,63 @@ export function memberLiveRefusal(channels, account) {
|
|
|
876
880
|
}
|
|
877
881
|
return "";
|
|
878
882
|
}
|
|
883
|
+
/**
|
|
884
|
+
* A station's files, chosen by track index (as /api/folders/live takes them)
|
|
885
|
+
* or by a folder under the library; null when neither was given. Only files
|
|
886
|
+
* on this machine and inside the library: a station never reaches past it.
|
|
887
|
+
*/
|
|
888
|
+
export function selectStationFiles(engine, body) {
|
|
889
|
+
const root = engine.snapshot(false).root;
|
|
890
|
+
if (Array.isArray(body.items)) {
|
|
891
|
+
const indices = [...new Set(body.items.filter((one) => Number.isInteger(one) && one >= 0))];
|
|
892
|
+
const files = indices.map((index) => engine.trackPath(index)).filter((one) => typeof one === "string" && !isRemote(one));
|
|
893
|
+
if (indices.length !== body.items.length || files.length !== indices.length) {
|
|
894
|
+
throw new StationError("unknown_items", "one or more of those tracks are not files in this library");
|
|
895
|
+
}
|
|
896
|
+
return files;
|
|
897
|
+
}
|
|
898
|
+
if (typeof body.folder === "string") {
|
|
899
|
+
const folder = body.folder.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
|
|
900
|
+
if (folder.split("/").includes(".."))
|
|
901
|
+
throw new StationError("invalid_folder", "a folder inside the library, please");
|
|
902
|
+
const files = [];
|
|
903
|
+
const count = engine.snapshot(false).trackCount;
|
|
904
|
+
for (let index = 0; index < count; index++) {
|
|
905
|
+
const file = engine.trackPath(index);
|
|
906
|
+
if (!file || isRemote(file))
|
|
907
|
+
continue;
|
|
908
|
+
const served = relative(root, file).replaceAll("\\", "/");
|
|
909
|
+
if (served.startsWith("../"))
|
|
910
|
+
continue;
|
|
911
|
+
if (folder === "" || served.startsWith(`${folder}/`))
|
|
912
|
+
files.push(file);
|
|
913
|
+
}
|
|
914
|
+
if (files.length === 0)
|
|
915
|
+
throw new StationError("empty_folder", "that folder has no files that play");
|
|
916
|
+
return files;
|
|
917
|
+
}
|
|
918
|
+
return null;
|
|
919
|
+
}
|
|
920
|
+
const tracksByPath = new WeakMap();
|
|
921
|
+
/** What the library knows about one file: its tags, for a now-playing line. */
|
|
922
|
+
export function trackFor(engine, file) {
|
|
923
|
+
const revision = engine.snapshot(false).revision;
|
|
924
|
+
let index = tracksByPath.get(engine);
|
|
925
|
+
if (!index || index.revision !== revision) {
|
|
926
|
+
const tracks = engine.snapshot().tracks ?? [];
|
|
927
|
+
const byPath = new Map();
|
|
928
|
+
tracks.forEach((track, at) => {
|
|
929
|
+
const path = engine.trackPath(at);
|
|
930
|
+
// Tags only: a title that is the file's own name is not a tag.
|
|
931
|
+
const named = track.title && !looksLikeFileName(track.title) ? track.title : "";
|
|
932
|
+
if (path)
|
|
933
|
+
byPath.set(path, { title: named, artist: track.artist ?? "", album: track.album ?? "" });
|
|
934
|
+
});
|
|
935
|
+
index = { revision, byPath };
|
|
936
|
+
tracksByPath.set(engine, index);
|
|
937
|
+
}
|
|
938
|
+
return index.byPath.get(file) ?? null;
|
|
939
|
+
}
|
|
879
940
|
/** How many times a source that answers nothing is asked, and how far apart. */
|
|
880
941
|
export const PROBE_TRIES = 3;
|
|
881
942
|
export const PROBE_RETRY_MS = 1500;
|
|
@@ -944,7 +1005,7 @@ export async function pullChannel(channels, ffprobe, id, name, source, input = [
|
|
|
944
1005
|
]
|
|
945
1006
|
// No picture in it, so none is invented: MP3 is the thing every browser
|
|
946
1007
|
// plays and the thing a listener can join halfway through.
|
|
947
|
-
: ["-vn", "-c:a", "libmp3lame", "-b:a",
|
|
1008
|
+
: ["-vn", "-c:a", "libmp3lame", "-b:a", `${known.audioBitrate ?? 192}k`, "-f", "mp3"];
|
|
948
1009
|
// A transport stream is read further into before it is decoded, and given
|
|
949
1010
|
// the timestamps a recording cut mid-stream does not carry. Ahead of the
|
|
950
1011
|
// caller's own input arguments, which are headers for the address itself.
|
|
@@ -953,7 +1014,7 @@ export async function pullChannel(channels, ffprobe, id, name, source, input = [
|
|
|
953
1014
|
// MP3's tags name the episode and the show, and "Inspiring Founders" is a
|
|
954
1015
|
// card; "ep-0412-final.mp3" is not.
|
|
955
1016
|
const tagged = looksLikeFileName(name) && codecs.tags?.title ? codecs.tags.title : name;
|
|
956
|
-
const channel = channels.pull(id, tagged, source, encode, kind, true, undefined, opening, kind === "video" ? audio : "", { live, position: known.position ?? 0, ...(playlist ? { playlist, playlistAt: known.playlistAt ?? 0 } : {}) },
|
|
1017
|
+
const channel = channels.pull(id, tagged, source, encode, kind, true, undefined, opening, kind === "video" ? audio : "", { live, position: known.position ?? 0, ...(playlist ? { playlist, playlistAt: known.playlistAt ?? 0 } : {}), ...(known.rotation ? { rotation: known.rotation } : {}) },
|
|
957
1018
|
// Known before the first dial: whether the source is a transport stream
|
|
958
1019
|
// decides whether it can be read here for a source-boundary relay.
|
|
959
1020
|
assumed ? undefined : codecs);
|
|
@@ -2072,6 +2133,9 @@ export function createHandler(engine, options) {
|
|
|
2072
2133
|
path !== "/api/health" &&
|
|
2073
2134
|
path !== "/api/directory" &&
|
|
2074
2135
|
!isSignInPath(path) &&
|
|
2136
|
+
// A station's link is its own key, checked by the station.
|
|
2137
|
+
!isStationListenPath(path) &&
|
|
2138
|
+
!STATION_PAGE.test(path) &&
|
|
2075
2139
|
!eventChannelRequest) {
|
|
2076
2140
|
let scope = scopeOf(keyFrom(request, url), key, listenKey);
|
|
2077
2141
|
// Kept for the routes below that answer a listener with less.
|
|
@@ -2108,6 +2172,55 @@ export function createHandler(engine, options) {
|
|
|
2108
2172
|
return;
|
|
2109
2173
|
}
|
|
2110
2174
|
}
|
|
2175
|
+
// Whether this request may administer: the control key, an open server,
|
|
2176
|
+
// or the owner's nixamp.com session. Asked by the routes that do not go
|
|
2177
|
+
// through the admin gate further down.
|
|
2178
|
+
const controlOf = async () => {
|
|
2179
|
+
if (key === null)
|
|
2180
|
+
return { allowed: true, account: "" };
|
|
2181
|
+
if (scopeOf(keyFrom(request, url), key, listenKey) === "control")
|
|
2182
|
+
return { allowed: true, account: "" };
|
|
2183
|
+
if (options.owner) {
|
|
2184
|
+
const session = tokenFrom(request.headers) || url.searchParams.get("session") || "";
|
|
2185
|
+
if (session !== "" && (await options.owner.check(false, session)).allowed) {
|
|
2186
|
+
return { allowed: true, account: (await options.owner.accountFor(session)) || "" };
|
|
2187
|
+
}
|
|
2188
|
+
}
|
|
2189
|
+
return { allowed: false, account: "" };
|
|
2190
|
+
};
|
|
2191
|
+
// The page a station's link opens. The same bytes for every station: it
|
|
2192
|
+
// reads its id and token from its own address.
|
|
2193
|
+
if (options.stations && STATION_PAGE.test(path) && (request.method === "GET" || request.method === "HEAD")) {
|
|
2194
|
+
response.writeHead(200, {
|
|
2195
|
+
"content-type": "text/html; charset=utf-8",
|
|
2196
|
+
"cache-control": "no-cache",
|
|
2197
|
+
"content-security-policy": STATION_PAGE_CSP,
|
|
2198
|
+
"referrer-policy": "no-referrer",
|
|
2199
|
+
"x-content-type-options": "nosniff",
|
|
2200
|
+
});
|
|
2201
|
+
response.end(request.method === "HEAD" ? undefined : STATION_PAGE_HTML);
|
|
2202
|
+
return;
|
|
2203
|
+
}
|
|
2204
|
+
if (options.stations && (path === "/api/v1/stations" || path.startsWith("/api/v1/stations/"))) {
|
|
2205
|
+
const control = await controlOf();
|
|
2206
|
+
const encrypted = request.socket.encrypted === true;
|
|
2207
|
+
const forwarded = behindProxy ? String(request.headers["x-forwarded-proto"] ?? "").split(",")[0]?.trim() : "";
|
|
2208
|
+
const scheme = forwarded === "https" || encrypted ? "https" : "http";
|
|
2209
|
+
await handleStations(request, response, path, url, {
|
|
2210
|
+
store: options.stations.store,
|
|
2211
|
+
runner: options.stations.runner,
|
|
2212
|
+
channels: options.channels,
|
|
2213
|
+
canControl: control.allowed,
|
|
2214
|
+
account: control.account,
|
|
2215
|
+
select: (body) => selectStationFiles(engine, body),
|
|
2216
|
+
trackFor: (file) => trackFor(engine, file),
|
|
2217
|
+
base: `${scheme}://${request.headers.host ?? "localhost"}`,
|
|
2218
|
+
json,
|
|
2219
|
+
readBody,
|
|
2220
|
+
headers: CORS,
|
|
2221
|
+
});
|
|
2222
|
+
return;
|
|
2223
|
+
}
|
|
2111
2224
|
if (path === "/api/health") {
|
|
2112
2225
|
json(response, 200, { name: "nixamp", version: options.version, media: options.media });
|
|
2113
2226
|
return;
|
|
@@ -3852,7 +3965,7 @@ export function createHandler(engine, options) {
|
|
|
3852
3965
|
// offering something that always failed.
|
|
3853
3966
|
carries: options.carries !== false && Boolean(options.channels),
|
|
3854
3967
|
},
|
|
3855
|
-
channels: (options.channels?.list() ?? []).map((one) => ({
|
|
3968
|
+
channels: (options.channels?.list() ?? []).filter((one) => !stationOfChannel(one.id)).map((one) => ({
|
|
3856
3969
|
id: one.id,
|
|
3857
3970
|
name: one.name,
|
|
3858
3971
|
via: one.via,
|
|
@@ -4478,7 +4591,8 @@ export function createHandler(engine, options) {
|
|
|
4478
4591
|
// on, and the address a channel is pulled from is the one thing about
|
|
4479
4592
|
// it that is not theirs to have.
|
|
4480
4593
|
json(response, 200, {
|
|
4481
|
-
|
|
4594
|
+
// Stations answer for themselves, under /api/v1/stations.
|
|
4595
|
+
channels: options.channels.list().filter((one) => !stationOfChannel(one.id)).map(({ source: _source, ...shown }) => shown),
|
|
4482
4596
|
listeners: options.channels.listeners,
|
|
4483
4597
|
});
|
|
4484
4598
|
return;
|
|
@@ -4490,6 +4604,12 @@ export function createHandler(engine, options) {
|
|
|
4490
4604
|
const rest = path.slice("/api/channels/".length);
|
|
4491
4605
|
const [rawId, action, file] = rest.split("/");
|
|
4492
4606
|
const id = cleanId(rawId);
|
|
4607
|
+
// A station's channel is heard through the station's own link, which
|
|
4608
|
+
// knows whether it is private. Through here, only by its owner.
|
|
4609
|
+
if (stationOfChannel(id) && !(await controlOf()).allowed) {
|
|
4610
|
+
json(response, 404, { error: "nothing is playing on that channel" });
|
|
4611
|
+
return;
|
|
4612
|
+
}
|
|
4493
4613
|
const event = await eventForChannel();
|
|
4494
4614
|
if (event) {
|
|
4495
4615
|
const account = await options.accounts?.whoIs(tokenFrom(request.headers)) ?? null;
|
|
@@ -5932,6 +6052,9 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
5932
6052
|
// same ffmpeg that carries the channels. No ffmpeg, no outro, and a show
|
|
5933
6053
|
// that ends closes its channel as before.
|
|
5934
6054
|
const outro = new Outro({ ffmpeg: tools.ffmpeg, dir: join(stateDir(), "outro"), onEvent: (message) => console.log(message) });
|
|
6055
|
+
// Made once the channels exist; told of every channel that ends, so a
|
|
6056
|
+
// station that died is brought back and one that was stopped is not.
|
|
6057
|
+
let stationRunner = null;
|
|
5935
6058
|
const channels = new Channels({
|
|
5936
6059
|
ffmpeg: tools.ffmpeg,
|
|
5937
6060
|
ffprobe: tools.ffprobe,
|
|
@@ -5941,8 +6064,24 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
5941
6064
|
onEnd: (info) => {
|
|
5942
6065
|
console.log(` "${info.id}" stopped.`);
|
|
5943
6066
|
art.forget(info.id);
|
|
6067
|
+
stationRunner?.onChannelEnd(info);
|
|
5944
6068
|
},
|
|
5945
6069
|
});
|
|
6070
|
+
const stationStore = new StationStore(StationStore.fileFor(stateDir(), options.port));
|
|
6071
|
+
stationRunner = new StationRunner({
|
|
6072
|
+
store: stationStore,
|
|
6073
|
+
channels,
|
|
6074
|
+
pull: async (channelId, name, files, known) => Boolean(await pullChannel(channels, tools.ffprobe, channelId, name, files[0], [], "", {
|
|
6075
|
+
playlist: files,
|
|
6076
|
+
live: true,
|
|
6077
|
+
rotation: known.rotation,
|
|
6078
|
+
audioBitrate: known.audioBitrate,
|
|
6079
|
+
})),
|
|
6080
|
+
// The library as it is now, or as it was chosen while it is still being read.
|
|
6081
|
+
roots: () => [engine.snapshot(false).root || chosen].filter((one) => one !== ""),
|
|
6082
|
+
log: (line) => console.log(line),
|
|
6083
|
+
});
|
|
6084
|
+
const stations = { store: stationStore, runner: stationRunner };
|
|
5946
6085
|
// Channels as HLS, on demand, for Safari on a phone: one ffmpeg copying a
|
|
5947
6086
|
// channel's fragments into short files while somebody is asking for them.
|
|
5948
6087
|
// What things are, from nichedb.dev, remembered beside the keys so a
|
|
@@ -5991,6 +6130,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
5991
6130
|
console.log(` "${one.id}" is already on.`);
|
|
5992
6131
|
});
|
|
5993
6132
|
}
|
|
6133
|
+
stationRunner.restore();
|
|
5994
6134
|
// Where each film has got to, written down every so often, so a restart
|
|
5995
6135
|
// picks it up from about there rather than from the start. Only when it
|
|
5996
6136
|
// has changed: a server carrying nothing but live television writes
|
|
@@ -6520,6 +6660,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
6520
6660
|
media: options.media,
|
|
6521
6661
|
owner,
|
|
6522
6662
|
channels,
|
|
6663
|
+
stations,
|
|
6523
6664
|
rememberChannels: remembering,
|
|
6524
6665
|
catalogs,
|
|
6525
6666
|
publishUrls: () => publishUrls,
|
|
@@ -6929,6 +7070,11 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
6929
7070
|
* A declaration rather than an assignment, so it exists from the moment the
|
|
6930
7071
|
* function is entered -- the port is open well before this line is reached.
|
|
6931
7072
|
*/
|
|
7073
|
+
/** The station behind this channel, when its owner made it public and listed it. */
|
|
7074
|
+
function listedStation(channelId) {
|
|
7075
|
+
const station = stationStore.get(stationOfChannel(channelId));
|
|
7076
|
+
return station && station.visibility === "public" && station.listed ? station : undefined;
|
|
7077
|
+
}
|
|
6932
7078
|
function makePublisher() {
|
|
6933
7079
|
if (!publishable_)
|
|
6934
7080
|
return null;
|
|
@@ -6953,10 +7099,20 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
6953
7099
|
// which channels are on. A directory row that says "5,717 files,
|
|
6954
7100
|
// live: CNN" is one somebody can decide about; "5717 tracks" was not.
|
|
6955
7101
|
playing: () => engine.snapshot(false).playing,
|
|
6956
|
-
|
|
7102
|
+
// Stations only when their owner listed them; every other channel as before.
|
|
7103
|
+
channels: () => channels.list().filter((one) => !stationOfChannel(one.id) || listedStation(one.id)).map((one) => one.name),
|
|
6957
7104
|
// The same with what a link preview needs: the id a join link names
|
|
6958
7105
|
// a channel by, a picture, a line.
|
|
6959
|
-
lineup: () => channels.list()
|
|
7106
|
+
lineup: () => channels.list()
|
|
7107
|
+
.filter((one) => !stationOfChannel(one.id) || listedStation(one.id))
|
|
7108
|
+
.map((one) => {
|
|
7109
|
+
const entry = lineupEntry(listen, one);
|
|
7110
|
+
const station = listedStation(one.id);
|
|
7111
|
+
// A station is listened to through its own link, never the server's.
|
|
7112
|
+
return station
|
|
7113
|
+
? { ...entry, listen: `${publishable_.url}/station/${station.id}?t=${encodeURIComponent(station.listenToken)}` }
|
|
7114
|
+
: entry;
|
|
7115
|
+
}),
|
|
6960
7116
|
// From `nixamp login`. The directory will not list a stream it cannot
|
|
6961
7117
|
// attribute to somebody, because a listing is now a phone code that
|
|
6962
7118
|
// costs money to answer.
|