nixamp 0.29.1 → 0.30.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.js +4 -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/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-page.d.ts +5 -0
- package/dist/station-page.js +137 -0
- package/dist/station-routes.d.ts +60 -0
- package/dist/station-routes.js +249 -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 +4 -1
- package/src/directory.ts +10 -2
- package/src/main.ts +8 -0
- package/src/server.ts +169 -7
- package/src/station-cli.ts +195 -0
- package/src/station-page.ts +144 -0
- package/src/station-routes.ts +301 -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-CTUDxy1J.js} +1 -1
- package/web/dist/assets/{cs-DEPGOGEV.js → cs-m4JTaO3o.js} +1 -1
- package/web/dist/assets/{da-CQw7SPC9.js → da-DHnJElYB.js} +1 -1
- package/web/dist/assets/{de-OWnobbah.js → de-DkxPohnf.js} +1 -1
- package/web/dist/assets/{es-qMpyH9bM.js → es-oY_G83d7.js} +1 -1
- package/web/dist/assets/{fi-DpqqwJTD.js → fi-fCO8XRdg.js} +1 -1
- package/web/dist/assets/{fr-BiO_ceen.js → fr-CSupf-IN.js} +1 -1
- package/web/dist/assets/{hi-D-q5wZuY.js → hi-Dve7PJsC.js} +1 -1
- package/web/dist/assets/{hls-3VKVEQE3-D2W4K8VR.js → hls-3VKVEQE3-D5-AkKSR.js} +1 -1
- package/web/dist/assets/{hu-FIjHyhVC.js → hu-D1KYamzV.js} +1 -1
- package/web/dist/assets/{id-gmB6lu56.js → id-Cofp_4aQ.js} +1 -1
- package/web/dist/assets/{index-UmGvzuun.js → index-7mCQZGNF.js} +2 -2
- package/web/dist/assets/{it-Dxfo713A.js → it-62iXQWQD.js} +1 -1
- package/web/dist/assets/{mpegts-BT4XGr_E.js → mpegts-BT5ESacM.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-CpZnlq0W.js → mpegts-LO6RVLD6-BgHd8PFT.js} +1 -1
- package/web/dist/assets/{nl-mk5eN3Io.js → nl-CzRRG-CN.js} +1 -1
- package/web/dist/assets/{ru-B2psU0kN.js → ru-OykC1LJQ.js} +1 -1
- package/web/dist/assets/{sv-Cg6waoNZ.js → sv-B8FXBDHm.js} +1 -1
- package/web/dist/assets/{uk-BcFkukLd.js → uk-DhAJBjwH.js} +1 -1
- package/web/dist/assets/{vi-CnqIC1Sz.js → vi-CdBSRReC.js} +1 -1
- package/web/dist/assets/{zh-CvnXIFvN.js → zh-wGWNyBEZ.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/sw.js +22 -22
package/src/server.ts
CHANGED
|
@@ -36,7 +36,7 @@ import {
|
|
|
36
36
|
import { Ingest, normaliseFormat } from "./ingest.ts";
|
|
37
37
|
import {
|
|
38
38
|
BACKLOG_SECONDS, Channels, cleanId, generatedId, rememberChannels, rememberedChannels, rememberedNow,
|
|
39
|
-
type Channel, type ChannelInfo, type RememberedChannel,
|
|
39
|
+
type Channel, type ChannelInfo, type PullResume, type RememberedChannel,
|
|
40
40
|
} from "./channels.ts";
|
|
41
41
|
import { RtmpListeners } from "./rtmp-in.ts";
|
|
42
42
|
import { Accounts, clearedCookie, sessionCookie, tokenFrom } from "./accounts.ts";
|
|
@@ -71,6 +71,10 @@ import { ArtCache, coverArtOf, stillFrom } from "./art.ts";
|
|
|
71
71
|
import { PartyLine, telnyxSms } from "./partyline.ts";
|
|
72
72
|
import { HlsPackagers, segmentType, withKey } from "./hls.ts";
|
|
73
73
|
import { refusedDestination } from "./destinations.ts";
|
|
74
|
+
import { handleStations, isStationListenPath } from "./station-routes.ts";
|
|
75
|
+
import { STATION_PAGE, STATION_PAGE_CSP, STATION_PAGE_HTML } from "./station-page.ts";
|
|
76
|
+
import { StationRunner } from "./station-runner.ts";
|
|
77
|
+
import { StationError, StationStore, channelOf, stationOfChannel, type Station } from "./stations.ts";
|
|
74
78
|
import { CompressionService } from "./compression/service.ts";
|
|
75
79
|
import { handleChannelCompression, handleCompressionApi, handleStaticRelay, type RouteContext } from "./compression/routes.ts";
|
|
76
80
|
import { DEFAULT_SITE as NICHEDB, Enricher, type EnrichKind, FIXTURE_TTL_MS } from "./enrich.ts";
|
|
@@ -1112,6 +1116,64 @@ export interface KnownSource {
|
|
|
1112
1116
|
/** A picture and a line about it, from the site or the list that named it. */
|
|
1113
1117
|
art?: string;
|
|
1114
1118
|
about?: string;
|
|
1119
|
+
/** A station's rotation (see PullResume.rotation). */
|
|
1120
|
+
rotation?: PullResume["rotation"];
|
|
1121
|
+
/** kbps for an audio rendition; 192 when unset. */
|
|
1122
|
+
audioBitrate?: number;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
/**
|
|
1126
|
+
* A station's files, chosen by track index (as /api/folders/live takes them)
|
|
1127
|
+
* or by a folder under the library; null when neither was given. Only files
|
|
1128
|
+
* on this machine and inside the library: a station never reaches past it.
|
|
1129
|
+
*/
|
|
1130
|
+
export function selectStationFiles(engine: Engine, body: { items?: unknown; folder?: unknown }): string[] | null {
|
|
1131
|
+
const root = engine.snapshot(false).root;
|
|
1132
|
+
if (Array.isArray(body.items)) {
|
|
1133
|
+
const indices = [...new Set(body.items.filter((one): one is number => Number.isInteger(one) && one >= 0))];
|
|
1134
|
+
const files = indices.map((index) => engine.trackPath(index)).filter((one): one is string => typeof one === "string" && !isRemote(one));
|
|
1135
|
+
if (indices.length !== body.items.length || files.length !== indices.length) {
|
|
1136
|
+
throw new StationError("unknown_items", "one or more of those tracks are not files in this library");
|
|
1137
|
+
}
|
|
1138
|
+
return files;
|
|
1139
|
+
}
|
|
1140
|
+
if (typeof body.folder === "string") {
|
|
1141
|
+
const folder = body.folder.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
|
|
1142
|
+
if (folder.split("/").includes("..")) throw new StationError("invalid_folder", "a folder inside the library, please");
|
|
1143
|
+
const files: string[] = [];
|
|
1144
|
+
const count = engine.snapshot(false).trackCount;
|
|
1145
|
+
for (let index = 0; index < count; index++) {
|
|
1146
|
+
const file = engine.trackPath(index);
|
|
1147
|
+
if (!file || isRemote(file)) continue;
|
|
1148
|
+
const served = relative(root, file).replaceAll("\\", "/");
|
|
1149
|
+
if (served.startsWith("../")) continue;
|
|
1150
|
+
if (folder === "" || served.startsWith(`${folder}/`)) files.push(file);
|
|
1151
|
+
}
|
|
1152
|
+
if (files.length === 0) throw new StationError("empty_folder", "that folder has no files that play");
|
|
1153
|
+
return files;
|
|
1154
|
+
}
|
|
1155
|
+
return null;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
const tracksByPath = new WeakMap<Engine, { revision: number; byPath: Map<string, { title: string; artist: string; album: string }> }>();
|
|
1159
|
+
|
|
1160
|
+
/** What the library knows about one file: its tags, for a now-playing line. */
|
|
1161
|
+
export function trackFor(engine: Engine, file: string): { title: string; artist: string; album: string } | null {
|
|
1162
|
+
const revision = engine.snapshot(false).revision;
|
|
1163
|
+
let index = tracksByPath.get(engine);
|
|
1164
|
+
if (!index || index.revision !== revision) {
|
|
1165
|
+
const tracks = engine.snapshot().tracks ?? [];
|
|
1166
|
+
const byPath = new Map<string, { title: string; artist: string; album: string }>();
|
|
1167
|
+
tracks.forEach((track, at) => {
|
|
1168
|
+
const path = engine.trackPath(at);
|
|
1169
|
+
// Tags only: a title that is the file's own name is not a tag.
|
|
1170
|
+
const named = track.title && !looksLikeFileName(track.title) ? track.title : "";
|
|
1171
|
+
if (path) byPath.set(path, { title: named, artist: track.artist ?? "", album: track.album ?? "" });
|
|
1172
|
+
});
|
|
1173
|
+
index = { revision, byPath };
|
|
1174
|
+
tracksByPath.set(engine, index);
|
|
1175
|
+
}
|
|
1176
|
+
return index.byPath.get(file) ?? null;
|
|
1115
1177
|
}
|
|
1116
1178
|
|
|
1117
1179
|
/** How many times a source that answers nothing is asked, and how far apart. */
|
|
@@ -1190,7 +1252,7 @@ export async function pullChannel(
|
|
|
1190
1252
|
]
|
|
1191
1253
|
// No picture in it, so none is invented: MP3 is the thing every browser
|
|
1192
1254
|
// plays and the thing a listener can join halfway through.
|
|
1193
|
-
: ["-vn", "-c:a", "libmp3lame", "-b:a",
|
|
1255
|
+
: ["-vn", "-c:a", "libmp3lame", "-b:a", `${known.audioBitrate ?? 192}k`, "-f", "mp3"];
|
|
1194
1256
|
// A transport stream is read further into before it is decoded, and given
|
|
1195
1257
|
// the timestamps a recording cut mid-stream does not carry. Ahead of the
|
|
1196
1258
|
// caller's own input arguments, which are headers for the address itself.
|
|
@@ -1201,7 +1263,7 @@ export async function pullChannel(
|
|
|
1201
1263
|
const tagged = looksLikeFileName(name) && codecs.tags?.title ? codecs.tags.title : name;
|
|
1202
1264
|
const channel = channels.pull(
|
|
1203
1265
|
id, tagged, source, encode, kind, true, undefined, opening, kind === "video" ? audio : "",
|
|
1204
|
-
{ live, position: known.position ?? 0, ...(playlist ? { playlist, playlistAt: known.playlistAt ?? 0 } : {}) },
|
|
1266
|
+
{ live, position: known.position ?? 0, ...(playlist ? { playlist, playlistAt: known.playlistAt ?? 0 } : {}), ...(known.rotation ? { rotation: known.rotation } : {}) },
|
|
1205
1267
|
// Known before the first dial: whether the source is a transport stream
|
|
1206
1268
|
// decides whether it can be read here for a source-boundary relay.
|
|
1207
1269
|
assumed ? undefined : codecs,
|
|
@@ -1644,6 +1706,8 @@ async function readBytes(request: IncomingMessage, limit: number): Promise<Uint8
|
|
|
1644
1706
|
}
|
|
1645
1707
|
|
|
1646
1708
|
export interface HandlerOptions {
|
|
1709
|
+
/** This server's stations and what keeps them on the air. */
|
|
1710
|
+
stations?: { store: StationStore; runner: StationRunner };
|
|
1647
1711
|
web: string | null;
|
|
1648
1712
|
/** Branded clients on this same backend, keyed by an explicitly configured host. */
|
|
1649
1713
|
webSites?: ReadonlyMap<string, import("./web-sites.ts").WebSite>;
|
|
@@ -2575,6 +2639,9 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2575
2639
|
path !== "/api/health" &&
|
|
2576
2640
|
path !== "/api/directory" &&
|
|
2577
2641
|
!isSignInPath(path) &&
|
|
2642
|
+
// A station's link is its own key, checked by the station.
|
|
2643
|
+
!isStationListenPath(path) &&
|
|
2644
|
+
!STATION_PAGE.test(path) &&
|
|
2578
2645
|
!eventChannelRequest
|
|
2579
2646
|
) {
|
|
2580
2647
|
let scope = scopeOf(keyFrom(request, url), key, listenKey);
|
|
@@ -2612,6 +2679,56 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2612
2679
|
}
|
|
2613
2680
|
}
|
|
2614
2681
|
|
|
2682
|
+
// Whether this request may administer: the control key, an open server,
|
|
2683
|
+
// or the owner's nixamp.com session. Asked by the routes that do not go
|
|
2684
|
+
// through the admin gate further down.
|
|
2685
|
+
const controlOf = async (): Promise<{ allowed: boolean; account: string }> => {
|
|
2686
|
+
if (key === null) return { allowed: true, account: "" };
|
|
2687
|
+
if (scopeOf(keyFrom(request, url), key, listenKey) === "control") return { allowed: true, account: "" };
|
|
2688
|
+
if (options.owner) {
|
|
2689
|
+
const session = tokenFrom(request.headers) || url.searchParams.get("session") || "";
|
|
2690
|
+
if (session !== "" && (await options.owner.check(false, session)).allowed) {
|
|
2691
|
+
return { allowed: true, account: (await options.owner.accountFor(session)) || "" };
|
|
2692
|
+
}
|
|
2693
|
+
}
|
|
2694
|
+
return { allowed: false, account: "" };
|
|
2695
|
+
};
|
|
2696
|
+
|
|
2697
|
+
// The page a station's link opens. The same bytes for every station: it
|
|
2698
|
+
// reads its id and token from its own address.
|
|
2699
|
+
if (options.stations && STATION_PAGE.test(path) && (request.method === "GET" || request.method === "HEAD")) {
|
|
2700
|
+
response.writeHead(200, {
|
|
2701
|
+
"content-type": "text/html; charset=utf-8",
|
|
2702
|
+
"cache-control": "no-cache",
|
|
2703
|
+
"content-security-policy": STATION_PAGE_CSP,
|
|
2704
|
+
"referrer-policy": "no-referrer",
|
|
2705
|
+
"x-content-type-options": "nosniff",
|
|
2706
|
+
});
|
|
2707
|
+
response.end(request.method === "HEAD" ? undefined : STATION_PAGE_HTML);
|
|
2708
|
+
return;
|
|
2709
|
+
}
|
|
2710
|
+
|
|
2711
|
+
if (options.stations && (path === "/api/v1/stations" || path.startsWith("/api/v1/stations/"))) {
|
|
2712
|
+
const control = await controlOf();
|
|
2713
|
+
const encrypted = (request.socket as { encrypted?: boolean }).encrypted === true;
|
|
2714
|
+
const forwarded = behindProxy ? String(request.headers["x-forwarded-proto"] ?? "").split(",")[0]?.trim() : "";
|
|
2715
|
+
const scheme = forwarded === "https" || encrypted ? "https" : "http";
|
|
2716
|
+
await handleStations(request, response, path, url, {
|
|
2717
|
+
store: options.stations.store,
|
|
2718
|
+
runner: options.stations.runner,
|
|
2719
|
+
channels: options.channels!,
|
|
2720
|
+
canControl: control.allowed,
|
|
2721
|
+
account: control.account,
|
|
2722
|
+
select: (body) => selectStationFiles(engine, body),
|
|
2723
|
+
trackFor: (file) => trackFor(engine, file),
|
|
2724
|
+
base: `${scheme}://${request.headers.host ?? "localhost"}`,
|
|
2725
|
+
json,
|
|
2726
|
+
readBody,
|
|
2727
|
+
headers: CORS,
|
|
2728
|
+
});
|
|
2729
|
+
return;
|
|
2730
|
+
}
|
|
2731
|
+
|
|
2615
2732
|
if (path === "/api/health") {
|
|
2616
2733
|
json(response, 200, { name: "nixamp", version: options.version, media: options.media });
|
|
2617
2734
|
return;
|
|
@@ -4268,7 +4385,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
4268
4385
|
// offering something that always failed.
|
|
4269
4386
|
carries: options.carries !== false && Boolean(options.channels),
|
|
4270
4387
|
},
|
|
4271
|
-
channels: (options.channels?.list() ?? []).map((one) => ({
|
|
4388
|
+
channels: (options.channels?.list() ?? []).filter((one) => !stationOfChannel(one.id)).map((one) => ({
|
|
4272
4389
|
id: one.id,
|
|
4273
4390
|
name: one.name,
|
|
4274
4391
|
via: one.via,
|
|
@@ -4899,7 +5016,8 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
4899
5016
|
// on, and the address a channel is pulled from is the one thing about
|
|
4900
5017
|
// it that is not theirs to have.
|
|
4901
5018
|
json(response, 200, {
|
|
4902
|
-
|
|
5019
|
+
// Stations answer for themselves, under /api/v1/stations.
|
|
5020
|
+
channels: options.channels.list().filter((one) => !stationOfChannel(one.id)).map(({ source: _source, ...shown }) => shown),
|
|
4903
5021
|
listeners: options.channels.listeners,
|
|
4904
5022
|
});
|
|
4905
5023
|
return;
|
|
@@ -4912,6 +5030,12 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
4912
5030
|
const rest = path.slice("/api/channels/".length);
|
|
4913
5031
|
const [rawId, action, file] = rest.split("/");
|
|
4914
5032
|
const id = cleanId(rawId);
|
|
5033
|
+
// A station's channel is heard through the station's own link, which
|
|
5034
|
+
// knows whether it is private. Through here, only by its owner.
|
|
5035
|
+
if (stationOfChannel(id) && !(await controlOf()).allowed) {
|
|
5036
|
+
json(response, 404, { error: "nothing is playing on that channel" });
|
|
5037
|
+
return;
|
|
5038
|
+
}
|
|
4915
5039
|
const event = await eventForChannel();
|
|
4916
5040
|
if (event) {
|
|
4917
5041
|
const account = await options.accounts?.whoIs(tokenFrom(request.headers)) ?? null;
|
|
@@ -6410,6 +6534,9 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
6410
6534
|
// same ffmpeg that carries the channels. No ffmpeg, no outro, and a show
|
|
6411
6535
|
// that ends closes its channel as before.
|
|
6412
6536
|
const outro = new Outro({ ffmpeg: tools.ffmpeg, dir: join(stateDir(), "outro"), onEvent: (message) => console.log(message) });
|
|
6537
|
+
// Made once the channels exist; told of every channel that ends, so a
|
|
6538
|
+
// station that died is brought back and one that was stopped is not.
|
|
6539
|
+
let stationRunner: StationRunner | null = null;
|
|
6413
6540
|
const channels = new Channels({
|
|
6414
6541
|
ffmpeg: tools.ffmpeg,
|
|
6415
6542
|
ffprobe: tools.ffprobe,
|
|
@@ -6420,8 +6547,25 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
6420
6547
|
onEnd: (info) => {
|
|
6421
6548
|
console.log(` "${info.id}" stopped.`);
|
|
6422
6549
|
art.forget(info.id);
|
|
6550
|
+
stationRunner?.onChannelEnd(info);
|
|
6423
6551
|
},
|
|
6424
6552
|
});
|
|
6553
|
+
const stationStore = new StationStore(StationStore.fileFor(stateDir(), options.port));
|
|
6554
|
+
stationRunner = new StationRunner({
|
|
6555
|
+
store: stationStore,
|
|
6556
|
+
channels,
|
|
6557
|
+
pull: async (channelId, name, files, known) =>
|
|
6558
|
+
Boolean(await pullChannel(channels, tools.ffprobe, channelId, name, files[0]!, [], "", {
|
|
6559
|
+
playlist: files,
|
|
6560
|
+
live: true,
|
|
6561
|
+
rotation: known.rotation,
|
|
6562
|
+
audioBitrate: known.audioBitrate,
|
|
6563
|
+
})),
|
|
6564
|
+
// The library as it is now, or as it was chosen while it is still being read.
|
|
6565
|
+
roots: () => [engine.snapshot(false).root || chosen].filter((one) => one !== ""),
|
|
6566
|
+
log: (line) => console.log(line),
|
|
6567
|
+
});
|
|
6568
|
+
const stations = { store: stationStore, runner: stationRunner };
|
|
6425
6569
|
// Channels as HLS, on demand, for Safari on a phone: one ffmpeg copying a
|
|
6426
6570
|
// channel's fragments into short files while somebody is asking for them.
|
|
6427
6571
|
// What things are, from nichedb.dev, remembered beside the keys so a
|
|
@@ -6470,6 +6614,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
6470
6614
|
if (!channel) console.log(` "${one.id}" is already on.`);
|
|
6471
6615
|
});
|
|
6472
6616
|
}
|
|
6617
|
+
stationRunner.restore();
|
|
6473
6618
|
// Where each film has got to, written down every so often, so a restart
|
|
6474
6619
|
// picks it up from about there rather than from the start. Only when it
|
|
6475
6620
|
// has changed: a server carrying nothing but live television writes
|
|
@@ -7012,6 +7157,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
7012
7157
|
media: options.media,
|
|
7013
7158
|
owner,
|
|
7014
7159
|
channels,
|
|
7160
|
+
stations,
|
|
7015
7161
|
rememberChannels: remembering,
|
|
7016
7162
|
catalogs,
|
|
7017
7163
|
publishUrls: () => publishUrls,
|
|
@@ -7441,6 +7587,12 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
7441
7587
|
* A declaration rather than an assignment, so it exists from the moment the
|
|
7442
7588
|
* function is entered -- the port is open well before this line is reached.
|
|
7443
7589
|
*/
|
|
7590
|
+
/** The station behind this channel, when its owner made it public and listed it. */
|
|
7591
|
+
function listedStation(channelId: string): Station | undefined {
|
|
7592
|
+
const station = stationStore.get(stationOfChannel(channelId));
|
|
7593
|
+
return station && station.visibility === "public" && station.listed ? station : undefined;
|
|
7594
|
+
}
|
|
7595
|
+
|
|
7444
7596
|
function makePublisher(): Publisher | null {
|
|
7445
7597
|
if (!publishable_) return null;
|
|
7446
7598
|
const listen = shareLink(publishable_.url, listenKey, false);
|
|
@@ -7464,10 +7616,20 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
7464
7616
|
// which channels are on. A directory row that says "5,717 files,
|
|
7465
7617
|
// live: CNN" is one somebody can decide about; "5717 tracks" was not.
|
|
7466
7618
|
playing: () => engine.snapshot(false).playing,
|
|
7467
|
-
|
|
7619
|
+
// Stations only when their owner listed them; every other channel as before.
|
|
7620
|
+
channels: () => channels.list().filter((one) => !stationOfChannel(one.id) || listedStation(one.id)).map((one) => one.name),
|
|
7468
7621
|
// The same with what a link preview needs: the id a join link names
|
|
7469
7622
|
// a channel by, a picture, a line.
|
|
7470
|
-
lineup: () => channels.list()
|
|
7623
|
+
lineup: () => channels.list()
|
|
7624
|
+
.filter((one) => !stationOfChannel(one.id) || listedStation(one.id))
|
|
7625
|
+
.map((one) => {
|
|
7626
|
+
const entry = lineupEntry(listen, one);
|
|
7627
|
+
const station = listedStation(one.id);
|
|
7628
|
+
// A station is listened to through its own link, never the server's.
|
|
7629
|
+
return station
|
|
7630
|
+
? { ...entry, listen: `${publishable_!.url}/station/${station.id}?t=${encodeURIComponent(station.listenToken)}` }
|
|
7631
|
+
: entry;
|
|
7632
|
+
}),
|
|
7471
7633
|
// From `nixamp login`. The directory will not list a stream it cannot
|
|
7472
7634
|
// attribute to somebody, because a listing is now a phone code that
|
|
7473
7635
|
// costs money to answer.
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `nixamp station`: stations from a terminal, a script or a cron job.
|
|
3
|
+
*
|
|
4
|
+
* Talks to the daemon on this machine with its control key, or to another
|
|
5
|
+
* server with --url and --key, exactly as `nixamp admin` does. Every command
|
|
6
|
+
* takes --json for a script; without it, a line a person can read. Exit codes
|
|
7
|
+
* a script can branch on: 0 done, 1 the server said no (the reason and a
|
|
8
|
+
* stable code are printed), 2 the command was wrong, 3 the server could not
|
|
9
|
+
* be reached.
|
|
10
|
+
*
|
|
11
|
+
* Nothing here publishes as a side effect. `create` and `start` leave a
|
|
12
|
+
* station private; `publish` is its own command and says on what basis.
|
|
13
|
+
*/
|
|
14
|
+
import { resolveTarget } from "./admin.ts";
|
|
15
|
+
import { readState } from "./daemon.ts";
|
|
16
|
+
import { KEY_HEADER } from "./share.ts";
|
|
17
|
+
|
|
18
|
+
import { USAGE } from "./station-usage.ts";
|
|
19
|
+
|
|
20
|
+
export { USAGE };
|
|
21
|
+
|
|
22
|
+
function flag(argv: string[], name: string): string | undefined {
|
|
23
|
+
const at = argv.indexOf(name);
|
|
24
|
+
return at === -1 ? undefined : argv[at + 1];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function has(argv: string[], name: string): boolean {
|
|
28
|
+
return argv.includes(name);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface Answer {
|
|
32
|
+
status: number;
|
|
33
|
+
body: Record<string, unknown>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
class Unreachable extends Error {}
|
|
37
|
+
|
|
38
|
+
export async function station(argv: string[]): Promise<number> {
|
|
39
|
+
const [command, id] = argv;
|
|
40
|
+
const json = has(argv, "--json");
|
|
41
|
+
if (!command || command === "help" || has(argv, "--help")) {
|
|
42
|
+
console.log(USAGE);
|
|
43
|
+
return command ? 0 : 2;
|
|
44
|
+
}
|
|
45
|
+
let target;
|
|
46
|
+
try {
|
|
47
|
+
target = resolveTarget(argv);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
console.error((error as Error).message);
|
|
50
|
+
return 3;
|
|
51
|
+
}
|
|
52
|
+
const headers: Record<string, string> = { "content-type": "application/json", ...(target.key ? { [KEY_HEADER]: target.key } : {}) };
|
|
53
|
+
const call = async (method: string, path: string, body?: unknown): Promise<Answer> => {
|
|
54
|
+
let response: Response;
|
|
55
|
+
try {
|
|
56
|
+
response = await fetch(`${target.url}${path}`, { method, headers, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
|
|
57
|
+
} catch (error) {
|
|
58
|
+
throw new Unreachable(`could not reach ${target.url}: ${(error as Error).message}`);
|
|
59
|
+
}
|
|
60
|
+
const text = await response.text();
|
|
61
|
+
let parsed: Record<string, unknown> = {};
|
|
62
|
+
try { parsed = JSON.parse(text) as Record<string, unknown>; } catch { parsed = { error: text }; }
|
|
63
|
+
return { status: response.status, body: parsed };
|
|
64
|
+
};
|
|
65
|
+
const needId = (): string | null => {
|
|
66
|
+
if (!id || id.startsWith("--")) {
|
|
67
|
+
console.error(`nixamp station ${command} needs a station id. \`nixamp station list\` shows them.`);
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
return id;
|
|
71
|
+
};
|
|
72
|
+
const show = (answer: Answer, line: (body: Record<string, unknown>) => string): number => {
|
|
73
|
+
if (json) {
|
|
74
|
+
console.log(JSON.stringify(answer.body, null, 2));
|
|
75
|
+
} else if (answer.status < 400) {
|
|
76
|
+
console.log(line(answer.body));
|
|
77
|
+
}
|
|
78
|
+
if (answer.status >= 400) {
|
|
79
|
+
if (!json) console.error(`nixamp: ${String(answer.body["error"] ?? "refused")} (${String(answer.body["code"] ?? answer.status)})`);
|
|
80
|
+
return 1;
|
|
81
|
+
}
|
|
82
|
+
return 0;
|
|
83
|
+
};
|
|
84
|
+
const describe = (station: Record<string, unknown>): string => {
|
|
85
|
+
const links = (station["links"] ?? {}) as Record<string, string>;
|
|
86
|
+
return [
|
|
87
|
+
`${String(station["name"])} [${String(station["id"])}]`,
|
|
88
|
+
` state ${String(station["state"])} (wanted ${String(station["desired"])})`,
|
|
89
|
+
` visibility ${String(station["visibility"])}${station["listed"] ? ", listed in the directory" : ""}`,
|
|
90
|
+
` rotation ${station["shuffle"] ? "shuffle" : "in order"}${station["repeat"] ? ", repeating" : ", once"}; ${(station["items"] as unknown[] | undefined)?.length ?? 0} files at ${String(station["bitrate"])} kbps`,
|
|
91
|
+
...(station["error"] ? [` problem ${String(station["error"])}`] : []),
|
|
92
|
+
...((station["excluded"] as unknown[] | undefined)?.length ? [` left out ${(station["excluded"] as unknown[]).length} files (nixamp station retry)`] : []),
|
|
93
|
+
...(station["visibility"] !== "private" && links["page"] ? [` listen ${links["page"]}`] : []),
|
|
94
|
+
].join("\n");
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
switch (command) {
|
|
99
|
+
case "list": {
|
|
100
|
+
const answer = await call("GET", "/api/v1/stations?limit=200");
|
|
101
|
+
return show(answer, (body) => {
|
|
102
|
+
const all = (body["stations"] as Record<string, unknown>[] | undefined) ?? [];
|
|
103
|
+
return all.length === 0 ? "No stations yet. nixamp station create --name NAME --folder PATH" : all.map(describe).join("\n\n");
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
case "create": {
|
|
107
|
+
const name = flag(argv, "--name");
|
|
108
|
+
if (!name) {
|
|
109
|
+
console.error("nixamp station create needs --name");
|
|
110
|
+
return 2;
|
|
111
|
+
}
|
|
112
|
+
const items = flag(argv, "--items");
|
|
113
|
+
let folder = flag(argv, "--folder");
|
|
114
|
+
if (folder !== undefined) {
|
|
115
|
+
// A path under the library is taken relative to it.
|
|
116
|
+
const root = readState()?.source ?? "";
|
|
117
|
+
if (root && folder.startsWith(root)) folder = folder.slice(root.length);
|
|
118
|
+
}
|
|
119
|
+
if (items === undefined && folder === undefined) {
|
|
120
|
+
console.error("nixamp station create needs --folder PATH or --items 0,1,2");
|
|
121
|
+
return 2;
|
|
122
|
+
}
|
|
123
|
+
const bitrate = flag(argv, "--bitrate");
|
|
124
|
+
const answer = await call("POST", "/api/v1/stations", {
|
|
125
|
+
name,
|
|
126
|
+
...(items !== undefined ? { items: items.split(",").map((one) => Number(one.trim())) } : { folder }),
|
|
127
|
+
shuffle: has(argv, "--shuffle"),
|
|
128
|
+
repeat: !has(argv, "--no-repeat"),
|
|
129
|
+
...(bitrate ? { bitrate: Number(bitrate) } : {}),
|
|
130
|
+
});
|
|
131
|
+
return show(answer, (body) => `Made (private, stopped):\n${describe(body["station"] as Record<string, unknown>)}`);
|
|
132
|
+
}
|
|
133
|
+
case "show":
|
|
134
|
+
case "status": {
|
|
135
|
+
const station = needId();
|
|
136
|
+
if (!station) return 2;
|
|
137
|
+
return show(await call("GET", `/api/v1/stations/${station}`), (body) => describe(body["station"] as Record<string, unknown>));
|
|
138
|
+
}
|
|
139
|
+
case "start":
|
|
140
|
+
case "stop":
|
|
141
|
+
case "skip":
|
|
142
|
+
case "retry": {
|
|
143
|
+
const station = needId();
|
|
144
|
+
if (!station) return 2;
|
|
145
|
+
return show(await call("POST", `/api/v1/stations/${station}/${command}`, {}), (body) => describe(body["station"] as Record<string, unknown>));
|
|
146
|
+
}
|
|
147
|
+
case "publish": {
|
|
148
|
+
const station = needId();
|
|
149
|
+
if (!station) return 2;
|
|
150
|
+
const basis = flag(argv, "--basis");
|
|
151
|
+
const unlisted = has(argv, "--unlisted");
|
|
152
|
+
const access: Record<string, unknown> = {
|
|
153
|
+
visibility: unlisted ? "unlisted" : "public",
|
|
154
|
+
listed: !unlisted && !has(argv, "--no-list"),
|
|
155
|
+
...(basis ? { rights: { basis, note: flag(argv, "--note") ?? "" } } : {}),
|
|
156
|
+
};
|
|
157
|
+
return show(await call("POST", `/api/v1/stations/${station}/access`, access), (body) => describe(body["station"] as Record<string, unknown>));
|
|
158
|
+
}
|
|
159
|
+
case "unpublish": {
|
|
160
|
+
const station = needId();
|
|
161
|
+
if (!station) return 2;
|
|
162
|
+
const access = has(argv, "--private") ? { visibility: "private" } : { listed: false };
|
|
163
|
+
return show(await call("POST", `/api/v1/stations/${station}/access`, access), (body) => {
|
|
164
|
+
const dropped = Number(body["dropped"] ?? 0);
|
|
165
|
+
return `${describe(body["station"] as Record<string, unknown>)}${dropped ? `\n ${dropped} listener(s) disconnected; old links no longer work.` : ""}`;
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
case "link": {
|
|
169
|
+
const station = needId();
|
|
170
|
+
if (!station) return 2;
|
|
171
|
+
const answer = has(argv, "--rotate")
|
|
172
|
+
? await call("POST", `/api/v1/stations/${station}/rotate-link`, {})
|
|
173
|
+
: await call("GET", `/api/v1/stations/${station}`);
|
|
174
|
+
return show(answer, (body) => {
|
|
175
|
+
const links = ((body["station"] as Record<string, unknown>)["links"] ?? {}) as Record<string, string>;
|
|
176
|
+
return [`page ${links["page"]}`, `stream ${links["stream"]}`, `m3u ${links["m3u"]}`].join("\n");
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
case "delete": {
|
|
180
|
+
const station = needId();
|
|
181
|
+
if (!station) return 2;
|
|
182
|
+
return show(await call("DELETE", `/api/v1/stations/${station}`), () => `Deleted ${station}. Its files were not touched.`);
|
|
183
|
+
}
|
|
184
|
+
default:
|
|
185
|
+
console.error(`nixamp station: no command "${command}".\n\n${USAGE}`);
|
|
186
|
+
return 2;
|
|
187
|
+
}
|
|
188
|
+
} catch (error) {
|
|
189
|
+
if (error instanceof Unreachable) {
|
|
190
|
+
console.error(`nixamp: ${error.message}`);
|
|
191
|
+
return 3;
|
|
192
|
+
}
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page a station's link opens: its name, what is on, and Play.
|
|
3
|
+
*
|
|
4
|
+
* Served by the station's own server, without the server's key, because the
|
|
5
|
+
* link is the key: the page reads `t` from its own address and asks the
|
|
6
|
+
* station's state and stream with it. Nothing about the station is written
|
|
7
|
+
* into the HTML, so the page is the same bytes for every station and can be
|
|
8
|
+
* locked down with a content security policy naming its script and style by
|
|
9
|
+
* hash. A private station, or a wrong link, shows "not available" -- the
|
|
10
|
+
* same answer, so the page cannot be used to find out which stations exist.
|
|
11
|
+
*/
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
13
|
+
|
|
14
|
+
const STYLE = `
|
|
15
|
+
:root{--bg:#f6f5f2;--fg:#17171a;--muted:#5d5d66;--card:#fff;--line:#dedcd6;--accent:#1f6feb;--live:#0b7a3b;--warn:#9a6700;--off:#8c2f39}
|
|
16
|
+
@media (prefers-color-scheme:dark){:root{--bg:#121214;--fg:#ececf1;--muted:#a4a4b0;--card:#1c1c20;--line:#2c2c33;--accent:#6aa7ff;--live:#3fbf7f;--warn:#e3b341;--off:#f0838f}}
|
|
17
|
+
*{box-sizing:border-box}html,body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif}
|
|
18
|
+
main{max-width:560px;margin:0 auto;padding:48px 16px}
|
|
19
|
+
.card{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:24px}
|
|
20
|
+
h1{font-size:1.6rem;line-height:1.2;margin:0 0 4px}
|
|
21
|
+
.about{color:var(--muted);margin:0 0 20px}
|
|
22
|
+
.chip{display:inline-flex;align-items:center;gap:8px;font-size:.85rem;font-weight:600;padding:4px 10px;border-radius:999px;border:1px solid var(--line)}
|
|
23
|
+
.chip::before{content:"";width:8px;height:8px;border-radius:50%;background:currentColor}
|
|
24
|
+
.live{color:var(--live)}.reconnecting{color:var(--warn)}.unavailable,.stopped{color:var(--off)}
|
|
25
|
+
.now{margin:20px 0;min-height:3em}.now .title{font-weight:600}.now .by{color:var(--muted)}
|
|
26
|
+
.controls{display:flex;align-items:center;gap:16px;flex-wrap:wrap}
|
|
27
|
+
button{font:inherit;font-weight:600;border:0;border-radius:999px;padding:12px 28px;background:var(--accent);color:#fff;cursor:pointer;min-width:120px}
|
|
28
|
+
button:disabled{opacity:.5;cursor:not-allowed}
|
|
29
|
+
button:focus-visible,input:focus-visible,a:focus-visible{outline:3px solid var(--accent);outline-offset:3px}
|
|
30
|
+
label{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:.9rem}
|
|
31
|
+
input[type=range]{width:120px;accent-color:var(--accent)}
|
|
32
|
+
.small{margin-top:20px;font-size:.85rem;color:var(--muted)}.small a{color:inherit}
|
|
33
|
+
.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
|
|
34
|
+
@media (prefers-reduced-motion:no-preference){.live::before{animation:pulse 2s ease-in-out infinite}}
|
|
35
|
+
@keyframes pulse{50%{opacity:.35}}
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
const SCRIPT = `
|
|
39
|
+
(() => {
|
|
40
|
+
const id = location.pathname.split("/").filter(Boolean)[1] || "";
|
|
41
|
+
const token = new URLSearchParams(location.search).get("t") || "";
|
|
42
|
+
const q = "?t=" + encodeURIComponent(token);
|
|
43
|
+
const api = "/api/v1/stations/" + encodeURIComponent(id);
|
|
44
|
+
const $ = (s) => document.querySelector(s);
|
|
45
|
+
const audio = new Audio();
|
|
46
|
+
audio.preload = "none";
|
|
47
|
+
let wanted = false, retry = 0, timer = 0, last = null;
|
|
48
|
+
const chip = (state, text) => { const c = $("#state"); c.className = "chip " + state; c.textContent = text; };
|
|
49
|
+
const say = (text) => { $("#announce").textContent = text; };
|
|
50
|
+
const draw = (s) => {
|
|
51
|
+
last = s;
|
|
52
|
+
document.title = s.name + " · nixamp";
|
|
53
|
+
$("#name").textContent = s.name;
|
|
54
|
+
$("#about").textContent = s.description || "";
|
|
55
|
+
$("#about").hidden = !s.description;
|
|
56
|
+
const playing = s.nowPlaying;
|
|
57
|
+
$("#title").textContent = playing ? (playing.title || "Untitled") : (s.state === "live" ? "On the air" : "");
|
|
58
|
+
$("#by").textContent = playing ? [playing.artist, playing.album].filter(Boolean).join(" · ") : "";
|
|
59
|
+
if (wanted && retry > 0) chip("reconnecting", "Reconnecting");
|
|
60
|
+
else if (s.state === "live") chip("live", "Live");
|
|
61
|
+
else if (s.state === "starting" || s.state === "recovering") chip("reconnecting", "Coming back");
|
|
62
|
+
else if (s.state === "stopped") chip("stopped", "Off the air");
|
|
63
|
+
else chip("unavailable", "Unavailable");
|
|
64
|
+
$("#play").disabled = !(s.state === "live" || s.state === "recovering" || s.state === "starting");
|
|
65
|
+
$("#m3u").href = api + "/listen.m3u" + q;
|
|
66
|
+
};
|
|
67
|
+
const poll = async () => {
|
|
68
|
+
try {
|
|
69
|
+
const r = await fetch(api + "/state" + q, { cache: "no-store" });
|
|
70
|
+
if (r.status === 404) { $("#name").textContent = "This station is not available"; chip("unavailable", "Unavailable"); $("#play").disabled = true; stop(); return; }
|
|
71
|
+
if (r.ok) draw(await r.json());
|
|
72
|
+
} catch {}
|
|
73
|
+
};
|
|
74
|
+
// Always the live edge: a fresh request, never a cached or paused one.
|
|
75
|
+
const join = () => { audio.src = api + "/stream" + q + "&n=" + Date.now(); audio.play().catch(() => { wanted = false; button(); }); };
|
|
76
|
+
const stop = () => { wanted = false; clearTimeout(timer); audio.pause(); audio.removeAttribute("src"); audio.load(); button(); };
|
|
77
|
+
const button = () => { const b = $("#play"); b.textContent = wanted ? "Stop" : "Play"; b.setAttribute("aria-pressed", String(wanted)); };
|
|
78
|
+
const again = () => {
|
|
79
|
+
if (!wanted) return;
|
|
80
|
+
retry += 1;
|
|
81
|
+
if (last) draw(last);
|
|
82
|
+
clearTimeout(timer);
|
|
83
|
+
timer = setTimeout(join, Math.min(15000, 1000 * 2 ** Math.min(retry, 4)));
|
|
84
|
+
};
|
|
85
|
+
audio.addEventListener("playing", () => { if (retry) say("Back on"); retry = 0; if (last) draw(last); });
|
|
86
|
+
audio.addEventListener("error", again);
|
|
87
|
+
audio.addEventListener("ended", again);
|
|
88
|
+
audio.addEventListener("stalled", () => { if (wanted && audio.readyState < 3) again(); });
|
|
89
|
+
$("#play").addEventListener("click", () => { if (wanted) { stop(); say("Stopped"); } else { wanted = true; retry = 0; button(); join(); say("Playing"); } });
|
|
90
|
+
$("#volume").addEventListener("input", (e) => { audio.volume = Number(e.target.value); });
|
|
91
|
+
poll();
|
|
92
|
+
setInterval(poll, 5000);
|
|
93
|
+
})();
|
|
94
|
+
`;
|
|
95
|
+
|
|
96
|
+
const hash = (text: string): string => `'sha256-${createHash("sha256").update(text).digest("base64")}'`;
|
|
97
|
+
|
|
98
|
+
const PAGE = `<!doctype html>
|
|
99
|
+
<html lang="en">
|
|
100
|
+
<head>
|
|
101
|
+
<meta charset="utf-8">
|
|
102
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
103
|
+
<meta name="robots" content="noindex">
|
|
104
|
+
<title>Station · nixamp</title>
|
|
105
|
+
<style>${STYLE}</style>
|
|
106
|
+
</head>
|
|
107
|
+
<body>
|
|
108
|
+
<main>
|
|
109
|
+
<div class="card">
|
|
110
|
+
<span id="state" class="chip unavailable" role="status">Loading</span>
|
|
111
|
+
<h1 id="name"> </h1>
|
|
112
|
+
<p id="about" class="about" hidden></p>
|
|
113
|
+
<div class="now" aria-live="polite"><div id="title" class="title"></div><div id="by" class="by"></div></div>
|
|
114
|
+
<div class="controls">
|
|
115
|
+
<button id="play" type="button" aria-pressed="false" disabled>Play</button>
|
|
116
|
+
<label>Volume <input id="volume" type="range" min="0" max="1" step="0.05" value="1"></label>
|
|
117
|
+
</div>
|
|
118
|
+
<p class="small"><a id="m3u" href="#">Open in another player (.m3u)</a> · Pausing here does not pause the station.</p>
|
|
119
|
+
</div>
|
|
120
|
+
<p class="small">Played by <a href="https://nixamp.com">nixamp</a>.</p>
|
|
121
|
+
<div id="announce" class="sr" aria-live="assertive"></div>
|
|
122
|
+
</main>
|
|
123
|
+
<script>${SCRIPT}</script>
|
|
124
|
+
</body>
|
|
125
|
+
</html>
|
|
126
|
+
`;
|
|
127
|
+
|
|
128
|
+
/** A station page's path: /station/<id>. */
|
|
129
|
+
export const STATION_PAGE = /^\/station\/([a-z0-9]{10})\/?$/;
|
|
130
|
+
|
|
131
|
+
export const STATION_PAGE_HTML = PAGE;
|
|
132
|
+
|
|
133
|
+
/** Only its own script and style, only its own server, never framed. */
|
|
134
|
+
export const STATION_PAGE_CSP = [
|
|
135
|
+
"default-src 'none'",
|
|
136
|
+
`script-src ${hash(SCRIPT)}`,
|
|
137
|
+
`style-src ${hash(STYLE)}`,
|
|
138
|
+
"connect-src 'self'",
|
|
139
|
+
"media-src 'self'",
|
|
140
|
+
"img-src 'self'",
|
|
141
|
+
"base-uri 'none'",
|
|
142
|
+
"form-action 'none'",
|
|
143
|
+
"frame-ancestors 'none'",
|
|
144
|
+
].join("; ");
|