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/dist/channels.js
CHANGED
|
@@ -1252,7 +1252,10 @@ export function rememberedChannels(dir, port) {
|
|
|
1252
1252
|
*/
|
|
1253
1253
|
export function rememberedNow(channels) {
|
|
1254
1254
|
return channels.list()
|
|
1255
|
-
|
|
1255
|
+
// A station's channel is put back by its station, which knows whether it
|
|
1256
|
+
// is still wanted; remembering it here too would bring back one its
|
|
1257
|
+
// owner had stopped.
|
|
1258
|
+
.filter((one) => one.via === "pull" && one.source && !channels.isEphemeral(one.id) && !one.id.startsWith("station-"))
|
|
1256
1259
|
.map((one) => {
|
|
1257
1260
|
const kept = { id: one.id, name: one.name, source: one.source };
|
|
1258
1261
|
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/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.
|
|
@@ -0,0 +1,201 @@
|
|
|
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.js";
|
|
15
|
+
import { readState } from "./daemon.js";
|
|
16
|
+
import { KEY_HEADER } from "./share.js";
|
|
17
|
+
import { USAGE } from "./station-usage.js";
|
|
18
|
+
export { USAGE };
|
|
19
|
+
function flag(argv, name) {
|
|
20
|
+
const at = argv.indexOf(name);
|
|
21
|
+
return at === -1 ? undefined : argv[at + 1];
|
|
22
|
+
}
|
|
23
|
+
function has(argv, name) {
|
|
24
|
+
return argv.includes(name);
|
|
25
|
+
}
|
|
26
|
+
class Unreachable extends Error {
|
|
27
|
+
}
|
|
28
|
+
export async function station(argv) {
|
|
29
|
+
const [command, id] = argv;
|
|
30
|
+
const json = has(argv, "--json");
|
|
31
|
+
if (!command || command === "help" || has(argv, "--help")) {
|
|
32
|
+
console.log(USAGE);
|
|
33
|
+
return command ? 0 : 2;
|
|
34
|
+
}
|
|
35
|
+
let target;
|
|
36
|
+
try {
|
|
37
|
+
target = resolveTarget(argv);
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
console.error(error.message);
|
|
41
|
+
return 3;
|
|
42
|
+
}
|
|
43
|
+
const headers = { "content-type": "application/json", ...(target.key ? { [KEY_HEADER]: target.key } : {}) };
|
|
44
|
+
const call = async (method, path, body) => {
|
|
45
|
+
let response;
|
|
46
|
+
try {
|
|
47
|
+
response = await fetch(`${target.url}${path}`, { method, headers, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
throw new Unreachable(`could not reach ${target.url}: ${error.message}`);
|
|
51
|
+
}
|
|
52
|
+
const text = await response.text();
|
|
53
|
+
let parsed = {};
|
|
54
|
+
try {
|
|
55
|
+
parsed = JSON.parse(text);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
parsed = { error: text };
|
|
59
|
+
}
|
|
60
|
+
return { status: response.status, body: parsed };
|
|
61
|
+
};
|
|
62
|
+
const needId = () => {
|
|
63
|
+
if (!id || id.startsWith("--")) {
|
|
64
|
+
console.error(`nixamp station ${command} needs a station id. \`nixamp station list\` shows them.`);
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
return id;
|
|
68
|
+
};
|
|
69
|
+
const show = (answer, line) => {
|
|
70
|
+
if (json) {
|
|
71
|
+
console.log(JSON.stringify(answer.body, null, 2));
|
|
72
|
+
}
|
|
73
|
+
else if (answer.status < 400) {
|
|
74
|
+
console.log(line(answer.body));
|
|
75
|
+
}
|
|
76
|
+
if (answer.status >= 400) {
|
|
77
|
+
if (!json)
|
|
78
|
+
console.error(`nixamp: ${String(answer.body["error"] ?? "refused")} (${String(answer.body["code"] ?? answer.status)})`);
|
|
79
|
+
return 1;
|
|
80
|
+
}
|
|
81
|
+
return 0;
|
|
82
|
+
};
|
|
83
|
+
const describe = (station) => {
|
|
84
|
+
const links = (station["links"] ?? {});
|
|
85
|
+
return [
|
|
86
|
+
`${String(station["name"])} [${String(station["id"])}]`,
|
|
87
|
+
` state ${String(station["state"])} (wanted ${String(station["desired"])})`,
|
|
88
|
+
` visibility ${String(station["visibility"])}${station["listed"] ? ", listed in the directory" : ""}`,
|
|
89
|
+
` rotation ${station["shuffle"] ? "shuffle" : "in order"}${station["repeat"] ? ", repeating" : ", once"}; ${station["items"]?.length ?? 0} files at ${String(station["bitrate"])} kbps`,
|
|
90
|
+
...(station["error"] ? [` problem ${String(station["error"])}`] : []),
|
|
91
|
+
...(station["excluded"]?.length ? [` left out ${station["excluded"].length} files (nixamp station retry)`] : []),
|
|
92
|
+
...(station["visibility"] !== "private" && links["page"] ? [` listen ${links["page"]}`] : []),
|
|
93
|
+
].join("\n");
|
|
94
|
+
};
|
|
95
|
+
try {
|
|
96
|
+
switch (command) {
|
|
97
|
+
case "list": {
|
|
98
|
+
const answer = await call("GET", "/api/v1/stations?limit=200");
|
|
99
|
+
return show(answer, (body) => {
|
|
100
|
+
const all = body["stations"] ?? [];
|
|
101
|
+
return all.length === 0 ? "No stations yet. nixamp station create --name NAME --folder PATH" : all.map(describe).join("\n\n");
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
case "create": {
|
|
105
|
+
const name = flag(argv, "--name");
|
|
106
|
+
if (!name) {
|
|
107
|
+
console.error("nixamp station create needs --name");
|
|
108
|
+
return 2;
|
|
109
|
+
}
|
|
110
|
+
const items = flag(argv, "--items");
|
|
111
|
+
let folder = flag(argv, "--folder");
|
|
112
|
+
if (folder !== undefined) {
|
|
113
|
+
// A path under the library is taken relative to it.
|
|
114
|
+
const root = readState()?.source ?? "";
|
|
115
|
+
if (root && folder.startsWith(root))
|
|
116
|
+
folder = folder.slice(root.length);
|
|
117
|
+
}
|
|
118
|
+
if (items === undefined && folder === undefined) {
|
|
119
|
+
console.error("nixamp station create needs --folder PATH or --items 0,1,2");
|
|
120
|
+
return 2;
|
|
121
|
+
}
|
|
122
|
+
const bitrate = flag(argv, "--bitrate");
|
|
123
|
+
const answer = await call("POST", "/api/v1/stations", {
|
|
124
|
+
name,
|
|
125
|
+
...(items !== undefined ? { items: items.split(",").map((one) => Number(one.trim())) } : { folder }),
|
|
126
|
+
shuffle: has(argv, "--shuffle"),
|
|
127
|
+
repeat: !has(argv, "--no-repeat"),
|
|
128
|
+
...(bitrate ? { bitrate: Number(bitrate) } : {}),
|
|
129
|
+
});
|
|
130
|
+
return show(answer, (body) => `Made (private, stopped):\n${describe(body["station"])}`);
|
|
131
|
+
}
|
|
132
|
+
case "show":
|
|
133
|
+
case "status": {
|
|
134
|
+
const station = needId();
|
|
135
|
+
if (!station)
|
|
136
|
+
return 2;
|
|
137
|
+
return show(await call("GET", `/api/v1/stations/${station}`), (body) => describe(body["station"]));
|
|
138
|
+
}
|
|
139
|
+
case "start":
|
|
140
|
+
case "stop":
|
|
141
|
+
case "skip":
|
|
142
|
+
case "retry": {
|
|
143
|
+
const station = needId();
|
|
144
|
+
if (!station)
|
|
145
|
+
return 2;
|
|
146
|
+
return show(await call("POST", `/api/v1/stations/${station}/${command}`, {}), (body) => describe(body["station"]));
|
|
147
|
+
}
|
|
148
|
+
case "publish": {
|
|
149
|
+
const station = needId();
|
|
150
|
+
if (!station)
|
|
151
|
+
return 2;
|
|
152
|
+
const basis = flag(argv, "--basis");
|
|
153
|
+
const unlisted = has(argv, "--unlisted");
|
|
154
|
+
const access = {
|
|
155
|
+
visibility: unlisted ? "unlisted" : "public",
|
|
156
|
+
listed: !unlisted && !has(argv, "--no-list"),
|
|
157
|
+
...(basis ? { rights: { basis, note: flag(argv, "--note") ?? "" } } : {}),
|
|
158
|
+
};
|
|
159
|
+
return show(await call("POST", `/api/v1/stations/${station}/access`, access), (body) => describe(body["station"]));
|
|
160
|
+
}
|
|
161
|
+
case "unpublish": {
|
|
162
|
+
const station = needId();
|
|
163
|
+
if (!station)
|
|
164
|
+
return 2;
|
|
165
|
+
const access = has(argv, "--private") ? { visibility: "private" } : { listed: false };
|
|
166
|
+
return show(await call("POST", `/api/v1/stations/${station}/access`, access), (body) => {
|
|
167
|
+
const dropped = Number(body["dropped"] ?? 0);
|
|
168
|
+
return `${describe(body["station"])}${dropped ? `\n ${dropped} listener(s) disconnected; old links no longer work.` : ""}`;
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
case "link": {
|
|
172
|
+
const station = needId();
|
|
173
|
+
if (!station)
|
|
174
|
+
return 2;
|
|
175
|
+
const answer = has(argv, "--rotate")
|
|
176
|
+
? await call("POST", `/api/v1/stations/${station}/rotate-link`, {})
|
|
177
|
+
: await call("GET", `/api/v1/stations/${station}`);
|
|
178
|
+
return show(answer, (body) => {
|
|
179
|
+
const links = (body["station"]["links"] ?? {});
|
|
180
|
+
return [`page ${links["page"]}`, `stream ${links["stream"]}`, `m3u ${links["m3u"]}`].join("\n");
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
case "delete": {
|
|
184
|
+
const station = needId();
|
|
185
|
+
if (!station)
|
|
186
|
+
return 2;
|
|
187
|
+
return show(await call("DELETE", `/api/v1/stations/${station}`), () => `Deleted ${station}. Its files were not touched.`);
|
|
188
|
+
}
|
|
189
|
+
default:
|
|
190
|
+
console.error(`nixamp station: no command "${command}".\n\n${USAGE}`);
|
|
191
|
+
return 2;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
if (error instanceof Unreachable) {
|
|
196
|
+
console.error(`nixamp: ${error.message}`);
|
|
197
|
+
return 3;
|
|
198
|
+
}
|
|
199
|
+
throw error;
|
|
200
|
+
}
|
|
201
|
+
}
|