nixamp 0.28.45 → 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/audio.js +3 -0
- package/dist/channels.d.ts +14 -0
- package/dist/channels.js +20 -4
- package/dist/decoder-policy.d.ts +20 -0
- package/dist/decoder-policy.js +34 -0
- package/dist/destinations.d.ts +5 -0
- package/dist/destinations.js +82 -0
- package/dist/directory.d.ts +3 -1
- package/dist/directory.js +8 -2
- package/dist/library.d.ts +2 -0
- package/dist/library.js +4 -1
- package/dist/links.d.ts +4 -0
- package/dist/links.js +20 -2
- package/dist/main.d.ts +0 -8
- package/dist/main.js +9 -0
- package/dist/playlist-input.d.ts +46 -0
- package/dist/playlist-input.js +158 -54
- package/dist/publish-choice.d.ts +21 -0
- package/dist/publish-choice.js +17 -0
- package/dist/server.d.ts +27 -1
- package/dist/server.js +228 -17
- package/dist/share.d.ts +1 -1
- package/dist/share.js +5 -4
- 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/audio.ts +3 -0
- package/src/channels.ts +30 -5
- package/src/decoder-policy.ts +33 -0
- package/src/destinations.ts +78 -0
- package/src/directory.ts +10 -2
- package/src/library.ts +6 -1
- package/src/links.ts +23 -4
- package/src/main.ts +8 -0
- package/src/playlist-input.ts +172 -50
- package/src/publish-choice.ts +35 -0
- package/src/server.ts +227 -18
- package/src/share.ts +5 -4
- 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-8fDonUoB.js → ar-CTUDxy1J.js} +1 -1
- package/web/dist/assets/{cs-DCClolTy.js → cs-m4JTaO3o.js} +1 -1
- package/web/dist/assets/{da-C3RPhXUd.js → da-DHnJElYB.js} +1 -1
- package/web/dist/assets/{de-CYBoEomk.js → de-DkxPohnf.js} +1 -1
- package/web/dist/assets/{es-DS3ls4xT.js → es-oY_G83d7.js} +1 -1
- package/web/dist/assets/{fi-DbCZpO89.js → fi-fCO8XRdg.js} +1 -1
- package/web/dist/assets/{fr-D0z3UpdL.js → fr-CSupf-IN.js} +1 -1
- package/web/dist/assets/{hi-BTU3sRlQ.js → hi-Dve7PJsC.js} +1 -1
- package/web/dist/assets/{hls-3VKVEQE3-CQXTVHn_.js → hls-3VKVEQE3-D5-AkKSR.js} +1 -1
- package/web/dist/assets/{hu-B37Q8ldT.js → hu-D1KYamzV.js} +1 -1
- package/web/dist/assets/{id-4pdXNa4G.js → id-Cofp_4aQ.js} +1 -1
- package/web/dist/assets/index-7mCQZGNF.js +22 -0
- package/web/dist/assets/{it-CTe69-UY.js → it-62iXQWQD.js} +1 -1
- package/web/dist/assets/{mpegts-BBmkDppm.js → mpegts-BT5ESacM.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-CUVdjYFi.js → mpegts-LO6RVLD6-BgHd8PFT.js} +1 -1
- package/web/dist/assets/{nl-CxrmHrMn.js → nl-CzRRG-CN.js} +1 -1
- package/web/dist/assets/{ru-zjVCfIKq.js → ru-OykC1LJQ.js} +1 -1
- package/web/dist/assets/{sv-CEoiT2Lr.js → sv-B8FXBDHm.js} +1 -1
- package/web/dist/assets/{uk-ZC6JzQvD.js → uk-DhAJBjwH.js} +1 -1
- package/web/dist/assets/{vi-D9anF-cl.js → vi-CdBSRReC.js} +1 -1
- package/web/dist/assets/{zh-BeLYdC0I.js → zh-wGWNyBEZ.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/sw.js +22 -22
- package/web/dist/assets/index-BDBbN5Qy.js +0 -22
package/src/server.ts
CHANGED
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
* a WebSocket because SSE is plain HTTP: no dependency, and it reconnects on
|
|
10
10
|
* its own when the laptop running the remote goes to sleep.
|
|
11
11
|
*/
|
|
12
|
-
import {
|
|
12
|
+
import { publishChoice, shouldRecord } from "./publish-choice.ts";
|
|
13
|
+
import { protocolArgs } from "./decoder-policy.ts";
|
|
14
|
+
import { createReadStream, existsSync, statSync } from "node:fs";
|
|
13
15
|
import { createServer as createHttpServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
14
16
|
import { createServer as createHttpsServer } from "node:https";
|
|
15
17
|
import { randomBytes } from "node:crypto";
|
|
@@ -34,7 +36,7 @@ import {
|
|
|
34
36
|
import { Ingest, normaliseFormat } from "./ingest.ts";
|
|
35
37
|
import {
|
|
36
38
|
BACKLOG_SECONDS, Channels, cleanId, generatedId, rememberChannels, rememberedChannels, rememberedNow,
|
|
37
|
-
type Channel, type ChannelInfo, type RememberedChannel,
|
|
39
|
+
type Channel, type ChannelInfo, type PullResume, type RememberedChannel,
|
|
38
40
|
} from "./channels.ts";
|
|
39
41
|
import { RtmpListeners } from "./rtmp-in.ts";
|
|
40
42
|
import { Accounts, clearedCookie, sessionCookie, tokenFrom } from "./accounts.ts";
|
|
@@ -68,6 +70,11 @@ import { Directory, ENDED_TTL_MS, parseAnnouncement, type LineupEntry, type List
|
|
|
68
70
|
import { ArtCache, coverArtOf, stillFrom } from "./art.ts";
|
|
69
71
|
import { PartyLine, telnyxSms } from "./partyline.ts";
|
|
70
72
|
import { HlsPackagers, segmentType, withKey } from "./hls.ts";
|
|
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";
|
|
71
78
|
import { CompressionService } from "./compression/service.ts";
|
|
72
79
|
import { handleChannelCompression, handleCompressionApi, handleStaticRelay, type RouteContext } from "./compression/routes.ts";
|
|
73
80
|
import { DEFAULT_SITE as NICHEDB, Enricher, type EnrichKind, FIXTURE_TTL_MS } from "./enrich.ts";
|
|
@@ -86,7 +93,7 @@ import { Porkbun, isIPv4, isIPv6, type DnsZone } from "./dns.ts";
|
|
|
86
93
|
import { NameError, Names } from "./names.ts";
|
|
87
94
|
import { AcmeIssuer, Certs } from "./certs.ts";
|
|
88
95
|
import { claimName, fetchCert, humanizeSource, labelFor, readCertFiles, writeCertFiles } from "./naming.ts";
|
|
89
|
-
import { forbiddenLibrary, readLibrary } from "./library.ts";
|
|
96
|
+
import { forbiddenLibrary, readConfig, readLibrary, writeConfig } from "./library.ts";
|
|
90
97
|
import { createThrottle, presentedCredential, type Throttle } from "@profullstack/throttle";
|
|
91
98
|
import { Durable } from "./durable.ts";
|
|
92
99
|
import { notifyAll, resendEmail, webPush, type Notification } from "./notify.ts";
|
|
@@ -1109,6 +1116,64 @@ export interface KnownSource {
|
|
|
1109
1116
|
/** A picture and a line about it, from the site or the list that named it. */
|
|
1110
1117
|
art?: string;
|
|
1111
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;
|
|
1112
1177
|
}
|
|
1113
1178
|
|
|
1114
1179
|
/** How many times a source that answers nothing is asked, and how far apart. */
|
|
@@ -1187,7 +1252,7 @@ export async function pullChannel(
|
|
|
1187
1252
|
]
|
|
1188
1253
|
// No picture in it, so none is invented: MP3 is the thing every browser
|
|
1189
1254
|
// plays and the thing a listener can join halfway through.
|
|
1190
|
-
: ["-vn", "-c:a", "libmp3lame", "-b:a",
|
|
1255
|
+
: ["-vn", "-c:a", "libmp3lame", "-b:a", `${known.audioBitrate ?? 192}k`, "-f", "mp3"];
|
|
1191
1256
|
// A transport stream is read further into before it is decoded, and given
|
|
1192
1257
|
// the timestamps a recording cut mid-stream does not carry. Ahead of the
|
|
1193
1258
|
// caller's own input arguments, which are headers for the address itself.
|
|
@@ -1198,7 +1263,7 @@ export async function pullChannel(
|
|
|
1198
1263
|
const tagged = looksLikeFileName(name) && codecs.tags?.title ? codecs.tags.title : name;
|
|
1199
1264
|
const channel = channels.pull(
|
|
1200
1265
|
id, tagged, source, encode, kind, true, undefined, opening, kind === "video" ? audio : "",
|
|
1201
|
-
{ 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 } : {}) },
|
|
1202
1267
|
// Known before the first dial: whether the source is a transport stream
|
|
1203
1268
|
// decides whether it can be read here for a source-boundary relay.
|
|
1204
1269
|
assumed ? undefined : codecs,
|
|
@@ -1641,6 +1706,8 @@ async function readBytes(request: IncomingMessage, limit: number): Promise<Uint8
|
|
|
1641
1706
|
}
|
|
1642
1707
|
|
|
1643
1708
|
export interface HandlerOptions {
|
|
1709
|
+
/** This server's stations and what keeps them on the air. */
|
|
1710
|
+
stations?: { store: StationStore; runner: StationRunner };
|
|
1644
1711
|
web: string | null;
|
|
1645
1712
|
/** Branded clients on this same backend, keyed by an explicitly configured host. */
|
|
1646
1713
|
webSites?: ReadonlyMap<string, import("./web-sites.ts").WebSite>;
|
|
@@ -1976,7 +2043,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
1976
2043
|
json(response, 404, { error: "not found" });
|
|
1977
2044
|
return;
|
|
1978
2045
|
}
|
|
1979
|
-
response.writeHead(302, { ...CORS, "set-cookie": keyCookie(offered), location: "/" });
|
|
2046
|
+
response.writeHead(302, { ...CORS, "set-cookie": keyCookie(offered, (request.socket as { encrypted?: boolean }).encrypted === true || (options.secureCookies ?? false)), location: "/" });
|
|
1980
2047
|
response.end();
|
|
1981
2048
|
return;
|
|
1982
2049
|
}
|
|
@@ -2563,14 +2630,23 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2563
2630
|
// port is open from another device before wondering whether the link is
|
|
2564
2631
|
// wrong, and it says nothing about the library.
|
|
2565
2632
|
const eventChannelRequest = Boolean(channelId && await eventForChannel());
|
|
2633
|
+
// Whether this request holds only the listen key. A listener may browse
|
|
2634
|
+
// and hear the library, but where it lives on this machine is the
|
|
2635
|
+
// owner's business: the absolute path names the user account and the disk.
|
|
2636
|
+
let listening = false;
|
|
2566
2637
|
if (
|
|
2567
2638
|
key !== null &&
|
|
2568
2639
|
path !== "/api/health" &&
|
|
2569
2640
|
path !== "/api/directory" &&
|
|
2570
2641
|
!isSignInPath(path) &&
|
|
2642
|
+
// A station's link is its own key, checked by the station.
|
|
2643
|
+
!isStationListenPath(path) &&
|
|
2644
|
+
!STATION_PAGE.test(path) &&
|
|
2571
2645
|
!eventChannelRequest
|
|
2572
2646
|
) {
|
|
2573
2647
|
let scope = scopeOf(keyFrom(request, url), key, listenKey);
|
|
2648
|
+
// Kept for the routes below that answer a listener with less.
|
|
2649
|
+
const settle = (): void => { listening = scope === "listen"; };
|
|
2574
2650
|
// A key is how somebody who was invited proves it. It is not the only way
|
|
2575
2651
|
// to be allowed in: the person who owns this server is allowed in whether
|
|
2576
2652
|
// or not they still have the link, and their nixamp.com session says who
|
|
@@ -2596,12 +2672,63 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2596
2672
|
});
|
|
2597
2673
|
return;
|
|
2598
2674
|
}
|
|
2675
|
+
settle();
|
|
2599
2676
|
if (scope === "listen" && !allowedForListening(path)) {
|
|
2600
2677
|
json(response, 403, { error: "this link can listen, not drive" });
|
|
2601
2678
|
return;
|
|
2602
2679
|
}
|
|
2603
2680
|
}
|
|
2604
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
|
+
|
|
2605
2732
|
if (path === "/api/health") {
|
|
2606
2733
|
json(response, 200, { name: "nixamp", version: options.version, media: options.media });
|
|
2607
2734
|
return;
|
|
@@ -4258,7 +4385,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
4258
4385
|
// offering something that always failed.
|
|
4259
4386
|
carries: options.carries !== false && Boolean(options.channels),
|
|
4260
4387
|
},
|
|
4261
|
-
channels: (options.channels?.list() ?? []).map((one) => ({
|
|
4388
|
+
channels: (options.channels?.list() ?? []).filter((one) => !stationOfChannel(one.id)).map((one) => ({
|
|
4262
4389
|
id: one.id,
|
|
4263
4390
|
name: one.name,
|
|
4264
4391
|
via: one.via,
|
|
@@ -4631,11 +4758,32 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
4631
4758
|
json(response, 429, { error: `this server is already carrying ${MAX_ON_DEMAND} channels on demand; try again in a minute` });
|
|
4632
4759
|
return;
|
|
4633
4760
|
}
|
|
4634
|
-
|
|
4761
|
+
// A member's link is fetched from inside the owner's network, so it has
|
|
4762
|
+
// to lead somewhere public: the link, every hop of a playlist, and
|
|
4763
|
+
// whatever yt-dlp says the media is. The owner's is theirs to aim.
|
|
4764
|
+
const guard = liveBy && !known ? (one: string) => refusedDestination(one) : undefined;
|
|
4765
|
+
if (guard) {
|
|
4766
|
+
const refused = await guard(link);
|
|
4767
|
+
if (refused) {
|
|
4768
|
+
json(response, 403, { error: refused });
|
|
4769
|
+
return;
|
|
4770
|
+
}
|
|
4771
|
+
}
|
|
4772
|
+
const resolved = known ?? await resolveLink(options.ytdlp ?? null, link, { cookies: options.cookies ?? "", guard });
|
|
4635
4773
|
if ("error" in resolved) {
|
|
4636
|
-
json(response, 422, { error: resolved.error });
|
|
4774
|
+
json(response, guard && resolved.error.includes("private network") ? 403 : 422, { error: resolved.error });
|
|
4637
4775
|
return;
|
|
4638
4776
|
}
|
|
4777
|
+
if (guard) {
|
|
4778
|
+
const reached = [resolved.media, resolved.audio, ...(resolved.playlist ?? [])].filter((one) => one && /^(https?|rtmps?):\/\//i.test(one));
|
|
4779
|
+
for (const one of [...new Set(reached)].slice(0, 200)) {
|
|
4780
|
+
const refused = await guard(one);
|
|
4781
|
+
if (refused) {
|
|
4782
|
+
json(response, 403, { error: refused });
|
|
4783
|
+
return;
|
|
4784
|
+
}
|
|
4785
|
+
}
|
|
4786
|
+
}
|
|
4639
4787
|
links.set(link, resolved);
|
|
4640
4788
|
// A channel already on -- put back by a restart, which forgets where
|
|
4641
4789
|
// it came from -- learns its picture and its line from the link pasted
|
|
@@ -4868,7 +5016,8 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
4868
5016
|
// on, and the address a channel is pulled from is the one thing about
|
|
4869
5017
|
// it that is not theirs to have.
|
|
4870
5018
|
json(response, 200, {
|
|
4871
|
-
|
|
5019
|
+
// Stations answer for themselves, under /api/v1/stations.
|
|
5020
|
+
channels: options.channels.list().filter((one) => !stationOfChannel(one.id)).map(({ source: _source, ...shown }) => shown),
|
|
4872
5021
|
listeners: options.channels.listeners,
|
|
4873
5022
|
});
|
|
4874
5023
|
return;
|
|
@@ -4881,6 +5030,12 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
4881
5030
|
const rest = path.slice("/api/channels/".length);
|
|
4882
5031
|
const [rawId, action, file] = rest.split("/");
|
|
4883
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
|
+
}
|
|
4884
5039
|
const event = await eventForChannel();
|
|
4885
5040
|
if (event) {
|
|
4886
5041
|
const account = await options.accounts?.whoIs(tokenFrom(request.headers)) ?? null;
|
|
@@ -5494,7 +5649,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
5494
5649
|
}
|
|
5495
5650
|
|
|
5496
5651
|
if (path === "/api/state") {
|
|
5497
|
-
json(response, 200, engine.snapshot());
|
|
5652
|
+
json(response, 200, listening ? { ...engine.snapshot(), root: "" } : engine.snapshot());
|
|
5498
5653
|
return;
|
|
5499
5654
|
}
|
|
5500
5655
|
|
|
@@ -5562,7 +5717,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
5562
5717
|
"x-accel-buffering": "no",
|
|
5563
5718
|
});
|
|
5564
5719
|
const send = (snapshot: Snapshot): void => {
|
|
5565
|
-
response.write(`data: ${JSON.stringify(snapshot)}\n\n`);
|
|
5720
|
+
response.write(`data: ${JSON.stringify(listening ? { ...snapshot, root: "" } : snapshot)}\n\n`);
|
|
5566
5721
|
};
|
|
5567
5722
|
const unsubscribe = engine.subscribe(send);
|
|
5568
5723
|
// A comment line keeps proxies from closing an idle stream.
|
|
@@ -6039,6 +6194,7 @@ async function liveAudio(
|
|
|
6039
6194
|
// A live source has no beginning to seek from.
|
|
6040
6195
|
...(from > 1 && !isRemote(source) ? ["-ss", String(from)] : []),
|
|
6041
6196
|
"-re",
|
|
6197
|
+
...protocolArgs(source),
|
|
6042
6198
|
"-i", source,
|
|
6043
6199
|
...(asVideo
|
|
6044
6200
|
// Copied where it can be, because a room full of viewers is a room
|
|
@@ -6157,6 +6313,7 @@ function pipeFfmpeg(
|
|
|
6157
6313
|
// when they are handed to it for a file on disk.
|
|
6158
6314
|
...(isRemote(source) ? ["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5"] : []),
|
|
6159
6315
|
...inputArgs,
|
|
6316
|
+
...protocolArgs(source),
|
|
6160
6317
|
"-i", source,
|
|
6161
6318
|
...outputArgs,
|
|
6162
6319
|
"-",
|
|
@@ -6341,6 +6498,10 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
6341
6498
|
// still works after a restart -- and a server is restarted to pick up a new
|
|
6342
6499
|
// version, which is to say often. `--new-key` mints a fresh pair and forgets
|
|
6343
6500
|
// the old one, which is the way to revoke a link that got out.
|
|
6501
|
+
// Whether this port had keys before this start: a server that has run
|
|
6502
|
+
// here before, whose directory listing (see publishChoice) is kept.
|
|
6503
|
+
const established = existsSync(join(stateDir(), "keys.json")) &&
|
|
6504
|
+
(() => { try { return String(options.port) in (JSON.parse(readFileSync(join(stateDir(), "keys.json"), "utf8")) as object); } catch { return false; } })();
|
|
6344
6505
|
const remembered = options.key ? rememberedKeys(stateDir(), options.port, options.newKey) : null;
|
|
6345
6506
|
const key = remembered?.key ?? null;
|
|
6346
6507
|
// Kept whether or not it is published, so `nixamp admin` and the operator
|
|
@@ -6373,6 +6534,9 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
6373
6534
|
// same ffmpeg that carries the channels. No ffmpeg, no outro, and a show
|
|
6374
6535
|
// that ends closes its channel as before.
|
|
6375
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;
|
|
6376
6540
|
const channels = new Channels({
|
|
6377
6541
|
ffmpeg: tools.ffmpeg,
|
|
6378
6542
|
ffprobe: tools.ffprobe,
|
|
@@ -6383,8 +6547,25 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
6383
6547
|
onEnd: (info) => {
|
|
6384
6548
|
console.log(` "${info.id}" stopped.`);
|
|
6385
6549
|
art.forget(info.id);
|
|
6550
|
+
stationRunner?.onChannelEnd(info);
|
|
6386
6551
|
},
|
|
6387
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 };
|
|
6388
6569
|
// Channels as HLS, on demand, for Safari on a phone: one ffmpeg copying a
|
|
6389
6570
|
// channel's fragments into short files while somebody is asking for them.
|
|
6390
6571
|
// What things are, from nichedb.dev, remembered beside the keys so a
|
|
@@ -6433,6 +6614,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
6433
6614
|
if (!channel) console.log(` "${one.id}" is already on.`);
|
|
6434
6615
|
});
|
|
6435
6616
|
}
|
|
6617
|
+
stationRunner.restore();
|
|
6436
6618
|
// Where each film has got to, written down every so often, so a restart
|
|
6437
6619
|
// picks it up from about there rather than from the start. Only when it
|
|
6438
6620
|
// has changed: a server carrying nothing but live television writes
|
|
@@ -6975,6 +7157,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
6975
7157
|
media: options.media,
|
|
6976
7158
|
owner,
|
|
6977
7159
|
channels,
|
|
7160
|
+
stations,
|
|
6978
7161
|
rememberChannels: remembering,
|
|
6979
7162
|
catalogs,
|
|
6980
7163
|
publishUrls: () => publishUrls,
|
|
@@ -7404,6 +7587,12 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
7404
7587
|
* A declaration rather than an assignment, so it exists from the moment the
|
|
7405
7588
|
* function is entered -- the port is open well before this line is reached.
|
|
7406
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
|
+
|
|
7407
7596
|
function makePublisher(): Publisher | null {
|
|
7408
7597
|
if (!publishable_) return null;
|
|
7409
7598
|
const listen = shareLink(publishable_.url, listenKey, false);
|
|
@@ -7427,10 +7616,20 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
7427
7616
|
// which channels are on. A directory row that says "5,717 files,
|
|
7428
7617
|
// live: CNN" is one somebody can decide about; "5717 tracks" was not.
|
|
7429
7618
|
playing: () => engine.snapshot(false).playing,
|
|
7430
|
-
|
|
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),
|
|
7431
7621
|
// The same with what a link preview needs: the id a join link names
|
|
7432
7622
|
// a channel by, a picture, a line.
|
|
7433
|
-
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
|
+
}),
|
|
7434
7633
|
// From `nixamp login`. The directory will not list a stream it cannot
|
|
7435
7634
|
// attribute to somebody, because a listing is now a phone code that
|
|
7436
7635
|
// costs money to answer.
|
|
@@ -7475,10 +7674,20 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
7475
7674
|
// public at all.
|
|
7476
7675
|
//
|
|
7477
7676
|
// Still asked when a person is at the terminal to answer, because that is
|
|
7478
|
-
// somebody who can say no.
|
|
7479
|
-
|
|
7480
|
-
|
|
7481
|
-
|
|
7677
|
+
// somebody who can say no -- and since 0.29 the answer is kept, so a
|
|
7678
|
+
// daemon started later does what the person said, not what it guesses.
|
|
7679
|
+
const recorded = readConfig().publish;
|
|
7680
|
+
const choice = publishChoice(options.publish, recorded, Boolean(process.stdin.isTTY), established);
|
|
7681
|
+
const wanted = choice === "ask"
|
|
7682
|
+
? await confirm(`\n List this stream at ${DEFAULT_DIRECTORY}/directory so anyone can find it?\n It publishes ${listen} — listen only, not the controls.`)
|
|
7683
|
+
: choice === "yes";
|
|
7684
|
+
if (shouldRecord(options.publish, recorded)) {
|
|
7685
|
+
try { writeConfig({ publish: wanted }); } catch { /* unwritable state: asked again next time */ }
|
|
7686
|
+
}
|
|
7687
|
+
if (!wanted && options.publish === "ask") {
|
|
7688
|
+
console.log("");
|
|
7689
|
+
console.log(` Not listed at ${DEFAULT_DIRECTORY}/directory. Start with --publish to list it.`);
|
|
7690
|
+
}
|
|
7482
7691
|
|
|
7483
7692
|
if (wanted) {
|
|
7484
7693
|
publisher = makePublisher();
|
package/src/share.ts
CHANGED
|
@@ -136,11 +136,12 @@ export function keyFrom(request: IncomingMessage, url: URL): string | null {
|
|
|
136
136
|
}
|
|
137
137
|
|
|
138
138
|
/** The Set-Cookie for a browser that just opened the link. */
|
|
139
|
-
export function keyCookie(key: string): string {
|
|
139
|
+
export function keyCookie(key: string, secure = false): string {
|
|
140
140
|
// HttpOnly because nothing in the page reads it: the browser attaches it to
|
|
141
|
-
// every same-origin request by itself.
|
|
142
|
-
// a plain-http address on your own network
|
|
143
|
-
|
|
141
|
+
// every same-origin request by itself. Secure only when the link was opened
|
|
142
|
+
// over https: a plain-http address on your own network is still a thing,
|
|
143
|
+
// and a Secure cookie there would never be sent back.
|
|
144
|
+
return `${KEY_COOKIE}=${encodeURIComponent(key)}; Path=/; Max-Age=31536000; SameSite=Lax; HttpOnly${secure ? "; Secure" : ""}`;
|
|
144
145
|
}
|
|
145
146
|
|
|
146
147
|
/**
|
|
@@ -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
|
+
}
|