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/dist/server.js
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.js";
|
|
13
|
+
import { protocolArgs } from "./decoder-policy.js";
|
|
14
|
+
import { createReadStream, existsSync, statSync } from "node:fs";
|
|
13
15
|
import { createServer as createHttpServer } from "node:http";
|
|
14
16
|
import { createServer as createHttpsServer } from "node:https";
|
|
15
17
|
import { randomBytes } from "node:crypto";
|
|
@@ -51,6 +53,11 @@ import { Directory, ENDED_TTL_MS, parseAnnouncement } from "./directory.js";
|
|
|
51
53
|
import { ArtCache, coverArtOf, stillFrom } from "./art.js";
|
|
52
54
|
import { PartyLine, telnyxSms } from "./partyline.js";
|
|
53
55
|
import { HlsPackagers, segmentType, withKey } from "./hls.js";
|
|
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";
|
|
54
61
|
import { CompressionService } from "./compression/service.js";
|
|
55
62
|
import { handleChannelCompression, handleCompressionApi, handleStaticRelay } from "./compression/routes.js";
|
|
56
63
|
import { DEFAULT_SITE as NICHEDB, Enricher, FIXTURE_TTL_MS } from "./enrich.js";
|
|
@@ -65,7 +72,7 @@ import { Porkbun, isIPv4, isIPv6 } from "./dns.js";
|
|
|
65
72
|
import { NameError, Names } from "./names.js";
|
|
66
73
|
import { AcmeIssuer, Certs } from "./certs.js";
|
|
67
74
|
import { claimName, fetchCert, humanizeSource, labelFor, readCertFiles, writeCertFiles } from "./naming.js";
|
|
68
|
-
import { forbiddenLibrary, readLibrary } from "./library.js";
|
|
75
|
+
import { forbiddenLibrary, readConfig, readLibrary, writeConfig } from "./library.js";
|
|
69
76
|
import { createThrottle, presentedCredential } from "@profullstack/throttle";
|
|
70
77
|
import { Durable } from "./durable.js";
|
|
71
78
|
import { notifyAll, resendEmail, webPush } from "./notify.js";
|
|
@@ -873,6 +880,63 @@ export function memberLiveRefusal(channels, account) {
|
|
|
873
880
|
}
|
|
874
881
|
return "";
|
|
875
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
|
+
}
|
|
876
940
|
/** How many times a source that answers nothing is asked, and how far apart. */
|
|
877
941
|
export const PROBE_TRIES = 3;
|
|
878
942
|
export const PROBE_RETRY_MS = 1500;
|
|
@@ -941,7 +1005,7 @@ export async function pullChannel(channels, ffprobe, id, name, source, input = [
|
|
|
941
1005
|
]
|
|
942
1006
|
// No picture in it, so none is invented: MP3 is the thing every browser
|
|
943
1007
|
// plays and the thing a listener can join halfway through.
|
|
944
|
-
: ["-vn", "-c:a", "libmp3lame", "-b:a",
|
|
1008
|
+
: ["-vn", "-c:a", "libmp3lame", "-b:a", `${known.audioBitrate ?? 192}k`, "-f", "mp3"];
|
|
945
1009
|
// A transport stream is read further into before it is decoded, and given
|
|
946
1010
|
// the timestamps a recording cut mid-stream does not carry. Ahead of the
|
|
947
1011
|
// caller's own input arguments, which are headers for the address itself.
|
|
@@ -950,7 +1014,7 @@ export async function pullChannel(channels, ffprobe, id, name, source, input = [
|
|
|
950
1014
|
// MP3's tags name the episode and the show, and "Inspiring Founders" is a
|
|
951
1015
|
// card; "ep-0412-final.mp3" is not.
|
|
952
1016
|
const tagged = looksLikeFileName(name) && codecs.tags?.title ? codecs.tags.title : name;
|
|
953
|
-
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 } : {}) },
|
|
954
1018
|
// Known before the first dial: whether the source is a transport stream
|
|
955
1019
|
// decides whether it can be read here for a source-boundary relay.
|
|
956
1020
|
assumed ? undefined : codecs);
|
|
@@ -1469,7 +1533,7 @@ export function createHandler(engine, options) {
|
|
|
1469
1533
|
json(response, 404, { error: "not found" });
|
|
1470
1534
|
return;
|
|
1471
1535
|
}
|
|
1472
|
-
response.writeHead(302, { ...CORS, "set-cookie": keyCookie(offered), location: "/" });
|
|
1536
|
+
response.writeHead(302, { ...CORS, "set-cookie": keyCookie(offered, request.socket.encrypted === true || (options.secureCookies ?? false)), location: "/" });
|
|
1473
1537
|
response.end();
|
|
1474
1538
|
return;
|
|
1475
1539
|
}
|
|
@@ -2061,12 +2125,21 @@ export function createHandler(engine, options) {
|
|
|
2061
2125
|
// port is open from another device before wondering whether the link is
|
|
2062
2126
|
// wrong, and it says nothing about the library.
|
|
2063
2127
|
const eventChannelRequest = Boolean(channelId && await eventForChannel());
|
|
2128
|
+
// Whether this request holds only the listen key. A listener may browse
|
|
2129
|
+
// and hear the library, but where it lives on this machine is the
|
|
2130
|
+
// owner's business: the absolute path names the user account and the disk.
|
|
2131
|
+
let listening = false;
|
|
2064
2132
|
if (key !== null &&
|
|
2065
2133
|
path !== "/api/health" &&
|
|
2066
2134
|
path !== "/api/directory" &&
|
|
2067
2135
|
!isSignInPath(path) &&
|
|
2136
|
+
// A station's link is its own key, checked by the station.
|
|
2137
|
+
!isStationListenPath(path) &&
|
|
2138
|
+
!STATION_PAGE.test(path) &&
|
|
2068
2139
|
!eventChannelRequest) {
|
|
2069
2140
|
let scope = scopeOf(keyFrom(request, url), key, listenKey);
|
|
2141
|
+
// Kept for the routes below that answer a listener with less.
|
|
2142
|
+
const settle = () => { listening = scope === "listen"; };
|
|
2070
2143
|
// A key is how somebody who was invited proves it. It is not the only way
|
|
2071
2144
|
// to be allowed in: the person who owns this server is allowed in whether
|
|
2072
2145
|
// or not they still have the link, and their nixamp.com session says who
|
|
@@ -2093,11 +2166,61 @@ export function createHandler(engine, options) {
|
|
|
2093
2166
|
});
|
|
2094
2167
|
return;
|
|
2095
2168
|
}
|
|
2169
|
+
settle();
|
|
2096
2170
|
if (scope === "listen" && !allowedForListening(path)) {
|
|
2097
2171
|
json(response, 403, { error: "this link can listen, not drive" });
|
|
2098
2172
|
return;
|
|
2099
2173
|
}
|
|
2100
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
|
+
}
|
|
2101
2224
|
if (path === "/api/health") {
|
|
2102
2225
|
json(response, 200, { name: "nixamp", version: options.version, media: options.media });
|
|
2103
2226
|
return;
|
|
@@ -3842,7 +3965,7 @@ export function createHandler(engine, options) {
|
|
|
3842
3965
|
// offering something that always failed.
|
|
3843
3966
|
carries: options.carries !== false && Boolean(options.channels),
|
|
3844
3967
|
},
|
|
3845
|
-
channels: (options.channels?.list() ?? []).map((one) => ({
|
|
3968
|
+
channels: (options.channels?.list() ?? []).filter((one) => !stationOfChannel(one.id)).map((one) => ({
|
|
3846
3969
|
id: one.id,
|
|
3847
3970
|
name: one.name,
|
|
3848
3971
|
via: one.via,
|
|
@@ -4204,11 +4327,32 @@ export function createHandler(engine, options) {
|
|
|
4204
4327
|
json(response, 429, { error: `this server is already carrying ${MAX_ON_DEMAND} channels on demand; try again in a minute` });
|
|
4205
4328
|
return;
|
|
4206
4329
|
}
|
|
4207
|
-
|
|
4330
|
+
// A member's link is fetched from inside the owner's network, so it has
|
|
4331
|
+
// to lead somewhere public: the link, every hop of a playlist, and
|
|
4332
|
+
// whatever yt-dlp says the media is. The owner's is theirs to aim.
|
|
4333
|
+
const guard = liveBy && !known ? (one) => refusedDestination(one) : undefined;
|
|
4334
|
+
if (guard) {
|
|
4335
|
+
const refused = await guard(link);
|
|
4336
|
+
if (refused) {
|
|
4337
|
+
json(response, 403, { error: refused });
|
|
4338
|
+
return;
|
|
4339
|
+
}
|
|
4340
|
+
}
|
|
4341
|
+
const resolved = known ?? await resolveLink(options.ytdlp ?? null, link, { cookies: options.cookies ?? "", guard });
|
|
4208
4342
|
if ("error" in resolved) {
|
|
4209
|
-
json(response, 422, { error: resolved.error });
|
|
4343
|
+
json(response, guard && resolved.error.includes("private network") ? 403 : 422, { error: resolved.error });
|
|
4210
4344
|
return;
|
|
4211
4345
|
}
|
|
4346
|
+
if (guard) {
|
|
4347
|
+
const reached = [resolved.media, resolved.audio, ...(resolved.playlist ?? [])].filter((one) => one && /^(https?|rtmps?):\/\//i.test(one));
|
|
4348
|
+
for (const one of [...new Set(reached)].slice(0, 200)) {
|
|
4349
|
+
const refused = await guard(one);
|
|
4350
|
+
if (refused) {
|
|
4351
|
+
json(response, 403, { error: refused });
|
|
4352
|
+
return;
|
|
4353
|
+
}
|
|
4354
|
+
}
|
|
4355
|
+
}
|
|
4212
4356
|
links.set(link, resolved);
|
|
4213
4357
|
// A channel already on -- put back by a restart, which forgets where
|
|
4214
4358
|
// it came from -- learns its picture and its line from the link pasted
|
|
@@ -4447,7 +4591,8 @@ export function createHandler(engine, options) {
|
|
|
4447
4591
|
// on, and the address a channel is pulled from is the one thing about
|
|
4448
4592
|
// it that is not theirs to have.
|
|
4449
4593
|
json(response, 200, {
|
|
4450
|
-
|
|
4594
|
+
// Stations answer for themselves, under /api/v1/stations.
|
|
4595
|
+
channels: options.channels.list().filter((one) => !stationOfChannel(one.id)).map(({ source: _source, ...shown }) => shown),
|
|
4451
4596
|
listeners: options.channels.listeners,
|
|
4452
4597
|
});
|
|
4453
4598
|
return;
|
|
@@ -4459,6 +4604,12 @@ export function createHandler(engine, options) {
|
|
|
4459
4604
|
const rest = path.slice("/api/channels/".length);
|
|
4460
4605
|
const [rawId, action, file] = rest.split("/");
|
|
4461
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
|
+
}
|
|
4462
4613
|
const event = await eventForChannel();
|
|
4463
4614
|
if (event) {
|
|
4464
4615
|
const account = await options.accounts?.whoIs(tokenFrom(request.headers)) ?? null;
|
|
@@ -5068,7 +5219,7 @@ export function createHandler(engine, options) {
|
|
|
5068
5219
|
return;
|
|
5069
5220
|
}
|
|
5070
5221
|
if (path === "/api/state") {
|
|
5071
|
-
json(response, 200, engine.snapshot());
|
|
5222
|
+
json(response, 200, listening ? { ...engine.snapshot(), root: "" } : engine.snapshot());
|
|
5072
5223
|
return;
|
|
5073
5224
|
}
|
|
5074
5225
|
// Everything the admin view draws, in one request: who is connected, and
|
|
@@ -5132,7 +5283,7 @@ export function createHandler(engine, options) {
|
|
|
5132
5283
|
"x-accel-buffering": "no",
|
|
5133
5284
|
});
|
|
5134
5285
|
const send = (snapshot) => {
|
|
5135
|
-
response.write(`data: ${JSON.stringify(snapshot)}\n\n`);
|
|
5286
|
+
response.write(`data: ${JSON.stringify(listening ? { ...snapshot, root: "" } : snapshot)}\n\n`);
|
|
5136
5287
|
};
|
|
5137
5288
|
const unsubscribe = engine.subscribe(send);
|
|
5138
5289
|
// A comment line keeps proxies from closing an idle stream.
|
|
@@ -5577,6 +5728,7 @@ async function liveAudio(request, response, engine, ffmpeg, ffprobe) {
|
|
|
5577
5728
|
// A live source has no beginning to seek from.
|
|
5578
5729
|
...(from > 1 && !isRemote(source) ? ["-ss", String(from)] : []),
|
|
5579
5730
|
"-re",
|
|
5731
|
+
...protocolArgs(source),
|
|
5580
5732
|
"-i", source,
|
|
5581
5733
|
...(asVideo
|
|
5582
5734
|
// Copied where it can be, because a room full of viewers is a room
|
|
@@ -5681,6 +5833,7 @@ inputArgs = []) {
|
|
|
5681
5833
|
// when they are handed to it for a file on disk.
|
|
5682
5834
|
...(isRemote(source) ? ["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5"] : []),
|
|
5683
5835
|
...inputArgs,
|
|
5836
|
+
...protocolArgs(source),
|
|
5684
5837
|
"-i", source,
|
|
5685
5838
|
...outputArgs,
|
|
5686
5839
|
"-",
|
|
@@ -5860,6 +6013,15 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
5860
6013
|
// still works after a restart -- and a server is restarted to pick up a new
|
|
5861
6014
|
// version, which is to say often. `--new-key` mints a fresh pair and forgets
|
|
5862
6015
|
// the old one, which is the way to revoke a link that got out.
|
|
6016
|
+
// Whether this port had keys before this start: a server that has run
|
|
6017
|
+
// here before, whose directory listing (see publishChoice) is kept.
|
|
6018
|
+
const established = existsSync(join(stateDir(), "keys.json")) &&
|
|
6019
|
+
(() => { try {
|
|
6020
|
+
return String(options.port) in JSON.parse(readFileSync(join(stateDir(), "keys.json"), "utf8"));
|
|
6021
|
+
}
|
|
6022
|
+
catch {
|
|
6023
|
+
return false;
|
|
6024
|
+
} })();
|
|
5863
6025
|
const remembered = options.key ? rememberedKeys(stateDir(), options.port, options.newKey) : null;
|
|
5864
6026
|
const key = remembered?.key ?? null;
|
|
5865
6027
|
// Kept whether or not it is published, so `nixamp admin` and the operator
|
|
@@ -5890,6 +6052,9 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
5890
6052
|
// same ffmpeg that carries the channels. No ffmpeg, no outro, and a show
|
|
5891
6053
|
// that ends closes its channel as before.
|
|
5892
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;
|
|
5893
6058
|
const channels = new Channels({
|
|
5894
6059
|
ffmpeg: tools.ffmpeg,
|
|
5895
6060
|
ffprobe: tools.ffprobe,
|
|
@@ -5899,8 +6064,24 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
5899
6064
|
onEnd: (info) => {
|
|
5900
6065
|
console.log(` "${info.id}" stopped.`);
|
|
5901
6066
|
art.forget(info.id);
|
|
6067
|
+
stationRunner?.onChannelEnd(info);
|
|
5902
6068
|
},
|
|
5903
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 };
|
|
5904
6085
|
// Channels as HLS, on demand, for Safari on a phone: one ffmpeg copying a
|
|
5905
6086
|
// channel's fragments into short files while somebody is asking for them.
|
|
5906
6087
|
// What things are, from nichedb.dev, remembered beside the keys so a
|
|
@@ -5949,6 +6130,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
5949
6130
|
console.log(` "${one.id}" is already on.`);
|
|
5950
6131
|
});
|
|
5951
6132
|
}
|
|
6133
|
+
stationRunner.restore();
|
|
5952
6134
|
// Where each film has got to, written down every so often, so a restart
|
|
5953
6135
|
// picks it up from about there rather than from the start. Only when it
|
|
5954
6136
|
// has changed: a server carrying nothing but live television writes
|
|
@@ -6478,6 +6660,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
6478
6660
|
media: options.media,
|
|
6479
6661
|
owner,
|
|
6480
6662
|
channels,
|
|
6663
|
+
stations,
|
|
6481
6664
|
rememberChannels: remembering,
|
|
6482
6665
|
catalogs,
|
|
6483
6666
|
publishUrls: () => publishUrls,
|
|
@@ -6887,6 +7070,11 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
6887
7070
|
* A declaration rather than an assignment, so it exists from the moment the
|
|
6888
7071
|
* function is entered -- the port is open well before this line is reached.
|
|
6889
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
|
+
}
|
|
6890
7078
|
function makePublisher() {
|
|
6891
7079
|
if (!publishable_)
|
|
6892
7080
|
return null;
|
|
@@ -6911,10 +7099,20 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
6911
7099
|
// which channels are on. A directory row that says "5,717 files,
|
|
6912
7100
|
// live: CNN" is one somebody can decide about; "5717 tracks" was not.
|
|
6913
7101
|
playing: () => engine.snapshot(false).playing,
|
|
6914
|
-
|
|
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),
|
|
6915
7104
|
// The same with what a link preview needs: the id a join link names
|
|
6916
7105
|
// a channel by, a picture, a line.
|
|
6917
|
-
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
|
+
}),
|
|
6918
7116
|
// From `nixamp login`. The directory will not list a stream it cannot
|
|
6919
7117
|
// attribute to somebody, because a listing is now a phone code that
|
|
6920
7118
|
// costs money to answer.
|
|
@@ -6959,10 +7157,23 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
6959
7157
|
// public at all.
|
|
6960
7158
|
//
|
|
6961
7159
|
// Still asked when a person is at the terminal to answer, because that is
|
|
6962
|
-
// somebody who can say no.
|
|
6963
|
-
|
|
6964
|
-
|
|
6965
|
-
|
|
7160
|
+
// somebody who can say no -- and since 0.29 the answer is kept, so a
|
|
7161
|
+
// daemon started later does what the person said, not what it guesses.
|
|
7162
|
+
const recorded = readConfig().publish;
|
|
7163
|
+
const choice = publishChoice(options.publish, recorded, Boolean(process.stdin.isTTY), established);
|
|
7164
|
+
const wanted = choice === "ask"
|
|
7165
|
+
? await confirm(`\n List this stream at ${DEFAULT_DIRECTORY}/directory so anyone can find it?\n It publishes ${listen} — listen only, not the controls.`)
|
|
7166
|
+
: choice === "yes";
|
|
7167
|
+
if (shouldRecord(options.publish, recorded)) {
|
|
7168
|
+
try {
|
|
7169
|
+
writeConfig({ publish: wanted });
|
|
7170
|
+
}
|
|
7171
|
+
catch { /* unwritable state: asked again next time */ }
|
|
7172
|
+
}
|
|
7173
|
+
if (!wanted && options.publish === "ask") {
|
|
7174
|
+
console.log("");
|
|
7175
|
+
console.log(` Not listed at ${DEFAULT_DIRECTORY}/directory. Start with --publish to list it.`);
|
|
7176
|
+
}
|
|
6966
7177
|
if (wanted) {
|
|
6967
7178
|
publisher = makePublisher();
|
|
6968
7179
|
listing = (await publisher?.start()) ?? null;
|
package/dist/share.d.ts
CHANGED
|
@@ -47,7 +47,7 @@ export declare function keysMatch(a: string, b: string): boolean;
|
|
|
47
47
|
/** Every place a key is accepted from, in the order they are looked for. */
|
|
48
48
|
export declare function keyFrom(request: IncomingMessage, url: URL): string | null;
|
|
49
49
|
/** The Set-Cookie for a browser that just opened the link. */
|
|
50
|
-
export declare function keyCookie(key: string): string;
|
|
50
|
+
export declare function keyCookie(key: string, secure?: boolean): string;
|
|
51
51
|
/** Where an address actually goes, which is not always where you would like. */
|
|
52
52
|
export declare function classify(address: string): "private" | "cgnat" | "public";
|
|
53
53
|
/** Looks like an address, rather than an error page or a rate-limit notice. */
|
package/dist/share.js
CHANGED
|
@@ -108,11 +108,12 @@ export function keyFrom(request, url) {
|
|
|
108
108
|
return null;
|
|
109
109
|
}
|
|
110
110
|
/** The Set-Cookie for a browser that just opened the link. */
|
|
111
|
-
export function keyCookie(key) {
|
|
111
|
+
export function keyCookie(key, secure = false) {
|
|
112
112
|
// HttpOnly because nothing in the page reads it: the browser attaches it to
|
|
113
|
-
// every same-origin request by itself.
|
|
114
|
-
// a plain-http address on your own network
|
|
115
|
-
|
|
113
|
+
// every same-origin request by itself. Secure only when the link was opened
|
|
114
|
+
// over https: a plain-http address on your own network is still a thing,
|
|
115
|
+
// and a Secure cookie there would never be sent back.
|
|
116
|
+
return `${KEY_COOKIE}=${encodeURIComponent(key)}; Path=/; Max-Age=31536000; SameSite=Lax; HttpOnly${secure ? "; Secure" : ""}`;
|
|
116
117
|
}
|
|
117
118
|
/**
|
|
118
119
|
* Interfaces that exist for containers and virtual machines. An address on one
|