nixamp 0.16.0 → 0.17.0
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/README.md +139 -0
- package/dist/audio.d.ts +70 -1
- package/dist/audio.js +84 -5
- package/dist/channels.d.ts +107 -3
- package/dist/channels.js +151 -3
- package/dist/compression/analyze.d.ts +105 -0
- package/dist/compression/analyze.js +213 -0
- package/dist/compression/blocks.d.ts +35 -0
- package/dist/compression/blocks.js +73 -0
- package/dist/compression/cli.d.ts +1 -0
- package/dist/compression/cli.js +347 -0
- package/dist/compression/codec.d.ts +66 -0
- package/dist/compression/codec.js +178 -0
- package/dist/compression/envelope.d.ts +77 -0
- package/dist/compression/envelope.js +191 -0
- package/dist/compression/jobs.d.ts +59 -0
- package/dist/compression/jobs.js +120 -0
- package/dist/compression/metrics.d.ts +62 -0
- package/dist/compression/metrics.js +64 -0
- package/dist/compression/policy.d.ts +76 -0
- package/dist/compression/policy.js +148 -0
- package/dist/compression/receiver.d.ts +51 -0
- package/dist/compression/receiver.js +92 -0
- package/dist/compression/relay.d.ts +134 -0
- package/dist/compression/relay.js +430 -0
- package/dist/compression/routes.d.ts +25 -0
- package/dist/compression/routes.js +266 -0
- package/dist/compression/service.d.ts +162 -0
- package/dist/compression/service.js +488 -0
- package/dist/compression/static.d.ts +65 -0
- package/dist/compression/static.js +248 -0
- package/dist/compression/store.d.ts +36 -0
- package/dist/compression/store.js +119 -0
- package/dist/compression/ts-transform.d.ts +44 -0
- package/dist/compression/ts-transform.js +148 -0
- package/dist/hls.d.ts +36 -3
- package/dist/hls.js +85 -11
- package/dist/layouts.d.ts +10 -0
- package/dist/layouts.js +71 -1
- package/dist/live-api.d.ts +16 -2
- package/dist/live-api.js +152 -19
- package/dist/live-events.d.ts +57 -2
- package/dist/live-events.js +243 -11
- package/dist/main.js +47 -0
- package/dist/mcp.d.ts +40 -0
- package/dist/mcp.js +255 -0
- package/dist/oauth-api.d.ts +50 -0
- package/dist/oauth-api.js +381 -0
- package/dist/oauth-server.d.ts +174 -0
- package/dist/oauth-server.js +559 -0
- package/dist/party.d.ts +32 -0
- package/dist/party.js +196 -0
- package/dist/playlist.d.ts +11 -0
- package/dist/playlist.js +27 -6
- package/dist/server.d.ts +37 -0
- package/dist/server.js +277 -45
- package/dist/sources.d.ts +16 -8
- package/dist/sources.js +102 -0
- package/dist/tickets.d.ts +67 -0
- package/dist/tickets.js +156 -0
- package/dist/tokens.d.ts +27 -2
- package/dist/tokens.js +42 -1
- package/dist/watch-party.d.ts +127 -0
- package/dist/watch-party.js +301 -0
- package/package.json +1 -1
- package/src/audio.ts +119 -6
- package/src/channels.ts +177 -7
- package/src/compression/analyze.ts +270 -0
- package/src/compression/blocks.ts +93 -0
- package/src/compression/cli.ts +340 -0
- package/src/compression/codec.ts +202 -0
- package/src/compression/envelope.ts +237 -0
- package/src/compression/jobs.ts +157 -0
- package/src/compression/metrics.ts +109 -0
- package/src/compression/policy.ts +171 -0
- package/src/compression/receiver.ts +107 -0
- package/src/compression/relay.ts +462 -0
- package/src/compression/routes.ts +294 -0
- package/src/compression/service.ts +525 -0
- package/src/compression/static.ts +264 -0
- package/src/compression/store.ts +126 -0
- package/src/compression/ts-transform.ts +148 -0
- package/src/hls.ts +100 -10
- package/src/layouts.ts +78 -1
- package/src/live-api.ts +172 -17
- package/src/live-events.ts +269 -11
- package/src/main.ts +47 -0
- package/src/mcp.ts +282 -0
- package/src/oauth-api.ts +481 -0
- package/src/oauth-server.ts +653 -0
- package/src/party.ts +216 -0
- package/src/playlist.ts +28 -5
- package/src/server.ts +296 -44
- package/src/sources.ts +100 -0
- package/src/tickets.ts +195 -0
- package/src/tokens.ts +50 -3
- package/src/watch-party.ts +390 -0
- package/web/dist/assets/{hls-3VKVEQE3-CrILISPJ.js → hls-3VKVEQE3-eV54kXE3.js} +1 -1
- package/web/dist/assets/index-CurZFzlH.css +1 -0
- package/web/dist/assets/index-DDzutJ75.js +1 -0
- package/web/dist/assets/{mpegts-CWeN63eG.js → mpegts-Buc3Odv6.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-DaMgRvlO.js → mpegts-LO6RVLD6-DcDKPB4P.js} +1 -1
- package/web/dist/index.html +59 -6
- package/web/dist/sw.js +6 -6
- package/web/dist/assets/index-CQ_m5HqS.css +0 -1
- package/web/dist/assets/index-CTxPM5KS.js +0 -1
package/src/server.ts
CHANGED
|
@@ -45,6 +45,9 @@ import {
|
|
|
45
45
|
signInFailedPage,
|
|
46
46
|
SignIn,
|
|
47
47
|
} from "./oauth.ts";
|
|
48
|
+
import { AuthorizationServer, clientsFrom } from "./oauth-server.ts";
|
|
49
|
+
import { handleOAuthApi, oauthApiPath } from "./oauth-api.ts";
|
|
50
|
+
import { WatchParties } from "./watch-party.ts";
|
|
48
51
|
import { needsAdmin, needsMember, Owner } from "./owner.ts";
|
|
49
52
|
import { createHash as sha } from "node:crypto";
|
|
50
53
|
import { playJingle } from "./jingle.ts";
|
|
@@ -52,7 +55,9 @@ import { stateDir } from "./daemon.ts";
|
|
|
52
55
|
import { readSession } from "./session.ts";
|
|
53
56
|
import { Directory, ENDED_TTL_MS, parseAnnouncement, type Listing } from "./directory.ts";
|
|
54
57
|
import { PartyLine, telnyxSms } from "./partyline.ts";
|
|
55
|
-
import { HlsPackagers, withKey } from "./hls.ts";
|
|
58
|
+
import { HlsPackagers, segmentType, withKey } from "./hls.ts";
|
|
59
|
+
import { CompressionService } from "./compression/service.ts";
|
|
60
|
+
import { handleChannelCompression, handleCompressionApi, handleStaticRelay, type RouteContext } from "./compression/routes.ts";
|
|
56
61
|
import { DEFAULT_SITE as NICHEDB, Enricher, type EnrichKind, FIXTURE_TTL_MS } from "./enrich.ts";
|
|
57
62
|
import {
|
|
58
63
|
contentTypeFor, downloadArgs, fileNameFor, inputArgsFor, linkChannelId, mergeDownloadArgs, playableLink, resolveLink,
|
|
@@ -74,7 +79,8 @@ import { Durable } from "./durable.ts";
|
|
|
74
79
|
import { notifyAll, resendEmail, webPush, type Notification } from "./notify.ts";
|
|
75
80
|
import { inviteSubject, inviteText, isEmail, isPhone, watchLink } from "./invite.ts";
|
|
76
81
|
import { handleLiveApi } from "./live-api.ts";
|
|
77
|
-
import { LiveEvents, type LiveEvent } from "./live-events.ts";
|
|
82
|
+
import { LiveEvents, eventStructuredData, isTicketed, type LiveEvent } from "./live-events.ts";
|
|
83
|
+
import { Tickets, needsTicket, ticketFrom, ticketsFromEnv } from "./tickets.ts";
|
|
78
84
|
import { Layouts } from "./layouts.ts";
|
|
79
85
|
import { Rooms } from "./rooms.ts";
|
|
80
86
|
import { confirm, DEFAULT_DIRECTORY, Publisher } from "./publish.ts";
|
|
@@ -85,8 +91,8 @@ import {
|
|
|
85
91
|
type PaywallConfig,
|
|
86
92
|
paywallFromEnv,
|
|
87
93
|
} from "./paywall.ts";
|
|
88
|
-
import { isRemote, playsInBrowser, sourceLabel } from "./sources.ts";
|
|
89
|
-
import { codecsOf, probeAsync, videoArgs, type Codecs } from "./audio.ts";
|
|
94
|
+
import { isRemote, isTransportStream, playsInBrowser, sourceLabel } from "./sources.ts";
|
|
95
|
+
import { codecsOf, probeAsync, transportInputArgs, videoArgs, type Codecs } from "./audio.ts";
|
|
90
96
|
import {
|
|
91
97
|
allowedForListening,
|
|
92
98
|
elevate,
|
|
@@ -550,11 +556,21 @@ export function toRemoteTracks(tracks: Loaded[]): RemoteTrack[] {
|
|
|
550
556
|
}
|
|
551
557
|
|
|
552
558
|
/** Video containers, as opposed to the songs that are most of a library. */
|
|
553
|
-
const PICTURE = new Set([
|
|
559
|
+
const PICTURE = new Set([
|
|
560
|
+
".mp4", ".mkv", ".avi", ".mov", ".m4v", ".webm", ".mpg", ".mpeg", ".wmv", ".flv",
|
|
561
|
+
// Raw transport streams: a recording off a card, a receiver or an IPTV
|
|
562
|
+
// dump, which is 1080p or 4K television and was arriving as its own
|
|
563
|
+
// soundtrack because none of these names were on this list.
|
|
564
|
+
".m2ts", ".mts", ".m2t", ".trp", ".tp",
|
|
565
|
+
]);
|
|
554
566
|
|
|
555
567
|
export function hasPicture(path: string): boolean {
|
|
556
568
|
const dot = path.lastIndexOf(".");
|
|
557
|
-
|
|
569
|
+
if (dot > 0 && PICTURE.has(path.slice(dot).toLowerCase())) return true;
|
|
570
|
+
// A `.ts` is a transport stream or a TypeScript file, and only its first
|
|
571
|
+
// bytes know which. Asked of the file rather than of the name, and the
|
|
572
|
+
// answer is remembered, because this is asked once per track per listing.
|
|
573
|
+
return isTransportStream(path);
|
|
558
574
|
}
|
|
559
575
|
|
|
560
576
|
/**
|
|
@@ -1042,6 +1058,22 @@ export interface KnownSource {
|
|
|
1042
1058
|
export const PROBE_TRIES = 3;
|
|
1043
1059
|
export const PROBE_RETRY_MS = 1500;
|
|
1044
1060
|
|
|
1061
|
+
/**
|
|
1062
|
+
* Whether a channel may carry H.265 as it is.
|
|
1063
|
+
*
|
|
1064
|
+
* A channel has one encode and many viewers, so it has to be something they
|
|
1065
|
+
* can all play, and H.265 is not that: Safari and televisions decode it,
|
|
1066
|
+
* Chrome on a desktop mostly does not and shows nothing rather than saying
|
|
1067
|
+
* so. So an HEVC source is re-encoded by default -- to 1080p H.264, because a
|
|
1068
|
+
* 4K re-encode does not keep up with playback -- and an operator whose
|
|
1069
|
+
* audience is phones and televisions can say `NIXAMP_HEVC_CHANNELS=1` and
|
|
1070
|
+
* have the 4K copied through untouched. A single viewer asking for a file
|
|
1071
|
+
* over /api/media is a different matter: there the browser says for itself.
|
|
1072
|
+
*/
|
|
1073
|
+
export function hevcChannelsAllowed(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
1074
|
+
return env["NIXAMP_HEVC_CHANNELS"] === "1";
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1045
1077
|
export async function pullChannel(
|
|
1046
1078
|
channels: Channels,
|
|
1047
1079
|
ffprobe: string[],
|
|
@@ -1092,16 +1124,27 @@ export async function pullChannel(
|
|
|
1092
1124
|
// from the first, the sound from the second. With one input ffmpeg
|
|
1093
1125
|
// picks for itself, as it always did.
|
|
1094
1126
|
...(audio ? ["-map", "0:v:0", "-map", "1:a:0"] : []),
|
|
1095
|
-
...videoArgs(codecs),
|
|
1127
|
+
...videoArgs(codecs, 0, { allowHevc: hevcChannelsAllowed() }),
|
|
1096
1128
|
]
|
|
1097
1129
|
// No picture in it, so none is invented: MP3 is the thing every browser
|
|
1098
1130
|
// plays and the thing a listener can join halfway through.
|
|
1099
1131
|
: ["-vn", "-c:a", "libmp3lame", "-b:a", "192k", "-f", "mp3"];
|
|
1132
|
+
// A transport stream is read further into before it is decoded, and given
|
|
1133
|
+
// the timestamps a recording cut mid-stream does not carry. Ahead of the
|
|
1134
|
+
// caller's own input arguments, which are headers for the address itself.
|
|
1135
|
+
const opening = [...transportInputArgs(source, codecs.container), ...input];
|
|
1100
1136
|
const channel = channels.pull(
|
|
1101
|
-
id, name, source, encode, kind, true, undefined,
|
|
1137
|
+
id, name, source, encode, kind, true, undefined, opening, kind === "video" ? audio : "",
|
|
1102
1138
|
{ live, position: known.position ?? 0 },
|
|
1103
1139
|
);
|
|
1104
1140
|
if (channel && !assumed) channel.info.codecs = codecs;
|
|
1141
|
+
// What comes out, as opposed to what went in. An H.265 source copied
|
|
1142
|
+
// through stays H.265; one re-encoded arrives as H.264, and a packager
|
|
1143
|
+
// told otherwise would cut fMP4 segments for a stream that did not need
|
|
1144
|
+
// them.
|
|
1145
|
+
if (channel && kind === "video") {
|
|
1146
|
+
channel.info.emits = encode.includes("libx264") ? "h264" : codecs.video || "h264";
|
|
1147
|
+
}
|
|
1105
1148
|
return channel;
|
|
1106
1149
|
}
|
|
1107
1150
|
|
|
@@ -1220,6 +1263,10 @@ export function isSignInPath(path: string): boolean {
|
|
|
1220
1263
|
// Public to read, so it must not be behind a share key either.
|
|
1221
1264
|
path === "/api/v1/opendirs" ||
|
|
1222
1265
|
path.startsWith("/api/v1/opendirs/") ||
|
|
1266
|
+
// nixamp as an authorization server: a client arriving here has no share
|
|
1267
|
+
// key and is not asking for one, and the metadata document is public by
|
|
1268
|
+
// the RFC that defines where it lives.
|
|
1269
|
+
oauthApiPath(path) ||
|
|
1223
1270
|
OAUTH_ROUTE.test(path)
|
|
1224
1271
|
);
|
|
1225
1272
|
}
|
|
@@ -1242,29 +1289,34 @@ function htmlText(value: string): string {
|
|
|
1242
1289
|
.replaceAll("'", "'");
|
|
1243
1290
|
}
|
|
1244
1291
|
|
|
1292
|
+
/**
|
|
1293
|
+
* Whose site this shell belongs to.
|
|
1294
|
+
*
|
|
1295
|
+
* Read from the page rather than hard-coded, because one NixAmp serves several
|
|
1296
|
+
* branded clients and each brings its own head. og:site_name first, then the
|
|
1297
|
+
* part of the title before the dash, then the hostname.
|
|
1298
|
+
*/
|
|
1299
|
+
export function brandOf(shell: string, site: string): string {
|
|
1300
|
+
const named = /<meta\s+property="og:site_name"\s+content="([^"]+)"/i.exec(shell);
|
|
1301
|
+
if (named?.[1]) return named[1];
|
|
1302
|
+
const titled = /<title>([^<]*)<\/title>/i.exec(shell);
|
|
1303
|
+
const head = titled?.[1]?.split(/\s[—-]\s/)[0]?.trim();
|
|
1304
|
+
if (head) return head;
|
|
1305
|
+
try {
|
|
1306
|
+
return new URL(site).hostname.replace(/^www\./, "");
|
|
1307
|
+
} catch {
|
|
1308
|
+
return "NixAmp";
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1245
1312
|
function eventDocument(shell: string, event: Awaited<ReturnType<LiveEvents["get"]>>, site: string): string {
|
|
1246
1313
|
if (!event) return shell;
|
|
1247
|
-
const
|
|
1248
|
-
const
|
|
1314
|
+
const brand = brandOf(shell, site);
|
|
1315
|
+
const title = `${event.title} — ${brand}`;
|
|
1316
|
+
const kindWord = event.kind === "concert" ? "Watch" : "Listen to";
|
|
1317
|
+
const description = event.description || `${kindWord} ${event.title} live on ${brand}.`;
|
|
1249
1318
|
const canonical = `${site.replace(/\/$/, "")}/live/${encodeURIComponent(event.slug)}`;
|
|
1250
|
-
const structured = JSON.stringify(
|
|
1251
|
-
"@context": "https://schema.org",
|
|
1252
|
-
"@type": "Event",
|
|
1253
|
-
name: event.title,
|
|
1254
|
-
description,
|
|
1255
|
-
eventStatus: event.status === "cancelled"
|
|
1256
|
-
? "https://schema.org/EventCancelled"
|
|
1257
|
-
: event.status === "live"
|
|
1258
|
-
? "https://schema.org/EventInProgress"
|
|
1259
|
-
: event.status === "ended"
|
|
1260
|
-
? "https://schema.org/EventCompleted"
|
|
1261
|
-
: "https://schema.org/EventScheduled",
|
|
1262
|
-
eventAttendanceMode: "https://schema.org/OnlineEventAttendanceMode",
|
|
1263
|
-
...(event.startsAt ? { startDate: event.startsAt } : {}),
|
|
1264
|
-
...(event.endsAt ? { endDate: event.endsAt } : {}),
|
|
1265
|
-
url: canonical,
|
|
1266
|
-
location: { "@type": "VirtualLocation", url: canonical },
|
|
1267
|
-
}).replaceAll("<", "\\u003c");
|
|
1319
|
+
const structured = JSON.stringify(eventStructuredData(event, canonical)).replaceAll("<", "\\u003c");
|
|
1268
1320
|
return shell
|
|
1269
1321
|
.replace(/<title>.*?<\/title>/s, `<title>${htmlText(title)}</title>`)
|
|
1270
1322
|
.replace(/<meta name="description" content="[^"]*"\s*\/>/, `<meta name="description" content="${htmlText(description)}" />`)
|
|
@@ -1316,6 +1368,8 @@ export interface HandlerOptions {
|
|
|
1316
1368
|
ytdlp?: string[] | null;
|
|
1317
1369
|
/** Channels as HLS, for Safari on a phone, which plays a live stream no other way. */
|
|
1318
1370
|
hls?: HlsPackagers;
|
|
1371
|
+
/** Lossless relay compression, its policies, diagnostics and static representations. */
|
|
1372
|
+
compression?: CompressionService;
|
|
1319
1373
|
/** What a name is -- a film, a channel, a fixture -- asked of nichedb.dev and remembered. */
|
|
1320
1374
|
enricher?: Enricher;
|
|
1321
1375
|
/** A Netscape cookies file for sites that want a signed-in browser, when there is one. */
|
|
@@ -1440,6 +1494,16 @@ export interface HandlerOptions {
|
|
|
1440
1494
|
layouts?: Layouts;
|
|
1441
1495
|
/** Persistent participation state that must not be coupled to live audio. */
|
|
1442
1496
|
rooms?: Rooms;
|
|
1497
|
+
/** Tickets: a paid pass to one event's room. Absent means every show is free. */
|
|
1498
|
+
tickets?: Tickets;
|
|
1499
|
+
/**
|
|
1500
|
+
* nixamp as an OAuth 2.1 authorization server, so another site can act on
|
|
1501
|
+
* an account here. nixamp.com only: a nixamp on a laptop keeps no accounts
|
|
1502
|
+
* and so has nobody to authorize.
|
|
1503
|
+
*/
|
|
1504
|
+
authServer?: AuthorizationServer;
|
|
1505
|
+
/** Watch parties bridged from a client site into nixamp rooms. */
|
|
1506
|
+
parties?: WatchParties;
|
|
1443
1507
|
/**
|
|
1444
1508
|
* How an invite is sent: by email, by text, and the site the watch link is
|
|
1445
1509
|
* built on. nixamp.com only; a personal nixamp has no mail to send from.
|
|
@@ -1514,6 +1578,24 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
1514
1578
|
}
|
|
1515
1579
|
return channelEvent;
|
|
1516
1580
|
};
|
|
1581
|
+
// What the compression routes need from this handler: how to answer,
|
|
1582
|
+
// how to count a stream, and whether this caller holds the controls.
|
|
1583
|
+
const compressionCtx = (): RouteContext | null =>
|
|
1584
|
+
options.compression
|
|
1585
|
+
? {
|
|
1586
|
+
service: options.compression,
|
|
1587
|
+
json,
|
|
1588
|
+
readBody: (incoming) => readBody(incoming),
|
|
1589
|
+
cors: CORS,
|
|
1590
|
+
watch: (incoming, outgoing, track) => watch(incoming, outgoing, "stream", track),
|
|
1591
|
+
controls: async () => {
|
|
1592
|
+
if (key === null) return true;
|
|
1593
|
+
if (scopeOf(keyFrom(request, url), key, null) === "control") return true;
|
|
1594
|
+
if (options.owner) return (await options.owner.check(false, tokenFrom(request.headers))).allowed;
|
|
1595
|
+
return false;
|
|
1596
|
+
},
|
|
1597
|
+
}
|
|
1598
|
+
: null;
|
|
1517
1599
|
|
|
1518
1600
|
if (request.method === "OPTIONS") {
|
|
1519
1601
|
response.writeHead(204, CORS);
|
|
@@ -1555,11 +1637,23 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
1555
1637
|
return;
|
|
1556
1638
|
}
|
|
1557
1639
|
|
|
1640
|
+
// nixamp as an authorization server, and the watch parties that pairing
|
|
1641
|
+
// exists for. Before the share-key check for the same reason sign-in is:
|
|
1642
|
+
// a client site holds an account's token, never a server's key.
|
|
1643
|
+
if (options.authServer && options.accounts && await handleOAuthApi(request, response, url, {
|
|
1644
|
+
server: options.authServer,
|
|
1645
|
+
accounts: options.accounts,
|
|
1646
|
+
...(options.parties ? { parties: options.parties } : {}),
|
|
1647
|
+
...(options.handles ? { handles: options.handles } : {}),
|
|
1648
|
+
secureCookies: options.secureCookies ?? false,
|
|
1649
|
+
})) return;
|
|
1650
|
+
|
|
1558
1651
|
if (options.events && await handleLiveApi(request, response, url, {
|
|
1559
1652
|
events: options.events,
|
|
1560
1653
|
...(options.accounts ? { accounts: options.accounts } : {}),
|
|
1561
1654
|
...(options.layouts ? { layouts: options.layouts } : {}),
|
|
1562
1655
|
...(options.rooms ? { rooms: options.rooms } : {}),
|
|
1656
|
+
...(options.tickets ? { tickets: options.tickets } : {}),
|
|
1563
1657
|
...(options.invites?.site ? { site: options.invites.site } : {}),
|
|
1564
1658
|
...(options.invites?.email ? { email: options.invites.email } : {}),
|
|
1565
1659
|
})) return;
|
|
@@ -3011,7 +3105,11 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
3011
3105
|
watch(request, response, "stream", entry.title);
|
|
3012
3106
|
const codecs = await codecsOf({ ffmpeg: [], ffprobe: options.ffprobe ?? ["ffprobe"], play: null }, entry.source);
|
|
3013
3107
|
if (codecs.video !== "") {
|
|
3014
|
-
pipeFfmpeg(
|
|
3108
|
+
pipeFfmpeg(
|
|
3109
|
+
request, response, entry.source, options.ffmpeg ?? ["ffmpeg"],
|
|
3110
|
+
videoArgs(codecs, 0, { allowHevc: url.searchParams.get("hevc") === "1" }), "video/mp4",
|
|
3111
|
+
transportInputArgs(entry.source, codecs.container),
|
|
3112
|
+
);
|
|
3015
3113
|
} else {
|
|
3016
3114
|
transcode(request, response, entry.source, options.ffmpeg ?? ["ffmpeg"]);
|
|
3017
3115
|
}
|
|
@@ -3246,6 +3344,12 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
3246
3344
|
// A channel is one publisher and everybody listening to them. Two or three
|
|
3247
3345
|
// devices can publish at once, each to their own channel, and a listener
|
|
3248
3346
|
// picks which to hear.
|
|
3347
|
+
// The server's compression at a glance, its switch, and the analyses.
|
|
3348
|
+
if (path.startsWith("/api/compression")) {
|
|
3349
|
+
const compression = compressionCtx();
|
|
3350
|
+
if (compression && (await handleCompressionApi(request, response, compression, path))) return;
|
|
3351
|
+
}
|
|
3352
|
+
|
|
3249
3353
|
if (path === "/api/channels" && options.channels) {
|
|
3250
3354
|
// Without the source. Anyone holding the listen link may ask what is
|
|
3251
3355
|
// on, and the address a channel is pulled from is the one thing about
|
|
@@ -3273,17 +3377,40 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
3273
3377
|
json(response, 404, { error: "nothing is playing on that channel" });
|
|
3274
3378
|
return;
|
|
3275
3379
|
}
|
|
3276
|
-
|
|
3277
|
-
|
|
3380
|
+
// A ticketed room is the one place a 402 belongs on the media
|
|
3381
|
+
// itself: there is a thing to buy, it is this, and the answer says
|
|
3382
|
+
// what it costs rather than pretending the room is not there.
|
|
3383
|
+
if (isTicketed(event)) {
|
|
3384
|
+
const held = await options.tickets?.holds(event, ticketFrom(request.headers, url)) ?? false;
|
|
3385
|
+
if (needsTicket(event, account?.id, held)) {
|
|
3386
|
+
json(response, 402, {
|
|
3387
|
+
error: `${event.title} is a ticketed show.`,
|
|
3388
|
+
ticket: options.tickets?.offer(event) ?? {
|
|
3389
|
+
ticketed: true,
|
|
3390
|
+
priceCents: event.ticketPriceCents,
|
|
3391
|
+
currency: event.ticketCurrency,
|
|
3392
|
+
},
|
|
3393
|
+
});
|
|
3394
|
+
return;
|
|
3395
|
+
}
|
|
3396
|
+
}
|
|
3397
|
+
} else if (!options.events!.canPerform(event, account?.id)) {
|
|
3398
|
+
json(response, account ? 403 : 401, { error: account ? "only the people on this stage can publish here" : "sign in to host this event" });
|
|
3278
3399
|
return;
|
|
3279
3400
|
} else if (request.method === "POST" &&
|
|
3280
3401
|
(action === undefined || action === "chunk") &&
|
|
3281
|
-
event.status !== "live") {
|
|
3402
|
+
event.status !== "live" && event.status !== "encore") {
|
|
3282
3403
|
json(response, 409, { error: "start the event before publishing audio" });
|
|
3283
3404
|
return;
|
|
3284
3405
|
}
|
|
3285
3406
|
}
|
|
3286
3407
|
|
|
3408
|
+
// Its compression policy, an analysis of it, and its relay to or from
|
|
3409
|
+
// another nixamp. The ordinary playback URL above is untouched by any
|
|
3410
|
+
// of it: a relay is a different media type on a different path.
|
|
3411
|
+
const compression = compressionCtx();
|
|
3412
|
+
if (compression && (await handleChannelCompression(request, response, compression, id, action, file))) return;
|
|
3413
|
+
|
|
3287
3414
|
// The same channel as HLS: a playlist of short files, which is what
|
|
3288
3415
|
// Safari on an iPhone plays live -- it will not take the endless MP4
|
|
3289
3416
|
// below, and spun on it a few times before giving up. Packaged on
|
|
@@ -3298,7 +3425,10 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
3298
3425
|
return;
|
|
3299
3426
|
}
|
|
3300
3427
|
if (file === "index.m3u8") {
|
|
3301
|
-
|
|
3428
|
+
// A channel carrying H.265 is cut into fMP4 rather than transport
|
|
3429
|
+
// segments: HLS in TS is defined for H.264 only, and Safari plays
|
|
3430
|
+
// an HEVC channel packaged as TS as sound over a black screen.
|
|
3431
|
+
const playlist = await options.hls.playlist(id, channels.info(id)?.emits === "hevc");
|
|
3302
3432
|
if (playlist === null) {
|
|
3303
3433
|
json(response, 503, { error: "that channel could not be packaged as HLS yet; try again in a moment" });
|
|
3304
3434
|
return;
|
|
@@ -3321,7 +3451,8 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
3321
3451
|
watch(request, response, "stream", id);
|
|
3322
3452
|
response.writeHead(200, {
|
|
3323
3453
|
...CORS,
|
|
3324
|
-
|
|
3454
|
+
// By its name: a TS segment, an fMP4 media segment, or the init.
|
|
3455
|
+
"content-type": segmentType(file ?? ""),
|
|
3325
3456
|
"cache-control": "no-store",
|
|
3326
3457
|
"content-length": statSync(segment).size,
|
|
3327
3458
|
});
|
|
@@ -3365,14 +3496,17 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
3365
3496
|
}
|
|
3366
3497
|
|
|
3367
3498
|
if (action === undefined && request.method === "DELETE") {
|
|
3368
|
-
// Asked once: the second call would answer false, having just stopped
|
|
3369
|
-
// the thing it was asking about.
|
|
3370
3499
|
// A member takes off what they put on, and nothing else.
|
|
3371
3500
|
if (liveBy && channels.info(id)?.startedBy !== liveBy) {
|
|
3372
3501
|
json(response, 403, { error: "that stream is not yours to take off" });
|
|
3373
3502
|
return;
|
|
3374
3503
|
}
|
|
3375
|
-
|
|
3504
|
+
// A channel fed by another nixamp's relay is taken off by stopping
|
|
3505
|
+
// the relay, which takes the channel with it; otherwise it would
|
|
3506
|
+
// dial again the moment the channel went. Asked once either way:
|
|
3507
|
+
// the second call would answer false, having just stopped the
|
|
3508
|
+
// thing it was asking about.
|
|
3509
|
+
const stopped = (options.compression?.stopPull(id) ?? false) || channels.stop(id);
|
|
3376
3510
|
// Taken off on purpose is forgotten on purpose: it must not come back
|
|
3377
3511
|
// at the next restart.
|
|
3378
3512
|
if (stopped) {
|
|
@@ -3380,7 +3514,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
3380
3514
|
// Off the air is news too: told now, so the directory drops it
|
|
3381
3515
|
// rather than listing it until the next heartbeat.
|
|
3382
3516
|
void options.live?.announce?.();
|
|
3383
|
-
if (event && event.status === "live") {
|
|
3517
|
+
if (event && (event.status === "live" || event.status === "encore")) {
|
|
3384
3518
|
await options.events?.transition(event.id, event.ownerId, "ended", event.version).catch(() => undefined);
|
|
3385
3519
|
}
|
|
3386
3520
|
}
|
|
@@ -3883,6 +4017,30 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
3883
4017
|
return;
|
|
3884
4018
|
}
|
|
3885
4019
|
|
|
4020
|
+
// A library file in the relay envelope: the same bytes as /api/media/N,
|
|
4021
|
+
// framed and compressed once and kept. The original stays where it is
|
|
4022
|
+
// and keeps answering ranges; this cannot be asked for one.
|
|
4023
|
+
const staticRelay = /^\/api\/media\/(\d+)\/relay$/.exec(path);
|
|
4024
|
+
if (staticRelay && request.method === "GET") {
|
|
4025
|
+
const compression = compressionCtx();
|
|
4026
|
+
if (!compression) {
|
|
4027
|
+
json(response, 503, { error: "this server keeps no relay representations" });
|
|
4028
|
+
return;
|
|
4029
|
+
}
|
|
4030
|
+
if (!options.media) {
|
|
4031
|
+
json(response, 403, { error: "media streaming is off" });
|
|
4032
|
+
return;
|
|
4033
|
+
}
|
|
4034
|
+
const index = Number(staticRelay[1]);
|
|
4035
|
+
const file = engine.trackPath(index);
|
|
4036
|
+
if (file === undefined) {
|
|
4037
|
+
json(response, 404, { error: "no such track" });
|
|
4038
|
+
return;
|
|
4039
|
+
}
|
|
4040
|
+
handleStaticRelay(request, response, compression, file, engine.snapshot().tracks?.[index]?.title ?? file);
|
|
4041
|
+
return;
|
|
4042
|
+
}
|
|
4043
|
+
|
|
3886
4044
|
if (path.startsWith("/api/media/")) {
|
|
3887
4045
|
if (!options.media) {
|
|
3888
4046
|
json(response, 403, { error: "media streaming is off" });
|
|
@@ -3904,6 +4062,11 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
3904
4062
|
// and above 20 megabits the original was always the better answer.
|
|
3905
4063
|
const asked = Number(url.searchParams.get("kbps") ?? "");
|
|
3906
4064
|
const capKbps = Number.isFinite(asked) && asked > 0 ? Math.min(20_000, Math.max(200, asked)) : 0;
|
|
4065
|
+
// Whether this browser decodes H.265, which only it can know: Safari and
|
|
4066
|
+
// televisions do, Chrome on a desktop does not and says nothing when
|
|
4067
|
+
// handed it. Copying a 4K HEVC film is free; re-encoding one does not
|
|
4068
|
+
// keep up with playing it, so the answer is worth carrying in the URL.
|
|
4069
|
+
const allowHevc = url.searchParams.get("hevc") === "1";
|
|
3907
4070
|
|
|
3908
4071
|
if (playsInBrowser(file) && capKbps === 0) {
|
|
3909
4072
|
sendFile(request, response, file);
|
|
@@ -3922,7 +4085,11 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
3922
4085
|
// added by an older nixamp goes to an audio element for ever, and
|
|
3923
4086
|
// the only cure is noticing and adding it again.
|
|
3924
4087
|
engine.sawPicture(index);
|
|
3925
|
-
pipeFfmpeg(
|
|
4088
|
+
pipeFfmpeg(
|
|
4089
|
+
request, response, file, options.ffmpeg ?? ["ffmpeg"],
|
|
4090
|
+
videoArgs(codecs, capKbps, { allowHevc }), "video/mp4",
|
|
4091
|
+
transportInputArgs(file, codecs.container),
|
|
4092
|
+
);
|
|
3926
4093
|
return;
|
|
3927
4094
|
}
|
|
3928
4095
|
}
|
|
@@ -4240,6 +4407,12 @@ function pipeFfmpeg(
|
|
|
4240
4407
|
ffmpeg: string[],
|
|
4241
4408
|
outputArgs: string[],
|
|
4242
4409
|
contentType: string,
|
|
4410
|
+
/**
|
|
4411
|
+
* What to say before the input is opened. A transport stream needs telling
|
|
4412
|
+
* how far to read before it decides what is in it, and to make up the
|
|
4413
|
+
* timestamps a recording cut mid-stream does not have.
|
|
4414
|
+
*/
|
|
4415
|
+
inputArgs: string[] = [],
|
|
4243
4416
|
): void {
|
|
4244
4417
|
const [command, ...prefix] = ffmpeg as [string, ...string[]];
|
|
4245
4418
|
const child = spawn(
|
|
@@ -4252,6 +4425,7 @@ function pipeFfmpeg(
|
|
|
4252
4425
|
// belong to the http protocol, and ffmpeg rejects the whole command
|
|
4253
4426
|
// when they are handed to it for a file on disk.
|
|
4254
4427
|
...(isRemote(source) ? ["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5"] : []),
|
|
4428
|
+
...inputArgs,
|
|
4255
4429
|
"-i", source,
|
|
4256
4430
|
...outputArgs,
|
|
4257
4431
|
"-",
|
|
@@ -4469,10 +4643,22 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
4469
4643
|
cacheFile: join(stateDir(), "enrich.json"),
|
|
4470
4644
|
onEvent: (message) => console.log(message),
|
|
4471
4645
|
});
|
|
4646
|
+
// Off unless a policy says otherwise: every channel plays exactly as it
|
|
4647
|
+
// did, and a relay is a thing another nixamp asks for by name.
|
|
4648
|
+
const compression = new CompressionService({
|
|
4649
|
+
channels,
|
|
4650
|
+
stateDir: stateDir(),
|
|
4651
|
+
port: options.port,
|
|
4652
|
+
ffprobe: tools.ffprobe,
|
|
4653
|
+
cacheDir: join(stateDir(), "relay-cache"),
|
|
4654
|
+
onEvent: (message) => console.log(message),
|
|
4655
|
+
});
|
|
4472
4656
|
const hls = new HlsPackagers({
|
|
4473
4657
|
ffmpeg: tools.ffmpeg,
|
|
4474
4658
|
listen: (id, listener) => channels.listen(id, listener),
|
|
4475
4659
|
onEvent: (message) => console.log(message),
|
|
4660
|
+
// TS unless the channel's policy, or the server's, asks for fMP4.
|
|
4661
|
+
packaging: (id) => compression.packagingOf(id),
|
|
4476
4662
|
});
|
|
4477
4663
|
|
|
4478
4664
|
// The channels this server was carrying when it was last stopped, put back
|
|
@@ -4536,9 +4722,67 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
4536
4722
|
: undefined;
|
|
4537
4723
|
const follows = pool ? new Follows(pool) : undefined;
|
|
4538
4724
|
const favorites = pool ? new Favorites(pool) : undefined;
|
|
4725
|
+
const nixampSite = (process.env["NIXAMP_SITE"] ?? DEFAULT_DIRECTORY).replace(/\/+$/, "");
|
|
4539
4726
|
const events = pool ? new LiveEvents(pool) : undefined;
|
|
4540
4727
|
const layouts = pool ? new Layouts(pool) : undefined;
|
|
4541
4728
|
const rooms = pool ? new Rooms(pool) : undefined;
|
|
4729
|
+
// Tickets need somewhere for the money to go and a key to settle it with.
|
|
4730
|
+
// Without a CoinPay key every event is simply a free one.
|
|
4731
|
+
const ticketConfig = events
|
|
4732
|
+
? ticketsFromEnv(process.env["NIXAMP_SITE"] ?? DEFAULT_DIRECTORY)
|
|
4733
|
+
: null;
|
|
4734
|
+
const tickets = ticketConfig ? new Tickets(ticketConfig) : undefined;
|
|
4735
|
+
|
|
4736
|
+
// Accounts live where the directory lives, and only there: a nixamp on a
|
|
4737
|
+
// laptop has nobody to be an account of. Made here rather than inline in
|
|
4738
|
+
// the handler options because the authorization server issues its access
|
|
4739
|
+
// tokens out of the same table -- an OAuth token IS a nixamp token with a
|
|
4740
|
+
// client's name on it, which is why every existing route understands one.
|
|
4741
|
+
const accounts =
|
|
4742
|
+
options.directory && process.env["DATABASE_URL"]
|
|
4743
|
+
? new Accounts({
|
|
4744
|
+
connectionString: process.env["DATABASE_URL"],
|
|
4745
|
+
secret: process.env["NIXAMP_JWT_SECRET"] ?? "",
|
|
4746
|
+
})
|
|
4747
|
+
: undefined;
|
|
4748
|
+
|
|
4749
|
+
// nixamp as an OAuth 2.1 authorization server, and the watch parties a
|
|
4750
|
+
// client site bridges through it. Both need the same three things -- a
|
|
4751
|
+
// database, accounts, and a site to be the issuer of -- so both appear or
|
|
4752
|
+
// neither does.
|
|
4753
|
+
const authServer =
|
|
4754
|
+
pool && accounts?.tokens
|
|
4755
|
+
? new AuthorizationServer({
|
|
4756
|
+
db: pool,
|
|
4757
|
+
tokens: accounts.tokens,
|
|
4758
|
+
clients: clientsFrom(process.env),
|
|
4759
|
+
issuer: nixampSite,
|
|
4760
|
+
})
|
|
4761
|
+
: undefined;
|
|
4762
|
+
const parties =
|
|
4763
|
+
pool && events && authServer
|
|
4764
|
+
? new WatchParties({
|
|
4765
|
+
db: pool,
|
|
4766
|
+
events,
|
|
4767
|
+
site: nixampSite,
|
|
4768
|
+
// A client may only point a watch link at its own site, which is
|
|
4769
|
+
// read off the redirect URIs it registered rather than configured
|
|
4770
|
+
// twice. Without it, "come and watch" could be sent anywhere.
|
|
4771
|
+
hostsFor: (origin) => {
|
|
4772
|
+
const client = authServer.client(origin);
|
|
4773
|
+
if (!client) return [];
|
|
4774
|
+
const hosts = new Set<string>();
|
|
4775
|
+
for (const uri of client.redirectUris) {
|
|
4776
|
+
try {
|
|
4777
|
+
hosts.add(new URL(uri).hostname);
|
|
4778
|
+
} catch {
|
|
4779
|
+
// A malformed registration names no host, which allows none.
|
|
4780
|
+
}
|
|
4781
|
+
}
|
|
4782
|
+
return [...hosts];
|
|
4783
|
+
},
|
|
4784
|
+
})
|
|
4785
|
+
: undefined;
|
|
4542
4786
|
|
|
4543
4787
|
// Names and certificates for signed-in servers, and the rate limit over
|
|
4544
4788
|
// everything. All of it is nixamp.com's business: the DNS keys live only
|
|
@@ -4579,6 +4823,12 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
4579
4823
|
{ path: "/api/v1/invite", limit: 10 },
|
|
4580
4824
|
{ path: "/api/v1/dns", limit: 30 },
|
|
4581
4825
|
{ path: "/api/v1/certs", limit: 30 },
|
|
4826
|
+
// A token endpoint is where a stolen code or refresh token would be
|
|
4827
|
+
// tried, so it is address-bucketed: a client presenting its own id
|
|
4828
|
+
// must not get the credentialed budget to guess with.
|
|
4829
|
+
{ path: "/api/v1/oauth/token", limit: 30, credential: false },
|
|
4830
|
+
{ path: "/api/v1/oauth/", limit: 60, credential: false },
|
|
4831
|
+
{ path: "/api/v1/watch-parties", limit: 60 },
|
|
4582
4832
|
{ path: "/api/health", open: true },
|
|
4583
4833
|
{ path: "/api/directory", limit: 120 },
|
|
4584
4834
|
],
|
|
@@ -4869,6 +5119,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
4869
5119
|
ytdlp: tools.ytdlp ?? null,
|
|
4870
5120
|
cookies: cookiesFile(),
|
|
4871
5121
|
hls,
|
|
5122
|
+
compression,
|
|
4872
5123
|
enricher,
|
|
4873
5124
|
...(tls ? { tls } : {}),
|
|
4874
5125
|
// Untagged, so a directory of five thousand files answers at once; the
|
|
@@ -4881,6 +5132,9 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
4881
5132
|
...(events ? { events } : {}),
|
|
4882
5133
|
...(layouts ? { layouts } : {}),
|
|
4883
5134
|
...(rooms ? { rooms } : {}),
|
|
5135
|
+
...(tickets ? { tickets } : {}),
|
|
5136
|
+
...(authServer ? { authServer } : {}),
|
|
5137
|
+
...(parties ? { parties } : {}),
|
|
4884
5138
|
// Invites go out the same way follow notifications do, and only from a
|
|
4885
5139
|
// site that has somebody to send them for.
|
|
4886
5140
|
...(pool
|
|
@@ -4915,12 +5169,9 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
4915
5169
|
...(partyLine ? { partyLine } : {}),
|
|
4916
5170
|
// Accounts live where the directory lives, and only there: a nixamp on a
|
|
4917
5171
|
// laptop has nobody to be an account of.
|
|
4918
|
-
...(
|
|
5172
|
+
...(accounts
|
|
4919
5173
|
? {
|
|
4920
|
-
accounts
|
|
4921
|
-
connectionString: process.env["DATABASE_URL"],
|
|
4922
|
-
secret: process.env["NIXAMP_JWT_SECRET"] ?? "",
|
|
4923
|
-
}),
|
|
5174
|
+
accounts,
|
|
4924
5175
|
secureCookies: (process.env["NIXAMP_SITE"] ?? "").startsWith("https://"),
|
|
4925
5176
|
// A deployment reached over https is one behind somebody's proxy, so
|
|
4926
5177
|
// the socket address is that proxy and the forwarded header is the
|
|
@@ -5318,6 +5569,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
5318
5569
|
const shutdown = (): void => {
|
|
5319
5570
|
rtmp?.stop();
|
|
5320
5571
|
enricher.save();
|
|
5572
|
+
compression.stopAll();
|
|
5321
5573
|
hls.stopAll();
|
|
5322
5574
|
channels.stopAll();
|
|
5323
5575
|
ingest?.stopRtmp();
|