nixamp 0.18.0 → 0.19.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/dist/audio.d.ts +12 -0
- package/dist/audio.js +22 -2
- package/dist/channels.d.ts +15 -0
- package/dist/channels.js +50 -10
- package/dist/links.d.ts +32 -0
- package/dist/links.js +82 -0
- package/dist/naming.d.ts +14 -0
- package/dist/naming.js +43 -0
- package/dist/server.d.ts +30 -1
- package/dist/server.js +106 -11
- package/package.json +1 -1
- package/src/audio.ts +32 -3
- package/src/channels.ts +59 -10
- package/src/links.ts +88 -0
- package/src/naming.ts +41 -0
- package/src/server.ts +114 -10
- package/web/dist/assets/{hls-3VKVEQE3-B2kl0-z1.js → hls-3VKVEQE3-wkRCzISl.js} +1 -1
- package/web/dist/assets/index-BuR7VEMY.js +1 -0
- package/web/dist/assets/{mpegts-LO6RVLD6-B4RLM-_e.js → mpegts-LO6RVLD6-CDElw-VW.js} +1 -1
- package/web/dist/assets/{mpegts-4pBNK_Yr.js → mpegts-mq9xdn-u.js} +1 -1
- package/web/dist/index.html +18 -17
- package/web/dist/sw.js +5 -5
- package/web/dist/assets/index-DF6O2leR.js +0 -1
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";
|
|
@@ -880,7 +880,9 @@ export async function pullChannel(channels, ffprobe, id, name, source, input = [
|
|
|
880
880
|
if (assumed)
|
|
881
881
|
console.log(` "${id}": the source would not say what it holds; carrying it as ${kind}.`);
|
|
882
882
|
// A film has a length and a place to go back to; a live source has neither.
|
|
883
|
-
|
|
883
|
+
// A list is a station: joined where it is, and never seeked.
|
|
884
|
+
const playlist = known.playlist && known.playlist.length > 0 ? known.playlist : null;
|
|
885
|
+
const live = playlist ? true : (known.live ?? !((codecs.duration ?? 0) > 0));
|
|
884
886
|
const encode = kind === "video"
|
|
885
887
|
? [
|
|
886
888
|
// Which streams from which input, when there are two: the picture
|
|
@@ -896,12 +898,14 @@ export async function pullChannel(channels, ffprobe, id, name, source, input = [
|
|
|
896
898
|
// the timestamps a recording cut mid-stream does not carry. Ahead of the
|
|
897
899
|
// caller's own input arguments, which are headers for the address itself.
|
|
898
900
|
const opening = [...transportInputArgs(source, codecs.container), ...input];
|
|
899
|
-
const channel = channels.pull(id, name, source, encode, kind, true, undefined, opening, kind === "video" ? audio : "", { live, position: known.position ?? 0 },
|
|
901
|
+
const channel = channels.pull(id, name, source, encode, kind, true, undefined, opening, kind === "video" ? audio : "", { live, position: known.position ?? 0, ...(playlist ? { playlist } : {}) },
|
|
900
902
|
// Known before the first dial: whether the source is a transport stream
|
|
901
903
|
// decides whether it can be read here for a source-boundary relay.
|
|
902
904
|
assumed ? undefined : codecs);
|
|
903
905
|
if (channel && !assumed)
|
|
904
906
|
channel.info.codecs = codecs;
|
|
907
|
+
if (channel && playlist && known.playlistAt !== undefined)
|
|
908
|
+
channel.info.playlistAt = known.playlistAt;
|
|
905
909
|
// What comes out, as opposed to what went in. An H.265 source copied
|
|
906
910
|
// through stays H.265; one re-encoded arrives as H.264, and a packager
|
|
907
911
|
// told otherwise would cut fMP4 segments for a stream that did not need
|
|
@@ -943,11 +947,16 @@ function shownLink(channelId, link, channel) {
|
|
|
943
947
|
channel: channelId,
|
|
944
948
|
name: channel?.name ?? link.title,
|
|
945
949
|
live,
|
|
946
|
-
|
|
950
|
+
// Whether there is a picture is the channel's answer where there is one:
|
|
951
|
+
// a list is guessed at as video until its first entry has been probed,
|
|
952
|
+
// and a station of podcasts opened a video element for nothing.
|
|
953
|
+
video: channel?.kind ? channel.kind !== "audio" : link.video,
|
|
947
954
|
duration: link.duration,
|
|
948
955
|
extractor: link.extractor,
|
|
949
956
|
// A live has no whole to keep; a bare file can be fetched by the browser itself.
|
|
950
957
|
download: !live && link.extractor !== "direct",
|
|
958
|
+
// How many things a list holds, for the page to say.
|
|
959
|
+
entries: link.playlist?.length ?? 0,
|
|
951
960
|
};
|
|
952
961
|
}
|
|
953
962
|
export function liveOnes(engine) {
|
|
@@ -1057,14 +1066,15 @@ function htmlText(value) {
|
|
|
1057
1066
|
*
|
|
1058
1067
|
* Read from the page rather than hard-coded, because one NixAmp serves several
|
|
1059
1068
|
* branded clients and each brings its own head. og:site_name first, then the
|
|
1060
|
-
* part of the title before the dash
|
|
1069
|
+
* part of the title before the dash or the colon ("nixamp: broadcast live
|
|
1070
|
+
* radio..." is nixamp), then the hostname.
|
|
1061
1071
|
*/
|
|
1062
1072
|
export function brandOf(shell, site) {
|
|
1063
1073
|
const named = /<meta\s+property="og:site_name"\s+content="([^"]+)"/i.exec(shell);
|
|
1064
1074
|
if (named?.[1])
|
|
1065
1075
|
return named[1];
|
|
1066
1076
|
const titled = /<title>([^<]*)<\/title>/i.exec(shell);
|
|
1067
|
-
const head = titled?.[1]?.split(/\s[—-]\s/)[0]?.trim();
|
|
1077
|
+
const head = titled?.[1]?.split(/\s[—-]\s|:\s/)[0]?.trim();
|
|
1068
1078
|
if (head)
|
|
1069
1079
|
return head;
|
|
1070
1080
|
try {
|
|
@@ -1090,6 +1100,65 @@ function eventDocument(shell, event, site) {
|
|
|
1090
1100
|
.replace(/<meta property="og:description" content="[^"]*"\s*\/>/, `<meta property="og:description" content="${htmlText(description)}" />`)
|
|
1091
1101
|
.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
1102
|
}
|
|
1103
|
+
/**
|
|
1104
|
+
* What a join link is for, named by something this server actually knows.
|
|
1105
|
+
*
|
|
1106
|
+
* A link preview reads the page before any script runs, so the shell has to
|
|
1107
|
+
* say what is on the air. On the directory that is the listing the link
|
|
1108
|
+
* points at, and the channel on it if one is asked for; on a server it is
|
|
1109
|
+
* the server itself, or one of its channels. A name that only appears in the
|
|
1110
|
+
* query is not used: a page that titled itself with whatever the address said
|
|
1111
|
+
* would be a preview anyone could put words in.
|
|
1112
|
+
*/
|
|
1113
|
+
export function joinSubject(url, options) {
|
|
1114
|
+
const play = url.searchParams.get("play") ?? "";
|
|
1115
|
+
const wanted = play.startsWith("channel:") ? play.slice("channel:".length) : "";
|
|
1116
|
+
const link = url.searchParams.get("url") ?? "";
|
|
1117
|
+
if (options.directory) {
|
|
1118
|
+
if (link === "")
|
|
1119
|
+
return null;
|
|
1120
|
+
const listings = options.directory.list();
|
|
1121
|
+
const listing = listings.find((one) => one.url === link) ?? listings.find((one) => {
|
|
1122
|
+
try {
|
|
1123
|
+
return new URL(one.url).origin === new URL(link).origin;
|
|
1124
|
+
}
|
|
1125
|
+
catch {
|
|
1126
|
+
return false;
|
|
1127
|
+
}
|
|
1128
|
+
});
|
|
1129
|
+
if (!listing)
|
|
1130
|
+
return null;
|
|
1131
|
+
if (wanted !== "" && listing.channels.includes(wanted))
|
|
1132
|
+
return { title: wanted, where: listing.name };
|
|
1133
|
+
return { title: listing.name, where: "" };
|
|
1134
|
+
}
|
|
1135
|
+
// A link to somewhere else is that server's to name.
|
|
1136
|
+
if (link !== "")
|
|
1137
|
+
return null;
|
|
1138
|
+
const name = options.serverName ?? "";
|
|
1139
|
+
if (wanted !== "") {
|
|
1140
|
+
const channel = options.channels?.list().find((one) => one.id === wanted || one.name === wanted);
|
|
1141
|
+
return channel ? { title: channel.name, where: name } : null;
|
|
1142
|
+
}
|
|
1143
|
+
return name === "" ? null : { title: name, where: "" };
|
|
1144
|
+
}
|
|
1145
|
+
/** The shell, titled for what a join link opens. */
|
|
1146
|
+
export function joinDocument(shell, subject, site) {
|
|
1147
|
+
const brand = brandOf(shell, site);
|
|
1148
|
+
const shellTitle = /<title>([^<]*)<\/title>/i.exec(shell)?.[1] ?? "";
|
|
1149
|
+
const title = `${subject.title} — ${brand}`;
|
|
1150
|
+
const description = subject.where === ""
|
|
1151
|
+
? `${subject.title} is live on ${brand}. Tune in free, no account needed.`
|
|
1152
|
+
: `${subject.title} is live on ${subject.where}. Tune in free on ${brand}, no account needed.`;
|
|
1153
|
+
return shell
|
|
1154
|
+
.replace(/<title>.*?<\/title>/s, `<title>${htmlText(title)}</title>`)
|
|
1155
|
+
.replace(/<meta name="description" content="[^"]*"\s*\/>/, `<meta name="description" content="${htmlText(description)}" />`)
|
|
1156
|
+
.replace(/<meta property="og:title" content="[^"]*"\s*\/>/, `<meta property="og:title" content="${htmlText(subject.title)}" />`)
|
|
1157
|
+
.replace(/<meta property="og:description" content="[^"]*"\s*\/>/, `<meta property="og:description" content="${htmlText(description)}" />`)
|
|
1158
|
+
// What the tab is called once the page takes over, so it does not stack
|
|
1159
|
+
// the channel on a title that already names the channel.
|
|
1160
|
+
.replace("</head>", `<meta name="nixamp-shell-title" content="${htmlText(shellTitle)}" />\n </head>`);
|
|
1161
|
+
}
|
|
1093
1162
|
function json(response, code, body) {
|
|
1094
1163
|
const text = JSON.stringify(body);
|
|
1095
1164
|
response.writeHead(code, {
|
|
@@ -2409,6 +2478,10 @@ export function createHandler(engine, options) {
|
|
|
2409
2478
|
// one nobody can dial is not worth showing.
|
|
2410
2479
|
code: state?.code ?? "",
|
|
2411
2480
|
url: state?.url ?? "",
|
|
2481
|
+
// Whether a link can go live here at all. The hosted directory
|
|
2482
|
+
// has no ffmpeg, and a page that offered to go live on it was
|
|
2483
|
+
// offering something that always failed.
|
|
2484
|
+
carries: options.carries !== false && Boolean(options.channels),
|
|
2412
2485
|
},
|
|
2413
2486
|
channels: (options.channels?.list() ?? []).map((one) => ({
|
|
2414
2487
|
id: one.id,
|
|
@@ -2425,6 +2498,8 @@ export function createHandler(engine, options) {
|
|
|
2425
2498
|
// How it has been going, for whoever may do something about it.
|
|
2426
2499
|
redials: one.redials ?? 0,
|
|
2427
2500
|
error: one.error ?? "",
|
|
2501
|
+
// For a list: how long it is, and which entry is on, from 0.
|
|
2502
|
+
...(one.playlist ? { entries: one.playlist.length, entry: one.playlistAt ?? 0 } : {}),
|
|
2428
2503
|
// The member who put it on, when one did: theirs to take off.
|
|
2429
2504
|
startedBy: one.startedBy ?? "",
|
|
2430
2505
|
})),
|
|
@@ -2683,6 +2758,12 @@ export function createHandler(engine, options) {
|
|
|
2683
2758
|
json(response, 503, { error: "this server cannot carry channels" });
|
|
2684
2759
|
return;
|
|
2685
2760
|
}
|
|
2761
|
+
if (options.carries === false) {
|
|
2762
|
+
json(response, 503, {
|
|
2763
|
+
error: `${options.serverName ?? "this server"} has no ffmpeg, so it cannot carry a link. Pick a server to go live on.`,
|
|
2764
|
+
});
|
|
2765
|
+
return;
|
|
2766
|
+
}
|
|
2686
2767
|
const channelId = linkChannelId(link);
|
|
2687
2768
|
const known = links.get(link);
|
|
2688
2769
|
// A member's is always a live: a decoder started on somebody else's
|
|
@@ -2756,7 +2837,7 @@ export function createHandler(engine, options) {
|
|
|
2756
2837
|
});
|
|
2757
2838
|
return;
|
|
2758
2839
|
}
|
|
2759
|
-
const started = await pullChannel(options.channels, options.ffprobe ?? ["ffprobe"], channelId, resolved.title, resolved.media, inputArgsFor(resolved.headers), resolved.audio);
|
|
2840
|
+
const started = await pullChannel(options.channels, options.ffprobe ?? ["ffprobe"], channelId, resolved.title, resolved.media, inputArgsFor(resolved.headers), resolved.audio, resolved.playlist ? { playlist: resolved.playlist } : {});
|
|
2760
2841
|
if (!started) {
|
|
2761
2842
|
json(response, 409, { error: "that link is already starting" });
|
|
2762
2843
|
return;
|
|
@@ -3720,6 +3801,14 @@ export function createHandler(engine, options) {
|
|
|
3720
3801
|
}
|
|
3721
3802
|
catch { }
|
|
3722
3803
|
}
|
|
3804
|
+
if (file.endsWith("index.html") && path === "/") {
|
|
3805
|
+
const subject = joinSubject(url, options);
|
|
3806
|
+
const shell = subject ? readIfPossible(file) : null;
|
|
3807
|
+
if (subject && shell !== null) {
|
|
3808
|
+
html(response, 200, joinDocument(shell, subject, options.invites?.site ?? "https://nixamp.com"));
|
|
3809
|
+
return;
|
|
3810
|
+
}
|
|
3811
|
+
}
|
|
3723
3812
|
sendFile(request, response, file);
|
|
3724
3813
|
return;
|
|
3725
3814
|
}
|
|
@@ -4089,6 +4178,9 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
4089
4178
|
throw new Error("nixamp serve: which folder? Say `nixamp library ~/Music` once, or `nixamp daemon start ~/Music`.");
|
|
4090
4179
|
}
|
|
4091
4180
|
const root = isRemote(chosen) ? chosen : resolve(chosen);
|
|
4181
|
+
// What the stream is called where people see it: the name given, else the
|
|
4182
|
+
// folder or playlist it was started on, in words, else this machine.
|
|
4183
|
+
const streamName = options.name || humanizeSource(root) || hostname();
|
|
4092
4184
|
const why = isRemote(root) ? "" : forbiddenLibrary(root);
|
|
4093
4185
|
if (why) {
|
|
4094
4186
|
throw new Error(`nixamp will not serve ${why}. Pick a folder with your media in it: nixamp library ~/Music`);
|
|
@@ -4186,6 +4278,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
4186
4278
|
...(one.codecs ? { codecs: one.codecs } : {}),
|
|
4187
4279
|
...(one.position !== undefined ? { position: one.position } : {}),
|
|
4188
4280
|
...(one.live !== undefined ? { live: one.live } : {}),
|
|
4281
|
+
...(one.playlist ? { playlist: one.playlist, playlistAt: one.playlistAt ?? 0 } : {}),
|
|
4189
4282
|
}).then((channel) => {
|
|
4190
4283
|
if (!channel)
|
|
4191
4284
|
console.log(` "${one.id}" is already on.`);
|
|
@@ -4198,7 +4291,8 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
4198
4291
|
let lastRemembered = "";
|
|
4199
4292
|
setInterval(() => {
|
|
4200
4293
|
const now = rememberedNow(channels);
|
|
4201
|
-
|
|
4294
|
+
// A list moves on without a position: which entry is on is the thing to keep.
|
|
4295
|
+
if (!now.some((one) => one.position !== undefined || one.playlist))
|
|
4202
4296
|
return;
|
|
4203
4297
|
const text = JSON.stringify(now);
|
|
4204
4298
|
if (text === lastRemembered)
|
|
@@ -4548,14 +4642,14 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
4548
4642
|
rememberChannels: remembering,
|
|
4549
4643
|
catalogs,
|
|
4550
4644
|
publishUrls: () => publishUrls,
|
|
4551
|
-
serverName:
|
|
4645
|
+
serverName: streamName,
|
|
4552
4646
|
homeSource: root,
|
|
4553
4647
|
live: {
|
|
4554
4648
|
status: () => ({
|
|
4555
4649
|
live: publisher !== null,
|
|
4556
4650
|
code: listing?.code ?? "",
|
|
4557
4651
|
channelCodes: listing?.channelCodes ?? {},
|
|
4558
|
-
name: listing?.name ??
|
|
4652
|
+
name: listing?.name ?? streamName,
|
|
4559
4653
|
url: listing?.url ?? (publishable_ ? shareLink(publishable_.url, listenKey, false) : ""),
|
|
4560
4654
|
// Whether going live is even possible here. A laptop behind a router
|
|
4561
4655
|
// with no address the world can reach cannot be listed, and a button
|
|
@@ -4609,6 +4703,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
4609
4703
|
// A cookie jar beside the state, when the operator has put one there,
|
|
4610
4704
|
// for the sites that will not talk to a datacenter without one.
|
|
4611
4705
|
ytdlp: tools.ytdlp ?? null,
|
|
4706
|
+
carries: tools.carries !== false,
|
|
4612
4707
|
cookies: cookiesFile(),
|
|
4613
4708
|
hls,
|
|
4614
4709
|
compression,
|
|
@@ -4916,7 +5011,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
4916
5011
|
const audio = audioLink(publishable_.url, listenKey);
|
|
4917
5012
|
return new Publisher({
|
|
4918
5013
|
directory: DEFAULT_DIRECTORY,
|
|
4919
|
-
name:
|
|
5014
|
+
name: streamName,
|
|
4920
5015
|
url: listen,
|
|
4921
5016
|
audio,
|
|
4922
5017
|
// The control link, for the owner to open this machine as its
|
package/package.json
CHANGED
package/src/audio.ts
CHANGED
|
@@ -31,6 +31,12 @@ export interface Tools {
|
|
|
31
31
|
play: string[] | null;
|
|
32
32
|
/** yt-dlp, which turns a pasted page into a media address; null when there is none. */
|
|
33
33
|
ytdlp?: string[] | null;
|
|
34
|
+
/**
|
|
35
|
+
* Whether an ffmpeg was actually found, as opposed to guessed at. A server
|
|
36
|
+
* without one cannot carry a channel, and should say so before a link is
|
|
37
|
+
* resolved and probed and blamed for it.
|
|
38
|
+
*/
|
|
39
|
+
carries?: boolean;
|
|
34
40
|
}
|
|
35
41
|
|
|
36
42
|
function works(argv: string[], flag = "-version"): boolean {
|
|
@@ -112,6 +118,7 @@ export function detectTools(): Tools {
|
|
|
112
118
|
ffprobe: ffprobe ?? ["ffprobe"],
|
|
113
119
|
play,
|
|
114
120
|
ytdlp,
|
|
121
|
+
carries: ffmpeg !== null,
|
|
115
122
|
};
|
|
116
123
|
}
|
|
117
124
|
|
|
@@ -439,7 +446,10 @@ export async function codecsOf(tools: Tools, path: string, input: string[] = [])
|
|
|
439
446
|
...rest,
|
|
440
447
|
"-v", "quiet",
|
|
441
448
|
"-print_format", "json",
|
|
442
|
-
|
|
449
|
+
// The disposition too: an MP3 with its cover art in it carries that
|
|
450
|
+
// art as a video stream of one JPEG, and a probe that took it for a
|
|
451
|
+
// picture put a podcast on the air as a film with no frames.
|
|
452
|
+
"-show_entries", "format=format_name,duration:stream=codec_type,codec_name,width,height:stream_disposition=attached_pic",
|
|
443
453
|
// A transport stream needs looking further into than a file with an
|
|
444
454
|
// index does: there is no header listing the tracks, only packets, and
|
|
445
455
|
// a 4K recording can carry a second of null padding and a long gap to
|
|
@@ -461,14 +471,17 @@ export async function codecsOf(tools: Tools, path: string, input: string[] = [])
|
|
|
461
471
|
child.on("close", () => {
|
|
462
472
|
try {
|
|
463
473
|
const parsed = JSON.parse(out) as {
|
|
464
|
-
streams?: {
|
|
474
|
+
streams?: {
|
|
475
|
+
codec_type?: string; codec_name?: string; width?: number; height?: number;
|
|
476
|
+
disposition?: { attached_pic?: number };
|
|
477
|
+
}[];
|
|
465
478
|
format?: { format_name?: string; duration?: string };
|
|
466
479
|
};
|
|
467
480
|
const streams = parsed.streams ?? [];
|
|
468
481
|
// ffprobe prints seconds as a string, and "N/A" for a stream with no
|
|
469
482
|
// end; both of those read as 0.
|
|
470
483
|
const seconds = Number(parsed.format?.duration ?? 0);
|
|
471
|
-
const picture = streams.find((s) => s.codec_type === "video");
|
|
484
|
+
const picture = streams.find((s) => s.codec_type === "video" && !isCoverArt(s));
|
|
472
485
|
return done({
|
|
473
486
|
video: picture?.codec_name ?? "",
|
|
474
487
|
audio: streams.find((s) => s.codec_type === "audio")?.codec_name ?? "",
|
|
@@ -484,6 +497,22 @@ export async function codecsOf(tools: Tools, path: string, input: string[] = [])
|
|
|
484
497
|
});
|
|
485
498
|
}
|
|
486
499
|
|
|
500
|
+
/**
|
|
501
|
+
* Cover art is not a picture.
|
|
502
|
+
*
|
|
503
|
+
* An MP3, FLAC or M4A with its sleeve embedded shows ffprobe a video stream:
|
|
504
|
+
* one JPEG or PNG, flagged as an attached picture. Carried as video it is a
|
|
505
|
+
* channel whose picture never gets a second frame -- ffmpeg writes nothing
|
|
506
|
+
* and the source is dialled again and again -- so a sleeve counts for
|
|
507
|
+
* nothing and the sound decides. A still-image codec with no such flag is
|
|
508
|
+
* the same thing from a container that does not flag it.
|
|
509
|
+
*/
|
|
510
|
+
const STILL_IMAGE = new Set(["png", "bmp", "gif", "tiff", "webp"]);
|
|
511
|
+
export function isCoverArt(stream: { codec_name?: string; disposition?: { attached_pic?: number } }): boolean {
|
|
512
|
+
if (stream.disposition?.attached_pic === 1) return true;
|
|
513
|
+
return STILL_IMAGE.has((stream.codec_name ?? "").toLowerCase());
|
|
514
|
+
}
|
|
515
|
+
|
|
487
516
|
/** How much of a transport stream is read before deciding what is in it. */
|
|
488
517
|
export const TRANSPORT_PROBE_BYTES = 20 * 1024 * 1024;
|
|
489
518
|
export const TRANSPORT_ANALYSE_US = 10_000_000;
|
package/src/channels.ts
CHANGED
|
@@ -56,6 +56,14 @@ export interface ChannelInfo {
|
|
|
56
56
|
error?: string;
|
|
57
57
|
/** How many times the source has been dialled again since it started. */
|
|
58
58
|
redials?: number;
|
|
59
|
+
/**
|
|
60
|
+
* For a channel that plays a list: the entries, in order, and which one is
|
|
61
|
+
* on. When one ends the next is dialled at once, and the last is followed
|
|
62
|
+
* by the first: a station, not a file. A restart picks up at the entry
|
|
63
|
+
* that was on.
|
|
64
|
+
*/
|
|
65
|
+
playlist?: string[];
|
|
66
|
+
playlistAt?: number;
|
|
59
67
|
/**
|
|
60
68
|
* For a pulled film: how far into it we are, in seconds, read off ffmpeg
|
|
61
69
|
* as it goes. This is what a restart and a redial go back to. A live
|
|
@@ -95,6 +103,8 @@ export interface PullResume {
|
|
|
95
103
|
live: boolean;
|
|
96
104
|
/** Seconds into a film to start from. */
|
|
97
105
|
position: number;
|
|
106
|
+
/** For a list of things rather than one: every entry, in order. */
|
|
107
|
+
playlist?: string[];
|
|
98
108
|
}
|
|
99
109
|
|
|
100
110
|
/**
|
|
@@ -246,6 +256,8 @@ export class Channel {
|
|
|
246
256
|
private fragments: Fragments | null = null;
|
|
247
257
|
/** For a pulled channel: what to run, and how many times it has failed. */
|
|
248
258
|
private redial: (() => void) | null = null;
|
|
259
|
+
/** For a channel playing a list: move to the next entry. Null when there is no list. */
|
|
260
|
+
private advance: (() => boolean) | null = null;
|
|
249
261
|
private failures = 0;
|
|
250
262
|
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
251
263
|
/** Fires when a pulled source has said nothing for STALL. */
|
|
@@ -349,19 +361,37 @@ export class Channel {
|
|
|
349
361
|
this.stall = stall;
|
|
350
362
|
if (this.info.kind === "video") this.fragments = new Fragments();
|
|
351
363
|
const [command, ...prefix] = this.options.ffmpeg as [string, ...string[]];
|
|
352
|
-
const remote = /^https?:\/\//i.test(source);
|
|
353
364
|
this.info.live = resume.live;
|
|
354
365
|
if (!resume.live) this.info.position = Math.max(0, resume.position);
|
|
355
|
-
//
|
|
356
|
-
//
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
366
|
+
// A list plays entry by entry: the one that is on is what gets dialled,
|
|
367
|
+
// and an entry that ended is followed by the next, at once.
|
|
368
|
+
const list = resume.playlist && resume.playlist.length > 0 ? resume.playlist : null;
|
|
369
|
+
let at = 0;
|
|
370
|
+
if (list) {
|
|
371
|
+
this.info.playlist = list;
|
|
372
|
+
at = Math.min(Math.max(0, this.info.playlistAt ?? 0), list.length - 1);
|
|
373
|
+
this.info.playlistAt = at;
|
|
374
|
+
}
|
|
375
|
+
let current = list ? (list[at] as string) : source;
|
|
376
|
+
this.advance = list && list.length > 1
|
|
377
|
+
? () => {
|
|
378
|
+
at = (at + 1) % list.length;
|
|
379
|
+
this.info.playlistAt = at;
|
|
380
|
+
current = list[at] as string;
|
|
381
|
+
return true;
|
|
382
|
+
}
|
|
383
|
+
: null;
|
|
361
384
|
|
|
362
385
|
const dial = (): void => {
|
|
363
386
|
if (this.closing) return;
|
|
364
387
|
this.stderr = "";
|
|
388
|
+
const remote = /^https?:\/\//i.test(current);
|
|
389
|
+
// What every remote input is told: dial again when the CDN drops it, and
|
|
390
|
+
// give up on a socket that has gone quiet. Input options apply to the
|
|
391
|
+
// input that follows them, so a second input is told again.
|
|
392
|
+
const remoteArgs = remote
|
|
393
|
+
? ["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5", "-rw_timeout", String(stall * 1000)]
|
|
394
|
+
: [];
|
|
365
395
|
// Where to pick a film up from: a little before where it was, since
|
|
366
396
|
// the place was written down a moment ago and a moment of it twice is
|
|
367
397
|
// better than a moment of it missing. A live source is joined as is,
|
|
@@ -421,7 +451,7 @@ export class Channel {
|
|
|
421
451
|
// and a CDN that got them from yt-dlp and not from us answers 403.
|
|
422
452
|
...input,
|
|
423
453
|
...seek,
|
|
424
|
-
"-i",
|
|
454
|
+
"-i", current,
|
|
425
455
|
// The sound, when the site keeps it apart from the picture: a
|
|
426
456
|
// second input, dialled the same way, that the encode maps in.
|
|
427
457
|
...(audio ? [...remoteArgs, ...(paced ? ["-re"] : []), ...input, ...seek, "-i", audio] : []),
|
|
@@ -584,13 +614,20 @@ export class Channel {
|
|
|
584
614
|
this.close();
|
|
585
615
|
return;
|
|
586
616
|
}
|
|
587
|
-
|
|
617
|
+
// A list moves on. An entry that played to its end is not a source that
|
|
618
|
+
// dropped: the next is dialled now, and it is not a redial. One that
|
|
619
|
+
// gave nothing is skipped the same way, counted as the failure it was,
|
|
620
|
+
// so a list of dead links gives up rather than cycling for ever.
|
|
621
|
+
const moved = this.advance?.() ?? false;
|
|
622
|
+
const ended = moved && sent;
|
|
623
|
+
if (ended) this.info.error = undefined;
|
|
624
|
+
else this.info.redials = (this.info.redials ?? 0) + 1;
|
|
588
625
|
this.startOver();
|
|
589
626
|
const dial = this.redial;
|
|
590
627
|
this.timer = setTimeout(() => {
|
|
591
628
|
this.timer = null;
|
|
592
629
|
dial();
|
|
593
|
-
}, REDIAL);
|
|
630
|
+
}, ended ? 0 : REDIAL);
|
|
594
631
|
// A redial is not a reason to keep the process alive at exit.
|
|
595
632
|
this.timer.unref?.();
|
|
596
633
|
}
|
|
@@ -1171,6 +1208,9 @@ export interface RememberedChannel {
|
|
|
1171
1208
|
live?: boolean;
|
|
1172
1209
|
/** The member who put it on, so it is still theirs after a restart. */
|
|
1173
1210
|
startedBy?: string;
|
|
1211
|
+
/** For a list: every entry, and which was on, so it carries on from there. */
|
|
1212
|
+
playlist?: string[];
|
|
1213
|
+
playlistAt?: number;
|
|
1174
1214
|
}
|
|
1175
1215
|
|
|
1176
1216
|
const REMEMBERED = "channels.json";
|
|
@@ -1207,6 +1247,11 @@ export function rememberedChannels(dir: string, port: number): RememberedChannel
|
|
|
1207
1247
|
if (typeof one["position"] === "number" && Number.isFinite(one["position"]) && one["position"] > 0) kept.position = one["position"];
|
|
1208
1248
|
if (typeof one["live"] === "boolean") kept.live = one["live"];
|
|
1209
1249
|
if (typeof one["startedBy"] === "string" && one["startedBy"] !== "") kept.startedBy = one["startedBy"];
|
|
1250
|
+
if (Array.isArray(one["playlist"])) {
|
|
1251
|
+
const entries = (one["playlist"] as unknown[]).filter((entry): entry is string => typeof entry === "string" && entry !== "");
|
|
1252
|
+
if (entries.length > 0) kept.playlist = entries;
|
|
1253
|
+
if (typeof one["playlistAt"] === "number" && Number.isInteger(one["playlistAt"]) && one["playlistAt"] >= 0) kept.playlistAt = one["playlistAt"];
|
|
1254
|
+
}
|
|
1210
1255
|
return kept;
|
|
1211
1256
|
});
|
|
1212
1257
|
} catch {
|
|
@@ -1230,6 +1275,10 @@ export function rememberedNow(channels: Channels): RememberedChannel[] {
|
|
|
1230
1275
|
if (typeof one.live === "boolean") kept.live = one.live;
|
|
1231
1276
|
if (!one.live && typeof one.position === "number" && one.position > 0) kept.position = Math.floor(one.position);
|
|
1232
1277
|
if (one.startedBy) kept.startedBy = one.startedBy;
|
|
1278
|
+
if (one.playlist && one.playlist.length > 0) {
|
|
1279
|
+
kept.playlist = one.playlist;
|
|
1280
|
+
kept.playlistAt = one.playlistAt ?? 0;
|
|
1281
|
+
}
|
|
1233
1282
|
return kept;
|
|
1234
1283
|
});
|
|
1235
1284
|
}
|
package/src/links.ts
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
import { createHash } from "node:crypto";
|
|
23
23
|
import { spawn } from "node:child_process";
|
|
24
|
+
import { parseCatalog } from "./catalogs.ts";
|
|
24
25
|
|
|
25
26
|
export interface ResolvedLink {
|
|
26
27
|
/** What to call it: the page's title, or the file's name for a bare file. */
|
|
@@ -50,6 +51,12 @@ export interface ResolvedLink {
|
|
|
50
51
|
ext: string;
|
|
51
52
|
/** The page it came from. */
|
|
52
53
|
page: string;
|
|
54
|
+
/**
|
|
55
|
+
* For a pasted .m3u: every entry in it, in order. The channel plays them
|
|
56
|
+
* one after another and starts over at the end, a station rather than a
|
|
57
|
+
* file; `media` is the first, for the probe that decides what it holds.
|
|
58
|
+
*/
|
|
59
|
+
playlist?: string[];
|
|
53
60
|
}
|
|
54
61
|
|
|
55
62
|
/**
|
|
@@ -71,6 +78,86 @@ export function isDirectMedia(url: string): boolean {
|
|
|
71
78
|
}
|
|
72
79
|
}
|
|
73
80
|
|
|
81
|
+
/**
|
|
82
|
+
* A plain m3u: a list of things to play, one per line, which is not a thing
|
|
83
|
+
* ffmpeg reads. The .m3u8 of HLS is a list of segments of one thing, which
|
|
84
|
+
* it does, and stays in DIRECT above.
|
|
85
|
+
*/
|
|
86
|
+
const PLAYLIST = /\.m3u(\?.*)?$/i;
|
|
87
|
+
|
|
88
|
+
export function isPlaylistLink(url: string): boolean {
|
|
89
|
+
try {
|
|
90
|
+
const parsed = new URL(url);
|
|
91
|
+
return PLAYLIST.test(parsed.pathname + parsed.search);
|
|
92
|
+
} catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** How much of a playlist is worth reading: a list, not a library dump. */
|
|
98
|
+
export const PLAYLIST_MAX_BYTES = 2_000_000;
|
|
99
|
+
export const PLAYLIST_MAX_ENTRIES = 1000;
|
|
100
|
+
export const PLAYLIST_FETCH_TIMEOUT_MS = 15_000;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* What a playlist's text holds: the entries, in order, resolved against
|
|
104
|
+
* where the list was fetched from. Some things called .m3u are HLS after
|
|
105
|
+
* all -- a segment list with #EXT-X- tags -- and those are one stream for
|
|
106
|
+
* ffmpeg to read as it is, not a list of streams.
|
|
107
|
+
*/
|
|
108
|
+
export function playlistFrom(text: string, base: string): { hls: boolean; sources: string[] } {
|
|
109
|
+
if (/^#EXT-X-/m.test(text)) return { hls: true, sources: [] };
|
|
110
|
+
const sources = parseCatalog(text, base)
|
|
111
|
+
.map((entry) => entry.source)
|
|
112
|
+
.filter((source) => /^(https?|rtmps?):\/\//i.test(source))
|
|
113
|
+
.slice(0, PLAYLIST_MAX_ENTRIES);
|
|
114
|
+
return { hls: false, sources };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* A pasted .m3u, read and turned into a channel's worth of entries. Fetched
|
|
119
|
+
* here rather than by yt-dlp, which would take the first entry and stop, or
|
|
120
|
+
* by ffmpeg, which does not read a plain list at all.
|
|
121
|
+
*/
|
|
122
|
+
export async function resolvePlaylist(
|
|
123
|
+
url: string,
|
|
124
|
+
options: { timeoutMs?: number; fetcher?: typeof fetch } = {},
|
|
125
|
+
): Promise<ResolvedLink | { error: string }> {
|
|
126
|
+
const get = options.fetcher ?? fetch;
|
|
127
|
+
const abort = new AbortController();
|
|
128
|
+
const timer = setTimeout(() => abort.abort(), options.timeoutMs ?? PLAYLIST_FETCH_TIMEOUT_MS);
|
|
129
|
+
let text = "";
|
|
130
|
+
try {
|
|
131
|
+
const answer = await get(url, { signal: abort.signal, headers: { "user-agent": "nixamp" }, redirect: "follow" });
|
|
132
|
+
if (!answer.ok) return { error: `that playlist could not be fetched (HTTP ${answer.status})` };
|
|
133
|
+
text = (await answer.text()).slice(0, PLAYLIST_MAX_BYTES);
|
|
134
|
+
} catch (error) {
|
|
135
|
+
const why = abort.signal.aborted ? "took too long to answer" : ((error as Error).message || "could not be fetched");
|
|
136
|
+
return { error: `that playlist ${why}` };
|
|
137
|
+
} finally {
|
|
138
|
+
clearTimeout(timer);
|
|
139
|
+
}
|
|
140
|
+
const list = playlistFrom(text, url);
|
|
141
|
+
if (list.hls) return directLink(url);
|
|
142
|
+
const [first] = list.sources;
|
|
143
|
+
if (!first) return { error: "that playlist has nothing in it this can play" };
|
|
144
|
+
return {
|
|
145
|
+
title: fileNameOf(url).replace(/\.m3u$/i, "") || "Playlist",
|
|
146
|
+
media: first,
|
|
147
|
+
audio: "",
|
|
148
|
+
// A station: joined where it is, never seeked, and with no end to save.
|
|
149
|
+
live: true,
|
|
150
|
+
duration: 0,
|
|
151
|
+
// Whether there is a picture is the first entry's to say; ffprobe settles it.
|
|
152
|
+
video: true,
|
|
153
|
+
headers: {},
|
|
154
|
+
extractor: "playlist",
|
|
155
|
+
ext: "",
|
|
156
|
+
page: url,
|
|
157
|
+
playlist: list.sources,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
74
161
|
/** A link somebody pasted, or "" when it is not one this can play. */
|
|
75
162
|
export function playableLink(entered: unknown): string {
|
|
76
163
|
if (typeof entered !== "string") return "";
|
|
@@ -313,6 +400,7 @@ export async function resolveLink(
|
|
|
313
400
|
options: { cookies?: string; timeoutMs?: number; format?: string } = {},
|
|
314
401
|
): Promise<ResolvedLink | { error: string }> {
|
|
315
402
|
if (isDirectMedia(url)) return directLink(url);
|
|
403
|
+
if (isPlaylistLink(url)) return resolvePlaylist(url, { timeoutMs: options.timeoutMs });
|
|
316
404
|
// Without yt-dlp, a link is taken as the media it may well be: an IPTV
|
|
317
405
|
// feed has no extension and no page behind it, and ffprobe -- asked next,
|
|
318
406
|
// before anything goes on the air -- tells a stream from a web page in a
|
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
|
+
}
|