nixamp 0.18.0 → 0.18.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/naming.d.ts +14 -0
- package/dist/naming.js +43 -0
- package/dist/server.d.ts +21 -1
- package/dist/server.js +77 -6
- package/package.json +1 -1
- package/src/naming.ts +41 -0
- package/src/server.ts +77 -6
- package/web/dist/assets/{hls-3VKVEQE3-B2kl0-z1.js → hls-3VKVEQE3-DLpqiiG9.js} +1 -1
- package/web/dist/assets/index-BafZs9FO.js +1 -0
- package/web/dist/assets/{mpegts-LO6RVLD6-B4RLM-_e.js → mpegts-LO6RVLD6-CdlaJXcD.js} +1 -1
- package/web/dist/assets/{mpegts-4pBNK_Yr.js → mpegts-LSi2IZbO.js} +1 -1
- package/web/dist/index.html +14 -9
- package/web/dist/sw.js +5 -5
- package/web/dist/assets/index-DF6O2leR.js +0 -1
package/dist/naming.d.ts
CHANGED
|
@@ -55,3 +55,17 @@ export declare function readCertFiles(stateDir: string, host: string): {
|
|
|
55
55
|
* first label of its hostname, made safe for a subdomain.
|
|
56
56
|
*/
|
|
57
57
|
export declare function labelFor(name: string, hostname: string): string;
|
|
58
|
+
/**
|
|
59
|
+
* What to call a stream that was started on a path, for people.
|
|
60
|
+
*
|
|
61
|
+
* `nixamp serve ~/Music/live-sets_2024` used to be listed as the machine's
|
|
62
|
+
* hostname, which tells a stranger nothing. The last segment of the path is
|
|
63
|
+
* what the person who made the folder called it, so that is the title: the
|
|
64
|
+
* separators that a filesystem forces become spaces, a playlist loses its
|
|
65
|
+
* ending, and each lowercase word gets a capital. Words with a capital in
|
|
66
|
+
* them already are left alone, so "DJ" and "LoFi" stay as written, and a
|
|
67
|
+
* dash or dot between two digits stays too, so a date is still a date.
|
|
68
|
+
* Empty when the path has no segment worth saying, and the caller falls
|
|
69
|
+
* back to whatever it used before.
|
|
70
|
+
*/
|
|
71
|
+
export declare function humanizeSource(source: string): string;
|
package/dist/naming.js
CHANGED
|
@@ -148,3 +148,46 @@ export function labelFor(name, hostname) {
|
|
|
148
148
|
}
|
|
149
149
|
return "server";
|
|
150
150
|
}
|
|
151
|
+
/** File endings that name a playlist rather than a folder, dropped from a title. */
|
|
152
|
+
const PLAYLIST_ENDING = /\.(m3u8?|pls|xspf|txt|json)$/i;
|
|
153
|
+
/**
|
|
154
|
+
* What to call a stream that was started on a path, for people.
|
|
155
|
+
*
|
|
156
|
+
* `nixamp serve ~/Music/live-sets_2024` used to be listed as the machine's
|
|
157
|
+
* hostname, which tells a stranger nothing. The last segment of the path is
|
|
158
|
+
* what the person who made the folder called it, so that is the title: the
|
|
159
|
+
* separators that a filesystem forces become spaces, a playlist loses its
|
|
160
|
+
* ending, and each lowercase word gets a capital. Words with a capital in
|
|
161
|
+
* them already are left alone, so "DJ" and "LoFi" stay as written, and a
|
|
162
|
+
* dash or dot between two digits stays too, so a date is still a date.
|
|
163
|
+
* Empty when the path has no segment worth saying, and the caller falls
|
|
164
|
+
* back to whatever it used before.
|
|
165
|
+
*/
|
|
166
|
+
export function humanizeSource(source) {
|
|
167
|
+
const remote = /^[a-z][a-z0-9+.-]*:\/\//i.test(source);
|
|
168
|
+
let last = "";
|
|
169
|
+
try {
|
|
170
|
+
const path = remote ? new URL(source).pathname : source;
|
|
171
|
+
last = path.split("/").filter(Boolean).pop() ?? "";
|
|
172
|
+
if (remote)
|
|
173
|
+
last = decodeURIComponent(last);
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return "";
|
|
177
|
+
}
|
|
178
|
+
if (last === "" || last === "~" || last === ".")
|
|
179
|
+
return "";
|
|
180
|
+
const words = last
|
|
181
|
+
.replace(PLAYLIST_ENDING, "")
|
|
182
|
+
.replace(/(?<!\d)[-_.+]+|[-_.+]+(?!\d)/g, " ")
|
|
183
|
+
.replace(/\s+/g, " ")
|
|
184
|
+
.trim();
|
|
185
|
+
if (words === "")
|
|
186
|
+
return "";
|
|
187
|
+
return words
|
|
188
|
+
.split(" ")
|
|
189
|
+
.map((word) => (word === word.toLowerCase() ? word.charAt(0).toUpperCase() + word.slice(1) : word))
|
|
190
|
+
.join(" ")
|
|
191
|
+
.slice(0, 60)
|
|
192
|
+
.trim();
|
|
193
|
+
}
|
package/dist/server.d.ts
CHANGED
|
@@ -458,9 +458,29 @@ export declare function isSignInPath(path: string): boolean;
|
|
|
458
458
|
*
|
|
459
459
|
* Read from the page rather than hard-coded, because one NixAmp serves several
|
|
460
460
|
* branded clients and each brings its own head. og:site_name first, then the
|
|
461
|
-
* part of the title before the dash
|
|
461
|
+
* part of the title before the dash or the colon ("nixamp: broadcast live
|
|
462
|
+
* radio..." is nixamp), then the hostname.
|
|
462
463
|
*/
|
|
463
464
|
export declare function brandOf(shell: string, site: string): string;
|
|
465
|
+
/**
|
|
466
|
+
* What a join link is for, named by something this server actually knows.
|
|
467
|
+
*
|
|
468
|
+
* A link preview reads the page before any script runs, so the shell has to
|
|
469
|
+
* say what is on the air. On the directory that is the listing the link
|
|
470
|
+
* points at, and the channel on it if one is asked for; on a server it is
|
|
471
|
+
* the server itself, or one of its channels. A name that only appears in the
|
|
472
|
+
* query is not used: a page that titled itself with whatever the address said
|
|
473
|
+
* would be a preview anyone could put words in.
|
|
474
|
+
*/
|
|
475
|
+
export declare function joinSubject(url: URL, options: Pick<HandlerOptions, "directory" | "channels" | "serverName">): {
|
|
476
|
+
title: string;
|
|
477
|
+
where: string;
|
|
478
|
+
} | null;
|
|
479
|
+
/** The shell, titled for what a join link opens. */
|
|
480
|
+
export declare function joinDocument(shell: string, subject: {
|
|
481
|
+
title: string;
|
|
482
|
+
where: string;
|
|
483
|
+
}, site: string): string;
|
|
464
484
|
export interface HandlerOptions {
|
|
465
485
|
web: string | null;
|
|
466
486
|
media: boolean;
|
package/dist/server.js
CHANGED
|
@@ -51,7 +51,7 @@ import { Catalogs, shownCatalog, shownEntry } from "./catalogs.js";
|
|
|
51
51
|
import { Porkbun, isIPv4, isIPv6 } from "./dns.js";
|
|
52
52
|
import { NameError, Names } from "./names.js";
|
|
53
53
|
import { AcmeIssuer, Certs } from "./certs.js";
|
|
54
|
-
import { claimName, fetchCert, labelFor, readCertFiles, writeCertFiles } from "./naming.js";
|
|
54
|
+
import { claimName, fetchCert, humanizeSource, labelFor, readCertFiles, writeCertFiles } from "./naming.js";
|
|
55
55
|
import { forbiddenLibrary, readLibrary } from "./library.js";
|
|
56
56
|
import { createThrottle, presentedCredential } from "@profullstack/throttle";
|
|
57
57
|
import { Durable } from "./durable.js";
|
|
@@ -1057,14 +1057,15 @@ function htmlText(value) {
|
|
|
1057
1057
|
*
|
|
1058
1058
|
* Read from the page rather than hard-coded, because one NixAmp serves several
|
|
1059
1059
|
* branded clients and each brings its own head. og:site_name first, then the
|
|
1060
|
-
* part of the title before the dash
|
|
1060
|
+
* part of the title before the dash or the colon ("nixamp: broadcast live
|
|
1061
|
+
* radio..." is nixamp), then the hostname.
|
|
1061
1062
|
*/
|
|
1062
1063
|
export function brandOf(shell, site) {
|
|
1063
1064
|
const named = /<meta\s+property="og:site_name"\s+content="([^"]+)"/i.exec(shell);
|
|
1064
1065
|
if (named?.[1])
|
|
1065
1066
|
return named[1];
|
|
1066
1067
|
const titled = /<title>([^<]*)<\/title>/i.exec(shell);
|
|
1067
|
-
const head = titled?.[1]?.split(/\s[—-]\s/)[0]?.trim();
|
|
1068
|
+
const head = titled?.[1]?.split(/\s[—-]\s|:\s/)[0]?.trim();
|
|
1068
1069
|
if (head)
|
|
1069
1070
|
return head;
|
|
1070
1071
|
try {
|
|
@@ -1090,6 +1091,65 @@ function eventDocument(shell, event, site) {
|
|
|
1090
1091
|
.replace(/<meta property="og:description" content="[^"]*"\s*\/>/, `<meta property="og:description" content="${htmlText(description)}" />`)
|
|
1091
1092
|
.replace("</head>", `${event.visibility === "public" ? "" : '<meta name="robots" content="noindex,nofollow" />'}\n <meta property="og:url" content="${htmlText(canonical)}" />\n <link rel="canonical" href="${htmlText(canonical)}" />\n <script type="application/ld+json">${structured}</script>\n </head>`);
|
|
1092
1093
|
}
|
|
1094
|
+
/**
|
|
1095
|
+
* What a join link is for, named by something this server actually knows.
|
|
1096
|
+
*
|
|
1097
|
+
* A link preview reads the page before any script runs, so the shell has to
|
|
1098
|
+
* say what is on the air. On the directory that is the listing the link
|
|
1099
|
+
* points at, and the channel on it if one is asked for; on a server it is
|
|
1100
|
+
* the server itself, or one of its channels. A name that only appears in the
|
|
1101
|
+
* query is not used: a page that titled itself with whatever the address said
|
|
1102
|
+
* would be a preview anyone could put words in.
|
|
1103
|
+
*/
|
|
1104
|
+
export function joinSubject(url, options) {
|
|
1105
|
+
const play = url.searchParams.get("play") ?? "";
|
|
1106
|
+
const wanted = play.startsWith("channel:") ? play.slice("channel:".length) : "";
|
|
1107
|
+
const link = url.searchParams.get("url") ?? "";
|
|
1108
|
+
if (options.directory) {
|
|
1109
|
+
if (link === "")
|
|
1110
|
+
return null;
|
|
1111
|
+
const listings = options.directory.list();
|
|
1112
|
+
const listing = listings.find((one) => one.url === link) ?? listings.find((one) => {
|
|
1113
|
+
try {
|
|
1114
|
+
return new URL(one.url).origin === new URL(link).origin;
|
|
1115
|
+
}
|
|
1116
|
+
catch {
|
|
1117
|
+
return false;
|
|
1118
|
+
}
|
|
1119
|
+
});
|
|
1120
|
+
if (!listing)
|
|
1121
|
+
return null;
|
|
1122
|
+
if (wanted !== "" && listing.channels.includes(wanted))
|
|
1123
|
+
return { title: wanted, where: listing.name };
|
|
1124
|
+
return { title: listing.name, where: "" };
|
|
1125
|
+
}
|
|
1126
|
+
// A link to somewhere else is that server's to name.
|
|
1127
|
+
if (link !== "")
|
|
1128
|
+
return null;
|
|
1129
|
+
const name = options.serverName ?? "";
|
|
1130
|
+
if (wanted !== "") {
|
|
1131
|
+
const channel = options.channels?.list().find((one) => one.id === wanted || one.name === wanted);
|
|
1132
|
+
return channel ? { title: channel.name, where: name } : null;
|
|
1133
|
+
}
|
|
1134
|
+
return name === "" ? null : { title: name, where: "" };
|
|
1135
|
+
}
|
|
1136
|
+
/** The shell, titled for what a join link opens. */
|
|
1137
|
+
export function joinDocument(shell, subject, site) {
|
|
1138
|
+
const brand = brandOf(shell, site);
|
|
1139
|
+
const shellTitle = /<title>([^<]*)<\/title>/i.exec(shell)?.[1] ?? "";
|
|
1140
|
+
const title = `${subject.title} — ${brand}`;
|
|
1141
|
+
const description = subject.where === ""
|
|
1142
|
+
? `${subject.title} is live on ${brand}. Tune in free, no account needed.`
|
|
1143
|
+
: `${subject.title} is live on ${subject.where}. Tune in free on ${brand}, no account needed.`;
|
|
1144
|
+
return shell
|
|
1145
|
+
.replace(/<title>.*?<\/title>/s, `<title>${htmlText(title)}</title>`)
|
|
1146
|
+
.replace(/<meta name="description" content="[^"]*"\s*\/>/, `<meta name="description" content="${htmlText(description)}" />`)
|
|
1147
|
+
.replace(/<meta property="og:title" content="[^"]*"\s*\/>/, `<meta property="og:title" content="${htmlText(subject.title)}" />`)
|
|
1148
|
+
.replace(/<meta property="og:description" content="[^"]*"\s*\/>/, `<meta property="og:description" content="${htmlText(description)}" />`)
|
|
1149
|
+
// What the tab is called once the page takes over, so it does not stack
|
|
1150
|
+
// the channel on a title that already names the channel.
|
|
1151
|
+
.replace("</head>", `<meta name="nixamp-shell-title" content="${htmlText(shellTitle)}" />\n </head>`);
|
|
1152
|
+
}
|
|
1093
1153
|
function json(response, code, body) {
|
|
1094
1154
|
const text = JSON.stringify(body);
|
|
1095
1155
|
response.writeHead(code, {
|
|
@@ -3720,6 +3780,14 @@ export function createHandler(engine, options) {
|
|
|
3720
3780
|
}
|
|
3721
3781
|
catch { }
|
|
3722
3782
|
}
|
|
3783
|
+
if (file.endsWith("index.html") && path === "/") {
|
|
3784
|
+
const subject = joinSubject(url, options);
|
|
3785
|
+
const shell = subject ? readIfPossible(file) : null;
|
|
3786
|
+
if (subject && shell !== null) {
|
|
3787
|
+
html(response, 200, joinDocument(shell, subject, options.invites?.site ?? "https://nixamp.com"));
|
|
3788
|
+
return;
|
|
3789
|
+
}
|
|
3790
|
+
}
|
|
3723
3791
|
sendFile(request, response, file);
|
|
3724
3792
|
return;
|
|
3725
3793
|
}
|
|
@@ -4089,6 +4157,9 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
4089
4157
|
throw new Error("nixamp serve: which folder? Say `nixamp library ~/Music` once, or `nixamp daemon start ~/Music`.");
|
|
4090
4158
|
}
|
|
4091
4159
|
const root = isRemote(chosen) ? chosen : resolve(chosen);
|
|
4160
|
+
// What the stream is called where people see it: the name given, else the
|
|
4161
|
+
// folder or playlist it was started on, in words, else this machine.
|
|
4162
|
+
const streamName = options.name || humanizeSource(root) || hostname();
|
|
4092
4163
|
const why = isRemote(root) ? "" : forbiddenLibrary(root);
|
|
4093
4164
|
if (why) {
|
|
4094
4165
|
throw new Error(`nixamp will not serve ${why}. Pick a folder with your media in it: nixamp library ~/Music`);
|
|
@@ -4548,14 +4619,14 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
4548
4619
|
rememberChannels: remembering,
|
|
4549
4620
|
catalogs,
|
|
4550
4621
|
publishUrls: () => publishUrls,
|
|
4551
|
-
serverName:
|
|
4622
|
+
serverName: streamName,
|
|
4552
4623
|
homeSource: root,
|
|
4553
4624
|
live: {
|
|
4554
4625
|
status: () => ({
|
|
4555
4626
|
live: publisher !== null,
|
|
4556
4627
|
code: listing?.code ?? "",
|
|
4557
4628
|
channelCodes: listing?.channelCodes ?? {},
|
|
4558
|
-
name: listing?.name ??
|
|
4629
|
+
name: listing?.name ?? streamName,
|
|
4559
4630
|
url: listing?.url ?? (publishable_ ? shareLink(publishable_.url, listenKey, false) : ""),
|
|
4560
4631
|
// Whether going live is even possible here. A laptop behind a router
|
|
4561
4632
|
// with no address the world can reach cannot be listed, and a button
|
|
@@ -4916,7 +4987,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
4916
4987
|
const audio = audioLink(publishable_.url, listenKey);
|
|
4917
4988
|
return new Publisher({
|
|
4918
4989
|
directory: DEFAULT_DIRECTORY,
|
|
4919
|
-
name:
|
|
4990
|
+
name: streamName,
|
|
4920
4991
|
url: listen,
|
|
4921
4992
|
audio,
|
|
4922
4993
|
// The control link, for the owner to open this machine as its
|
package/package.json
CHANGED
package/src/naming.ts
CHANGED
|
@@ -195,3 +195,44 @@ export function labelFor(name: string, hostname: string): string {
|
|
|
195
195
|
}
|
|
196
196
|
return "server";
|
|
197
197
|
}
|
|
198
|
+
|
|
199
|
+
/** File endings that name a playlist rather than a folder, dropped from a title. */
|
|
200
|
+
const PLAYLIST_ENDING = /\.(m3u8?|pls|xspf|txt|json)$/i;
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* What to call a stream that was started on a path, for people.
|
|
204
|
+
*
|
|
205
|
+
* `nixamp serve ~/Music/live-sets_2024` used to be listed as the machine's
|
|
206
|
+
* hostname, which tells a stranger nothing. The last segment of the path is
|
|
207
|
+
* what the person who made the folder called it, so that is the title: the
|
|
208
|
+
* separators that a filesystem forces become spaces, a playlist loses its
|
|
209
|
+
* ending, and each lowercase word gets a capital. Words with a capital in
|
|
210
|
+
* them already are left alone, so "DJ" and "LoFi" stay as written, and a
|
|
211
|
+
* dash or dot between two digits stays too, so a date is still a date.
|
|
212
|
+
* Empty when the path has no segment worth saying, and the caller falls
|
|
213
|
+
* back to whatever it used before.
|
|
214
|
+
*/
|
|
215
|
+
export function humanizeSource(source: string): string {
|
|
216
|
+
const remote = /^[a-z][a-z0-9+.-]*:\/\//i.test(source);
|
|
217
|
+
let last = "";
|
|
218
|
+
try {
|
|
219
|
+
const path = remote ? new URL(source).pathname : source;
|
|
220
|
+
last = path.split("/").filter(Boolean).pop() ?? "";
|
|
221
|
+
if (remote) last = decodeURIComponent(last);
|
|
222
|
+
} catch {
|
|
223
|
+
return "";
|
|
224
|
+
}
|
|
225
|
+
if (last === "" || last === "~" || last === ".") return "";
|
|
226
|
+
const words = last
|
|
227
|
+
.replace(PLAYLIST_ENDING, "")
|
|
228
|
+
.replace(/(?<!\d)[-_.+]+|[-_.+]+(?!\d)/g, " ")
|
|
229
|
+
.replace(/\s+/g, " ")
|
|
230
|
+
.trim();
|
|
231
|
+
if (words === "") return "";
|
|
232
|
+
return words
|
|
233
|
+
.split(" ")
|
|
234
|
+
.map((word) => (word === word.toLowerCase() ? word.charAt(0).toUpperCase() + word.slice(1) : word))
|
|
235
|
+
.join(" ")
|
|
236
|
+
.slice(0, 60)
|
|
237
|
+
.trim();
|
|
238
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -72,7 +72,7 @@ import { Catalogs, shownCatalog, shownEntry } from "./catalogs.ts";
|
|
|
72
72
|
import { Porkbun, isIPv4, isIPv6, type DnsZone } from "./dns.ts";
|
|
73
73
|
import { NameError, Names } from "./names.ts";
|
|
74
74
|
import { AcmeIssuer, Certs } from "./certs.ts";
|
|
75
|
-
import { claimName, fetchCert, labelFor, readCertFiles, writeCertFiles } from "./naming.ts";
|
|
75
|
+
import { claimName, fetchCert, humanizeSource, labelFor, readCertFiles, writeCertFiles } from "./naming.ts";
|
|
76
76
|
import { forbiddenLibrary, readLibrary } from "./library.ts";
|
|
77
77
|
import { createThrottle, presentedCredential, type Throttle } from "@profullstack/throttle";
|
|
78
78
|
import { Durable } from "./durable.ts";
|
|
@@ -1304,13 +1304,14 @@ function htmlText(value: string): string {
|
|
|
1304
1304
|
*
|
|
1305
1305
|
* Read from the page rather than hard-coded, because one NixAmp serves several
|
|
1306
1306
|
* branded clients and each brings its own head. og:site_name first, then the
|
|
1307
|
-
* part of the title before the dash
|
|
1307
|
+
* part of the title before the dash or the colon ("nixamp: broadcast live
|
|
1308
|
+
* radio..." is nixamp), then the hostname.
|
|
1308
1309
|
*/
|
|
1309
1310
|
export function brandOf(shell: string, site: string): string {
|
|
1310
1311
|
const named = /<meta\s+property="og:site_name"\s+content="([^"]+)"/i.exec(shell);
|
|
1311
1312
|
if (named?.[1]) return named[1];
|
|
1312
1313
|
const titled = /<title>([^<]*)<\/title>/i.exec(shell);
|
|
1313
|
-
const head = titled?.[1]?.split(/\s[—-]\s/)[0]?.trim();
|
|
1314
|
+
const head = titled?.[1]?.split(/\s[—-]\s|:\s/)[0]?.trim();
|
|
1314
1315
|
if (head) return head;
|
|
1315
1316
|
try {
|
|
1316
1317
|
return new URL(site).hostname.replace(/^www\./, "");
|
|
@@ -1335,6 +1336,65 @@ function eventDocument(shell: string, event: Awaited<ReturnType<LiveEvents["get"
|
|
|
1335
1336
|
.replace("</head>", `${event.visibility === "public" ? "" : '<meta name="robots" content="noindex,nofollow" />'}\n <meta property="og:url" content="${htmlText(canonical)}" />\n <link rel="canonical" href="${htmlText(canonical)}" />\n <script type="application/ld+json">${structured}</script>\n </head>`);
|
|
1336
1337
|
}
|
|
1337
1338
|
|
|
1339
|
+
/**
|
|
1340
|
+
* What a join link is for, named by something this server actually knows.
|
|
1341
|
+
*
|
|
1342
|
+
* A link preview reads the page before any script runs, so the shell has to
|
|
1343
|
+
* say what is on the air. On the directory that is the listing the link
|
|
1344
|
+
* points at, and the channel on it if one is asked for; on a server it is
|
|
1345
|
+
* the server itself, or one of its channels. A name that only appears in the
|
|
1346
|
+
* query is not used: a page that titled itself with whatever the address said
|
|
1347
|
+
* would be a preview anyone could put words in.
|
|
1348
|
+
*/
|
|
1349
|
+
export function joinSubject(
|
|
1350
|
+
url: URL,
|
|
1351
|
+
options: Pick<HandlerOptions, "directory" | "channels" | "serverName">,
|
|
1352
|
+
): { title: string; where: string } | null {
|
|
1353
|
+
const play = url.searchParams.get("play") ?? "";
|
|
1354
|
+
const wanted = play.startsWith("channel:") ? play.slice("channel:".length) : "";
|
|
1355
|
+
const link = url.searchParams.get("url") ?? "";
|
|
1356
|
+
if (options.directory) {
|
|
1357
|
+
if (link === "") return null;
|
|
1358
|
+
const listings = options.directory.list();
|
|
1359
|
+
const listing = listings.find((one) => one.url === link) ?? listings.find((one) => {
|
|
1360
|
+
try {
|
|
1361
|
+
return new URL(one.url).origin === new URL(link).origin;
|
|
1362
|
+
} catch {
|
|
1363
|
+
return false;
|
|
1364
|
+
}
|
|
1365
|
+
});
|
|
1366
|
+
if (!listing) return null;
|
|
1367
|
+
if (wanted !== "" && listing.channels.includes(wanted)) return { title: wanted, where: listing.name };
|
|
1368
|
+
return { title: listing.name, where: "" };
|
|
1369
|
+
}
|
|
1370
|
+
// A link to somewhere else is that server's to name.
|
|
1371
|
+
if (link !== "") return null;
|
|
1372
|
+
const name = options.serverName ?? "";
|
|
1373
|
+
if (wanted !== "") {
|
|
1374
|
+
const channel = options.channels?.list().find((one) => one.id === wanted || one.name === wanted);
|
|
1375
|
+
return channel ? { title: channel.name, where: name } : null;
|
|
1376
|
+
}
|
|
1377
|
+
return name === "" ? null : { title: name, where: "" };
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
/** The shell, titled for what a join link opens. */
|
|
1381
|
+
export function joinDocument(shell: string, subject: { title: string; where: string }, site: string): string {
|
|
1382
|
+
const brand = brandOf(shell, site);
|
|
1383
|
+
const shellTitle = /<title>([^<]*)<\/title>/i.exec(shell)?.[1] ?? "";
|
|
1384
|
+
const title = `${subject.title} — ${brand}`;
|
|
1385
|
+
const description = subject.where === ""
|
|
1386
|
+
? `${subject.title} is live on ${brand}. Tune in free, no account needed.`
|
|
1387
|
+
: `${subject.title} is live on ${subject.where}. Tune in free on ${brand}, no account needed.`;
|
|
1388
|
+
return shell
|
|
1389
|
+
.replace(/<title>.*?<\/title>/s, `<title>${htmlText(title)}</title>`)
|
|
1390
|
+
.replace(/<meta name="description" content="[^"]*"\s*\/>/, `<meta name="description" content="${htmlText(description)}" />`)
|
|
1391
|
+
.replace(/<meta property="og:title" content="[^"]*"\s*\/>/, `<meta property="og:title" content="${htmlText(subject.title)}" />`)
|
|
1392
|
+
.replace(/<meta property="og:description" content="[^"]*"\s*\/>/, `<meta property="og:description" content="${htmlText(description)}" />`)
|
|
1393
|
+
// What the tab is called once the page takes over, so it does not stack
|
|
1394
|
+
// the channel on a title that already names the channel.
|
|
1395
|
+
.replace("</head>", `<meta name="nixamp-shell-title" content="${htmlText(shellTitle)}" />\n </head>`);
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1338
1398
|
function json(response: ServerResponse, code: number, body: unknown): void {
|
|
1339
1399
|
const text = JSON.stringify(body);
|
|
1340
1400
|
response.writeHead(code, {
|
|
@@ -4276,6 +4336,14 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
4276
4336
|
}
|
|
4277
4337
|
} catch {}
|
|
4278
4338
|
}
|
|
4339
|
+
if (file.endsWith("index.html") && path === "/") {
|
|
4340
|
+
const subject = joinSubject(url, options);
|
|
4341
|
+
const shell = subject ? readIfPossible(file) : null;
|
|
4342
|
+
if (subject && shell !== null) {
|
|
4343
|
+
html(response, 200, joinDocument(shell, subject, options.invites?.site ?? "https://nixamp.com"));
|
|
4344
|
+
return;
|
|
4345
|
+
}
|
|
4346
|
+
}
|
|
4279
4347
|
sendFile(request, response, file);
|
|
4280
4348
|
return;
|
|
4281
4349
|
}
|
|
@@ -4675,6 +4743,9 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
4675
4743
|
);
|
|
4676
4744
|
}
|
|
4677
4745
|
const root = isRemote(chosen) ? chosen : resolve(chosen);
|
|
4746
|
+
// What the stream is called where people see it: the name given, else the
|
|
4747
|
+
// folder or playlist it was started on, in words, else this machine.
|
|
4748
|
+
const streamName = options.name || humanizeSource(root) || hostname();
|
|
4678
4749
|
const why = isRemote(root) ? "" : forbiddenLibrary(root);
|
|
4679
4750
|
if (why) {
|
|
4680
4751
|
throw new Error(`nixamp will not serve ${why}. Pick a folder with your media in it: nixamp library ~/Music`);
|
|
@@ -5161,14 +5232,14 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
5161
5232
|
rememberChannels: remembering,
|
|
5162
5233
|
catalogs,
|
|
5163
5234
|
publishUrls: () => publishUrls,
|
|
5164
|
-
serverName:
|
|
5235
|
+
serverName: streamName,
|
|
5165
5236
|
homeSource: root,
|
|
5166
5237
|
live: {
|
|
5167
5238
|
status: () => ({
|
|
5168
5239
|
live: publisher !== null,
|
|
5169
5240
|
code: listing?.code ?? "",
|
|
5170
5241
|
channelCodes: listing?.channelCodes ?? {},
|
|
5171
|
-
name: listing?.name ??
|
|
5242
|
+
name: listing?.name ?? streamName,
|
|
5172
5243
|
url: listing?.url ?? (publishable_ ? shareLink(publishable_.url, listenKey, false) : ""),
|
|
5173
5244
|
// Whether going live is even possible here. A laptop behind a router
|
|
5174
5245
|
// with no address the world can reach cannot be listed, and a button
|
|
@@ -5549,7 +5620,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
5549
5620
|
const audio = audioLink(publishable_.url, listenKey);
|
|
5550
5621
|
return new Publisher({
|
|
5551
5622
|
directory: DEFAULT_DIRECTORY,
|
|
5552
|
-
name:
|
|
5623
|
+
name: streamName,
|
|
5553
5624
|
url: listen,
|
|
5554
5625
|
audio,
|
|
5555
5626
|
// The control link, for the owner to open this machine as its
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{t as e}from"./index-
|
|
1
|
+
import{t as e}from"./index-BafZs9FO.js";var t=3;async function n(n){let{media:r,src:i,isTv:a}=n,{default:o}=await e(async()=>{let{default:e}=await import(`./hls-n74Cnh8A.js`);return{default:e}},[]);if(!o.isSupported())return n.onError(`This browser cannot play HLS streams.`),{destroy:()=>void 0,levels:()=>[]};let s=new o({...a?{maxBufferLength:60,maxMaxBufferLength:120,backBufferLength:30,liveSyncDurationCount:4}:{backBufferLength:90},enableWorker:!0}),c=0,l=!1;s.on(o.Events.ERROR,(e,r)=>{if(!l&&r.fatal){if(c>=t){n.onError(`This stream kept failing and has been stopped.`),s.destroy();return}switch(c+=1,r.type){case o.ErrorTypes.NETWORK_ERROR:n.onNotice(`Reconnecting…`),s.startLoad();break;case o.ErrorTypes.MEDIA_ERROR:n.onNotice(`Recovering…`),s.recoverMediaError();break;default:n.onError(`This stream could not be played.`),s.destroy()}}}),s.on(o.Events.MANIFEST_PARSED,()=>{l||(n.onNotice(null),n.onReady?.({live:s.levels.length>0&&!Number.isFinite(r.duration),levels:u()}))}),s.on(o.Events.LEVEL_LOADED,(e,t)=>{l||n.onReady?.({live:t.details.live,levels:u()})}),s.on(o.Events.FRAG_BUFFERED,()=>{l||n.onNotice(null)});function u(){return s.levels.map((e,t)=>({index:t,height:e.height||null,bitrate:e.bitrate||null,label:e.height?`${String(e.height)}p`:`${String(Math.round((e.bitrate||0)/1e3))}k`}))}return s.loadSource(i),s.attachMedia(r),{destroy(){l=!0,s.destroy()},levels:u,setLevel(e){s.currentLevel=e},currentLevel:()=>s.autoLevelEnabled?-1:s.currentLevel}}export{n as createHlsEngine};
|