nixamp 0.23.1 → 0.23.3
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 +13 -2
- package/dist/art.d.ts +41 -0
- package/dist/art.js +154 -0
- package/dist/audio.d.ts +18 -0
- package/dist/audio.js +27 -1
- package/dist/channels.d.ts +14 -0
- package/dist/channels.js +12 -0
- package/dist/directory.d.ts +22 -0
- package/dist/directory.js +27 -0
- package/dist/links.d.ts +17 -0
- package/dist/links.js +52 -0
- package/dist/publish.d.ts +3 -1
- package/dist/publish.js +1 -0
- package/dist/server.d.ts +40 -9
- package/dist/server.js +190 -22
- package/package.json +1 -1
- package/src/art.ts +151 -0
- package/src/audio.ts +32 -2
- package/src/channels.ts +21 -2
- package/src/directory.ts +47 -0
- package/src/links.ts +64 -0
- package/src/publish.ts +4 -1
- package/src/server.ts +210 -14
- package/web/dist/assets/{hls-3VKVEQE3-oKIoBYl_.js → hls-3VKVEQE3-B34ptWVB.js} +1 -1
- package/web/dist/assets/index-46pwGn5-.css +1 -0
- package/web/dist/assets/index-CjxlA6ia.js +1 -0
- package/web/dist/assets/{mpegts-BuzFlHyM.js → mpegts-DAbMVJ3m.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-BCuOMoVO.js → mpegts-LO6RVLD6-Dwbw4mqA.js} +1 -1
- package/web/dist/index.html +7 -6
- package/web/dist/sw.js +6 -6
- package/web/dist/assets/index-gn9JHoUD.css +0 -1
- package/web/dist/assets/index-xFTVvqG0.js +0 -1
package/dist/server.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { type IncomingMessage, type Server, type ServerResponse } from "node:htt
|
|
|
2
2
|
import { Connections } from "./connections.ts";
|
|
3
3
|
import { Broadcaster, type Destination, type EncoderSettings } from "./broadcast.ts";
|
|
4
4
|
import { Ingest } from "./ingest.ts";
|
|
5
|
-
import { Channels, type Channel, type RememberedChannel } from "./channels.ts";
|
|
5
|
+
import { Channels, type Channel, type ChannelInfo, type RememberedChannel } from "./channels.ts";
|
|
6
6
|
import { Accounts } from "./accounts.ts";
|
|
7
7
|
import { Handles } from "./handles.ts";
|
|
8
8
|
import { OpenDirs } from "./opendirs.ts";
|
|
@@ -11,7 +11,8 @@ import { SignIn } from "./oauth.ts";
|
|
|
11
11
|
import { AuthorizationServer } from "./oauth-server.ts";
|
|
12
12
|
import { WatchParties } from "./watch-party.ts";
|
|
13
13
|
import { Owner } from "./owner.ts";
|
|
14
|
-
import { Directory } from "./directory.ts";
|
|
14
|
+
import { Directory, type LineupEntry } from "./directory.ts";
|
|
15
|
+
import { ArtCache } from "./art.ts";
|
|
15
16
|
import { PartyLine } from "./partyline.ts";
|
|
16
17
|
import { HlsPackagers } from "./hls.ts";
|
|
17
18
|
import { CompressionService } from "./compression/service.ts";
|
|
@@ -407,6 +408,9 @@ export interface KnownSource {
|
|
|
407
408
|
/** A list of entries to play in turn, and which one was on. */
|
|
408
409
|
playlist?: string[];
|
|
409
410
|
playlistAt?: number;
|
|
411
|
+
/** A picture and a line about it, from the site or the list that named it. */
|
|
412
|
+
art?: string;
|
|
413
|
+
about?: string;
|
|
410
414
|
}
|
|
411
415
|
/** How many times a source that answers nothing is asked, and how far apart. */
|
|
412
416
|
export declare const PROBE_TRIES = 3;
|
|
@@ -425,6 +429,26 @@ export declare const PROBE_RETRY_MS = 1500;
|
|
|
425
429
|
*/
|
|
426
430
|
export declare function hevcChannelsAllowed(env?: NodeJS.ProcessEnv): boolean;
|
|
427
431
|
export declare function pullChannel(channels: Channels, ffprobe: string[], id: string, name: string, source: string, input?: string[], audio?: string, known?: KnownSource): Promise<Channel | null>;
|
|
432
|
+
/** Whether a name is only a file's: something.mp3, with nothing said about it. */
|
|
433
|
+
export declare function looksLikeFileName(name: string): boolean;
|
|
434
|
+
/**
|
|
435
|
+
* Where a channel's picture is, for the page and the directory.
|
|
436
|
+
*
|
|
437
|
+
* The site's own thumbnail as it is; else this server's art route, when
|
|
438
|
+
* the channel has a picture to read one from -- a sleeve in the file, or a
|
|
439
|
+
* moving picture to take a frame of. "" for a channel of sound with no
|
|
440
|
+
* sleeve, which has no picture to be had.
|
|
441
|
+
*/
|
|
442
|
+
export declare function artOf(info: Pick<ChannelInfo, "id" | "art" | "kind" | "codecs">): string;
|
|
443
|
+
/**
|
|
444
|
+
* The same, as a crawler reaches it: an absolute address on this server
|
|
445
|
+
* with the listen key on it, since a preview is fetched by nobody who
|
|
446
|
+
* holds the cookie. The listen link is what the directory publishes
|
|
447
|
+
* already, so the key on the picture gives away nothing new.
|
|
448
|
+
*/
|
|
449
|
+
export declare function absoluteArtOf(listenLink: string, info: Pick<ChannelInfo, "id" | "art" | "kind" | "codecs">): string;
|
|
450
|
+
/** A channel as the directory's lineup wants it: id, name, kind, picture, line. */
|
|
451
|
+
export declare function lineupEntry(listenLink: string, info: ChannelInfo): LineupEntry;
|
|
428
452
|
/**
|
|
429
453
|
* A Netscape cookies file beside the state, if the operator has put one there.
|
|
430
454
|
*
|
|
@@ -479,15 +503,20 @@ export declare function brandOf(shell: string, site: string): string;
|
|
|
479
503
|
* query is not used: a page that titled itself with whatever the address said
|
|
480
504
|
* would be a preview anyone could put words in.
|
|
481
505
|
*/
|
|
482
|
-
|
|
506
|
+
/** What a join link opens, as the shell's head says it: a name, where it is, and a picture and a line when it has them. */
|
|
507
|
+
export interface JoinSubject {
|
|
483
508
|
title: string;
|
|
484
509
|
where: string;
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
510
|
+
/** An absolute picture address a crawler can fetch, or "". */
|
|
511
|
+
image?: string;
|
|
512
|
+
/** A line about it, or "". */
|
|
513
|
+
about?: string;
|
|
514
|
+
/** Whether the picture is a frame of a moving picture (wide) or a sleeve (square). */
|
|
515
|
+
kind?: "audio" | "video";
|
|
516
|
+
}
|
|
517
|
+
export declare function joinSubject(url: URL, options: Pick<HandlerOptions, "directory" | "channels" | "serverName" | "live">): JoinSubject | null;
|
|
518
|
+
/** The shell, titled for what a join link opens, with its picture on the card. */
|
|
519
|
+
export declare function joinDocument(shell: string, subject: JoinSubject, site: string): string;
|
|
491
520
|
export interface HandlerOptions {
|
|
492
521
|
web: string | null;
|
|
493
522
|
media: boolean;
|
|
@@ -514,6 +543,8 @@ export interface HandlerOptions {
|
|
|
514
543
|
ytdlp?: string[] | null;
|
|
515
544
|
/** Channels as HLS, for Safari on a phone, which plays a live stream no other way. */
|
|
516
545
|
hls?: HlsPackagers;
|
|
546
|
+
/** The pictures of channels already taken, so a card unfurled by five crawlers costs one ffmpeg. */
|
|
547
|
+
art?: ArtCache;
|
|
517
548
|
/** Captions for a channel: its sound, as lines, as they are heard. Needs ffmpeg and a sign-in. */
|
|
518
549
|
captions?: Captions;
|
|
519
550
|
/** Lossless relay compression, its policies, diagnostics and static representations. */
|
package/dist/server.js
CHANGED
|
@@ -37,6 +37,7 @@ import { playJingle } from "./jingle.js";
|
|
|
37
37
|
import { stateDir } from "./daemon.js";
|
|
38
38
|
import { readSession } from "./session.js";
|
|
39
39
|
import { Directory, ENDED_TTL_MS, parseAnnouncement } from "./directory.js";
|
|
40
|
+
import { ArtCache, coverArtOf, stillFrom } from "./art.js";
|
|
40
41
|
import { PartyLine, telnyxSms } from "./partyline.js";
|
|
41
42
|
import { HlsPackagers, segmentType, withKey } from "./hls.js";
|
|
42
43
|
import { CompressionService } from "./compression/service.js";
|
|
@@ -70,7 +71,7 @@ import { confirm, DEFAULT_DIRECTORY, Publisher } from "./publish.js";
|
|
|
70
71
|
import { applyRemoteConfig, createPaywall, FREE_LISTENERS, paywallFromEnv, } from "./paywall.js";
|
|
71
72
|
import { isRemote, isTransportStream, playsInBrowser, sourceLabel } from "./sources.js";
|
|
72
73
|
import { codecsOf, probeAsync, transportInputArgs, videoArgs } from "./audio.js";
|
|
73
|
-
import { allowedForListening, elevate, firewallInUse, certifiable, keyCookie, keyInPath, rememberedKeys, keyFrom, lookupPublicIp, portCommands, reachableAddresses, scopeOf, shareLink, audioLink, } from "./share.js";
|
|
74
|
+
import { allowedForListening, elevate, firewallInUse, certifiable, keyCookie, keyInPath, rememberedKeys, keyFrom, lookupPublicIp, portCommands, reachableAddresses, scopeOf, shareLink, audioLink, KEY_QUERY, } from "./share.js";
|
|
74
75
|
import { extname, join, normalize, resolve, sep } from "node:path";
|
|
75
76
|
import { fileURLToPath } from "node:url";
|
|
76
77
|
import { detectTools, peaks, RATE, Stream, toMono, } from "./audio.js";
|
|
@@ -902,7 +903,11 @@ export async function pullChannel(channels, ffprobe, id, name, source, input = [
|
|
|
902
903
|
// the timestamps a recording cut mid-stream does not carry. Ahead of the
|
|
903
904
|
// caller's own input arguments, which are headers for the address itself.
|
|
904
905
|
const opening = [...transportInputArgs(source, codecs.container), ...input];
|
|
905
|
-
|
|
906
|
+
// A bare file is named by its file name until the file says better: an
|
|
907
|
+
// MP3's tags name the episode and the show, and "Inspiring Founders" is a
|
|
908
|
+
// card; "ep-0412-final.mp3" is not.
|
|
909
|
+
const tagged = looksLikeFileName(name) && codecs.tags?.title ? codecs.tags.title : name;
|
|
910
|
+
const channel = channels.pull(id, tagged, source, encode, kind, true, undefined, opening, kind === "video" ? audio : "", { live, position: known.position ?? 0, ...(playlist ? { playlist } : {}) },
|
|
906
911
|
// Known before the first dial: whether the source is a transport stream
|
|
907
912
|
// decides whether it can be read here for a source-boundary relay.
|
|
908
913
|
assumed ? undefined : codecs);
|
|
@@ -910,6 +915,32 @@ export async function pullChannel(channels, ffprobe, id, name, source, input = [
|
|
|
910
915
|
channel.info.codecs = codecs;
|
|
911
916
|
if (channel && playlist && known.playlistAt !== undefined)
|
|
912
917
|
channel.info.playlistAt = known.playlistAt;
|
|
918
|
+
if (channel) {
|
|
919
|
+
// Its picture from wherever it came with one, and a line about it: what
|
|
920
|
+
// was given, else the show and the album the file's own tags name.
|
|
921
|
+
if (known.art && /^https?:\/\//i.test(known.art))
|
|
922
|
+
channel.info.art = known.art;
|
|
923
|
+
const byTags = [codecs.tags?.artist, codecs.tags?.album].filter((one) => Boolean(one)).join(" · ");
|
|
924
|
+
const about = (known.about ?? byTags).trim().slice(0, 200);
|
|
925
|
+
if (about !== "")
|
|
926
|
+
channel.info.about = about;
|
|
927
|
+
// A channel of sound put back from what an older nixamp wrote down was
|
|
928
|
+
// never asked whether its file carries a sleeve. Asked now, once, after
|
|
929
|
+
// it is on: reading the head of an MP3 on a CDN costs nothing and
|
|
930
|
+
// opens no second connection a panel would count (that is a film's
|
|
931
|
+
// trap, and a film has a picture of its own to take a frame of).
|
|
932
|
+
if (kind === "audio" && !channel.info.art && known.codecs && codecs.cover === undefined) {
|
|
933
|
+
void codecsOf(tools, source, input).then((later) => {
|
|
934
|
+
if (channel.info.codecs && later.cover)
|
|
935
|
+
channel.info.codecs.cover = true;
|
|
936
|
+
if (!channel.info.about) {
|
|
937
|
+
const line = [later.tags?.artist, later.tags?.album].filter((one) => Boolean(one)).join(" · ");
|
|
938
|
+
if (line !== "")
|
|
939
|
+
channel.info.about = line;
|
|
940
|
+
}
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
}
|
|
913
944
|
// What comes out, as opposed to what went in. An H.265 source copied
|
|
914
945
|
// through stays H.265; one re-encoded arrives as H.264, and a packager
|
|
915
946
|
// told otherwise would cut fMP4 segments for a stream that did not need
|
|
@@ -919,6 +950,52 @@ export async function pullChannel(channels, ffprobe, id, name, source, input = [
|
|
|
919
950
|
}
|
|
920
951
|
return channel;
|
|
921
952
|
}
|
|
953
|
+
/** Whether a name is only a file's: something.mp3, with nothing said about it. */
|
|
954
|
+
export function looksLikeFileName(name) {
|
|
955
|
+
return /\.[a-z0-9]{2,5}$/i.test(name.trim());
|
|
956
|
+
}
|
|
957
|
+
/**
|
|
958
|
+
* Where a channel's picture is, for the page and the directory.
|
|
959
|
+
*
|
|
960
|
+
* The site's own thumbnail as it is; else this server's art route, when
|
|
961
|
+
* the channel has a picture to read one from -- a sleeve in the file, or a
|
|
962
|
+
* moving picture to take a frame of. "" for a channel of sound with no
|
|
963
|
+
* sleeve, which has no picture to be had.
|
|
964
|
+
*/
|
|
965
|
+
export function artOf(info) {
|
|
966
|
+
if (info.art)
|
|
967
|
+
return info.art;
|
|
968
|
+
return info.codecs?.cover === true || info.kind === "video" ? `/api/channels/${encodeURIComponent(info.id)}/art` : "";
|
|
969
|
+
}
|
|
970
|
+
/**
|
|
971
|
+
* The same, as a crawler reaches it: an absolute address on this server
|
|
972
|
+
* with the listen key on it, since a preview is fetched by nobody who
|
|
973
|
+
* holds the cookie. The listen link is what the directory publishes
|
|
974
|
+
* already, so the key on the picture gives away nothing new.
|
|
975
|
+
*/
|
|
976
|
+
export function absoluteArtOf(listenLink, info) {
|
|
977
|
+
const art = artOf(info);
|
|
978
|
+
if (art === "" || /^https?:\/\//i.test(art))
|
|
979
|
+
return art;
|
|
980
|
+
try {
|
|
981
|
+
const link = new URL(listenLink);
|
|
982
|
+
const key = /\/(?:view|admin)\/([^/?#]+)$/.exec(link.pathname)?.[1] ?? "";
|
|
983
|
+
return `${link.origin}${art}${key ? `?${KEY_QUERY}=${encodeURIComponent(key)}` : ""}`;
|
|
984
|
+
}
|
|
985
|
+
catch {
|
|
986
|
+
return "";
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
/** A channel as the directory's lineup wants it: id, name, kind, picture, line. */
|
|
990
|
+
export function lineupEntry(listenLink, info) {
|
|
991
|
+
return {
|
|
992
|
+
id: info.id,
|
|
993
|
+
name: info.name,
|
|
994
|
+
kind: info.kind ?? "audio",
|
|
995
|
+
art: absoluteArtOf(listenLink, info),
|
|
996
|
+
about: info.about ?? "",
|
|
997
|
+
};
|
|
998
|
+
}
|
|
922
999
|
/** What a link resolves to, kept so a download can be named without asking twice. */
|
|
923
1000
|
const links = new Map();
|
|
924
1001
|
/**
|
|
@@ -961,6 +1038,9 @@ function shownLink(channelId, link, channel) {
|
|
|
961
1038
|
download: !live && link.extractor !== "direct",
|
|
962
1039
|
// How many things a list holds, for the page to say.
|
|
963
1040
|
entries: link.playlist?.length ?? 0,
|
|
1041
|
+
// Its picture and its line, for the meta line and the lock screen.
|
|
1042
|
+
art: channel ? artOf(channel) : link.thumbnail,
|
|
1043
|
+
about: channel?.about ?? link.about,
|
|
964
1044
|
};
|
|
965
1045
|
}
|
|
966
1046
|
export function liveOnes(engine) {
|
|
@@ -1104,20 +1184,16 @@ function eventDocument(shell, event, site) {
|
|
|
1104
1184
|
.replace(/<meta property="og:description" content="[^"]*"\s*\/>/, `<meta property="og:description" content="${htmlText(description)}" />`)
|
|
1105
1185
|
.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>`);
|
|
1106
1186
|
}
|
|
1107
|
-
/**
|
|
1108
|
-
* What a join link is for, named by something this server actually knows.
|
|
1109
|
-
*
|
|
1110
|
-
* A link preview reads the page before any script runs, so the shell has to
|
|
1111
|
-
* say what is on the air. On the directory that is the listing the link
|
|
1112
|
-
* points at, and the channel on it if one is asked for; on a server it is
|
|
1113
|
-
* the server itself, or one of its channels. A name that only appears in the
|
|
1114
|
-
* query is not used: a page that titled itself with whatever the address said
|
|
1115
|
-
* would be a preview anyone could put words in.
|
|
1116
|
-
*/
|
|
1117
1187
|
export function joinSubject(url, options) {
|
|
1118
1188
|
const play = url.searchParams.get("play") ?? "";
|
|
1119
1189
|
const wanted = play.startsWith("channel:") ? play.slice("channel:".length) : "";
|
|
1120
1190
|
const link = url.searchParams.get("url") ?? "";
|
|
1191
|
+
const withArt = (subject, entry) => ({
|
|
1192
|
+
...subject,
|
|
1193
|
+
...(entry?.art ? { image: entry.art } : {}),
|
|
1194
|
+
...(entry?.about ? { about: entry.about } : {}),
|
|
1195
|
+
...(entry ? { kind: entry.kind } : {}),
|
|
1196
|
+
});
|
|
1121
1197
|
if (options.directory) {
|
|
1122
1198
|
if (link === "")
|
|
1123
1199
|
return null;
|
|
@@ -1132,8 +1208,16 @@ export function joinSubject(url, options) {
|
|
|
1132
1208
|
});
|
|
1133
1209
|
if (!listing)
|
|
1134
1210
|
return null;
|
|
1135
|
-
if (wanted !== ""
|
|
1136
|
-
|
|
1211
|
+
if (wanted !== "") {
|
|
1212
|
+
// A join link names a channel by id; the directory row names it by
|
|
1213
|
+
// name; the lineup knows both. An older server's listing has names
|
|
1214
|
+
// alone, and is matched on those as before.
|
|
1215
|
+
const entry = listing.lineup.find((one) => one.id === wanted) ?? listing.lineup.find((one) => one.name === wanted);
|
|
1216
|
+
if (entry)
|
|
1217
|
+
return withArt({ title: entry.name, where: listing.name }, entry);
|
|
1218
|
+
if (listing.channels.includes(wanted))
|
|
1219
|
+
return { title: wanted, where: listing.name };
|
|
1220
|
+
}
|
|
1137
1221
|
return { title: listing.name, where: "" };
|
|
1138
1222
|
}
|
|
1139
1223
|
// A link to somewhere else is that server's to name.
|
|
@@ -1142,18 +1226,38 @@ export function joinSubject(url, options) {
|
|
|
1142
1226
|
const name = options.serverName ?? "";
|
|
1143
1227
|
if (wanted !== "") {
|
|
1144
1228
|
const channel = options.channels?.list().find((one) => one.id === wanted || one.name === wanted);
|
|
1145
|
-
|
|
1229
|
+
if (!channel)
|
|
1230
|
+
return null;
|
|
1231
|
+
// Its picture as a crawler reaches it: absolute, with the listen key,
|
|
1232
|
+
// which the listing this server publishes carries anyway.
|
|
1233
|
+
const listen = options.live?.status().url ?? "";
|
|
1234
|
+
const entry = listen ? lineupEntry(listen, channel) : undefined;
|
|
1235
|
+
return withArt({ title: channel.name, where: name }, entry);
|
|
1146
1236
|
}
|
|
1147
1237
|
return name === "" ? null : { title: name, where: "" };
|
|
1148
1238
|
}
|
|
1149
|
-
/** The shell, titled for what a join link opens. */
|
|
1239
|
+
/** The shell, titled for what a join link opens, with its picture on the card. */
|
|
1150
1240
|
export function joinDocument(shell, subject, site) {
|
|
1151
1241
|
const brand = brandOf(shell, site);
|
|
1152
1242
|
const shellTitle = /<title>([^<]*)<\/title>/i.exec(shell)?.[1] ?? "";
|
|
1153
1243
|
const title = `${subject.title} — ${brand}`;
|
|
1154
|
-
const
|
|
1244
|
+
const where = subject.where === ""
|
|
1155
1245
|
? `${subject.title} is live on ${brand}. Tune in free, no account needed.`
|
|
1156
1246
|
: `${subject.title} is live on ${subject.where}. Tune in free on ${brand}, no account needed.`;
|
|
1247
|
+
// What it is about comes first, then where it is: a card has two lines.
|
|
1248
|
+
const description = subject.about ? `${subject.about} — ${where}` : where;
|
|
1249
|
+
const image = subject.image ?? "";
|
|
1250
|
+
// A frame of a picture is wide and fills the card; a sleeve is square and
|
|
1251
|
+
// sits beside the words. Twitter has a card for each and picks neither
|
|
1252
|
+
// without being told; everything else reads og:image and decides itself.
|
|
1253
|
+
const card = image === "" ? "" : [
|
|
1254
|
+
`<meta property="og:image" content="${htmlText(image)}" />`,
|
|
1255
|
+
`<meta property="og:image:alt" content="${htmlText(subject.title)}" />`,
|
|
1256
|
+
`<meta name="twitter:card" content="${subject.kind === "video" ? "summary_large_image" : "summary"}" />`,
|
|
1257
|
+
`<meta name="twitter:title" content="${htmlText(subject.title)}" />`,
|
|
1258
|
+
`<meta name="twitter:description" content="${htmlText(description)}" />`,
|
|
1259
|
+
`<meta name="twitter:image" content="${htmlText(image)}" />`,
|
|
1260
|
+
].map((line) => `${line}\n `).join("");
|
|
1157
1261
|
return shell
|
|
1158
1262
|
.replace(/<title>.*?<\/title>/s, `<title>${htmlText(title)}</title>`)
|
|
1159
1263
|
.replace(/<meta name="description" content="[^"]*"\s*\/>/, `<meta name="description" content="${htmlText(description)}" />`)
|
|
@@ -1161,7 +1265,7 @@ export function joinDocument(shell, subject, site) {
|
|
|
1161
1265
|
.replace(/<meta property="og:description" content="[^"]*"\s*\/>/, `<meta property="og:description" content="${htmlText(description)}" />`)
|
|
1162
1266
|
// What the tab is called once the page takes over, so it does not stack
|
|
1163
1267
|
// the channel on a title that already names the channel.
|
|
1164
|
-
.replace("</head>",
|
|
1268
|
+
.replace("</head>", `${card}<meta name="nixamp-shell-title" content="${htmlText(shellTitle)}" />\n </head>`);
|
|
1165
1269
|
}
|
|
1166
1270
|
function json(response, code, body) {
|
|
1167
1271
|
const text = JSON.stringify(body);
|
|
@@ -2680,6 +2784,9 @@ export function createHandler(engine, options) {
|
|
|
2680
2784
|
...(one.playlist ? { entries: one.playlist.length, entry: one.playlistAt ?? 0 } : {}),
|
|
2681
2785
|
// The member who put it on, when one did: theirs to take off.
|
|
2682
2786
|
startedBy: one.startedBy ?? "",
|
|
2787
|
+
// A picture of it, and a line about it, where there is one.
|
|
2788
|
+
art: artOf(one),
|
|
2789
|
+
about: one.about ?? "",
|
|
2683
2790
|
})),
|
|
2684
2791
|
// Anything re-streamed into this server is a live stream too, and was
|
|
2685
2792
|
// sitting in the middle of the playlist among the files -- which is
|
|
@@ -2799,7 +2906,9 @@ export function createHandler(engine, options) {
|
|
|
2799
2906
|
json(response, 429, { error: `this server is already carrying ${MAX_ON_DEMAND} channels on demand; try again in a minute` });
|
|
2800
2907
|
return;
|
|
2801
2908
|
}
|
|
2802
|
-
const started = await pullChannel(options.channels, options.ffprobe ?? ["ffprobe"], channelId, entry.title, entry.source
|
|
2909
|
+
const started = await pullChannel(options.channels, options.ffprobe ?? ["ffprobe"], channelId, entry.title, entry.source, [], "",
|
|
2910
|
+
// The logo the list gave it is its picture.
|
|
2911
|
+
entry.logo ? { art: entry.logo, about: entry.group } : {});
|
|
2803
2912
|
if (!started) {
|
|
2804
2913
|
json(response, 409, { error: "that channel is already starting" });
|
|
2805
2914
|
return;
|
|
@@ -2833,7 +2942,9 @@ export function createHandler(engine, options) {
|
|
|
2833
2942
|
}
|
|
2834
2943
|
}
|
|
2835
2944
|
if (!options.channels.has(channelId)) {
|
|
2836
|
-
const started = await pullChannel(options.channels, options.ffprobe ?? ["ffprobe"], channelId, entry.title, entry.source
|
|
2945
|
+
const started = await pullChannel(options.channels, options.ffprobe ?? ["ffprobe"], channelId, entry.title, entry.source, [], "",
|
|
2946
|
+
// The logo the list gave it is its picture.
|
|
2947
|
+
entry.logo ? { art: entry.logo, about: entry.group } : {});
|
|
2837
2948
|
if (!started) {
|
|
2838
2949
|
json(response, 409, { error: "that channel is already starting" });
|
|
2839
2950
|
return;
|
|
@@ -3020,7 +3131,11 @@ export function createHandler(engine, options) {
|
|
|
3020
3131
|
});
|
|
3021
3132
|
return;
|
|
3022
3133
|
}
|
|
3023
|
-
const started = await pullChannel(options.channels, options.ffprobe ?? ["ffprobe"], channelId, resolved.title, resolved.media, inputArgsFor(resolved.headers), resolved.audio,
|
|
3134
|
+
const started = await pullChannel(options.channels, options.ffprobe ?? ["ffprobe"], channelId, resolved.title, resolved.media, inputArgsFor(resolved.headers), resolved.audio, {
|
|
3135
|
+
...(resolved.playlist ? { playlist: resolved.playlist } : {}),
|
|
3136
|
+
...(resolved.thumbnail ? { art: resolved.thumbnail } : {}),
|
|
3137
|
+
...(resolved.about ? { about: resolved.about } : {}),
|
|
3138
|
+
});
|
|
3024
3139
|
if (!started) {
|
|
3025
3140
|
json(response, 409, { error: "that link is already starting" });
|
|
3026
3141
|
return;
|
|
@@ -3225,6 +3340,48 @@ export function createHandler(engine, options) {
|
|
|
3225
3340
|
const compression = compressionCtx();
|
|
3226
3341
|
if (compression && (await handleChannelCompression(request, response, compression, id, action, file)))
|
|
3227
3342
|
return;
|
|
3343
|
+
// A picture of the channel: the sleeve in the file, or one frame of
|
|
3344
|
+
// what it is sending, as a JPEG. This is what a link preview shows
|
|
3345
|
+
// and what the lock screen shows, and a crawler fetches it with the
|
|
3346
|
+
// listen key on the address, since it holds no cookie.
|
|
3347
|
+
if (action === "art" && (request.method === "GET" || request.method === "HEAD")) {
|
|
3348
|
+
const info = channels.info(id);
|
|
3349
|
+
if (!info || options.carries === false) {
|
|
3350
|
+
json(response, 404, { error: "nothing is playing on that channel" });
|
|
3351
|
+
return;
|
|
3352
|
+
}
|
|
3353
|
+
// The site's own picture is where it is; sent there, so one address
|
|
3354
|
+
// per channel serves whichever kind it has.
|
|
3355
|
+
if (info.art && /^https?:\/\//i.test(info.art)) {
|
|
3356
|
+
response.writeHead(302, { ...CORS, location: info.art, "cache-control": "public, max-age=300" });
|
|
3357
|
+
response.end();
|
|
3358
|
+
return;
|
|
3359
|
+
}
|
|
3360
|
+
const ffmpeg = options.ffmpeg ?? ["ffmpeg"];
|
|
3361
|
+
const cover = info.codecs?.cover === true && Boolean(info.source);
|
|
3362
|
+
const cache = options.art ?? new ArtCache();
|
|
3363
|
+
const bytes = cover
|
|
3364
|
+
? await cache.get(`${id}:cover:${info.startedAt}`, true, () => {
|
|
3365
|
+
// With the headers its site wanted, when it came from a link.
|
|
3366
|
+
const link = [...links.values()].find((one) => one.media === info.source);
|
|
3367
|
+
return coverArtOf(ffmpeg, info.source, link ? inputArgsFor(link.headers) : []);
|
|
3368
|
+
})
|
|
3369
|
+
: info.kind === "video"
|
|
3370
|
+
? await cache.get(`${id}:still`, false, () => stillFrom(ffmpeg, channels.opening(id)))
|
|
3371
|
+
: null;
|
|
3372
|
+
if (!bytes) {
|
|
3373
|
+
json(response, 404, { error: "that channel has no picture yet" });
|
|
3374
|
+
return;
|
|
3375
|
+
}
|
|
3376
|
+
response.writeHead(200, {
|
|
3377
|
+
...CORS,
|
|
3378
|
+
"content-type": "image/jpeg",
|
|
3379
|
+
"content-length": bytes.byteLength,
|
|
3380
|
+
"cache-control": cover ? "public, max-age=3600" : "public, max-age=60",
|
|
3381
|
+
});
|
|
3382
|
+
response.end(request.method === "HEAD" ? undefined : bytes);
|
|
3383
|
+
return;
|
|
3384
|
+
}
|
|
3228
3385
|
// The same channel as HLS: a playlist of short files, which is what
|
|
3229
3386
|
// Safari on an iPhone plays live -- it will not take the endless MP4
|
|
3230
3387
|
// below, and spun on it a few times before giving up. Packaged on
|
|
@@ -4488,10 +4645,15 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
4488
4645
|
// The operator drives with the control key, and is not a customer.
|
|
4489
4646
|
exempt: (request) => key !== null && scopeOf(keyFrom(request, new URL(request.url ?? "/", "http://localhost")), key, null) === "control",
|
|
4490
4647
|
});
|
|
4648
|
+
// The pictures of channels, taken once each and kept while the channel is on.
|
|
4649
|
+
const art = new ArtCache();
|
|
4491
4650
|
const channels = new Channels({
|
|
4492
4651
|
ffmpeg: tools.ffmpeg,
|
|
4493
4652
|
onStart: (info) => console.log(` ${info.name} is publishing to "${info.id}" (${info.format} over ${info.via}).`),
|
|
4494
|
-
onEnd: (info) =>
|
|
4653
|
+
onEnd: (info) => {
|
|
4654
|
+
console.log(` "${info.id}" stopped.`);
|
|
4655
|
+
art.forget(info.id);
|
|
4656
|
+
},
|
|
4495
4657
|
});
|
|
4496
4658
|
// Channels as HLS, on demand, for Safari on a phone: one ffmpeg copying a
|
|
4497
4659
|
// channel's fragments into short files while somebody is asking for them.
|
|
@@ -4534,6 +4696,8 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
4534
4696
|
...(one.position !== undefined ? { position: one.position } : {}),
|
|
4535
4697
|
...(one.live !== undefined ? { live: one.live } : {}),
|
|
4536
4698
|
...(one.playlist ? { playlist: one.playlist, playlistAt: one.playlistAt ?? 0 } : {}),
|
|
4699
|
+
...(one.art ? { art: one.art } : {}),
|
|
4700
|
+
...(one.about ? { about: one.about } : {}),
|
|
4537
4701
|
}).then((channel) => {
|
|
4538
4702
|
if (!channel)
|
|
4539
4703
|
console.log(` "${one.id}" is already on.`);
|
|
@@ -4984,6 +5148,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
4984
5148
|
carries: tools.carries !== false,
|
|
4985
5149
|
cookies: cookiesFile(),
|
|
4986
5150
|
hls,
|
|
5151
|
+
art,
|
|
4987
5152
|
...(captions ? { captions } : {}),
|
|
4988
5153
|
compression,
|
|
4989
5154
|
enricher,
|
|
@@ -5308,6 +5473,9 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
5308
5473
|
// live: CNN" is one somebody can decide about; "5717 tracks" was not.
|
|
5309
5474
|
playing: () => engine.snapshot(false).playing,
|
|
5310
5475
|
channels: () => channels.list().map((one) => one.name),
|
|
5476
|
+
// The same with what a link preview needs: the id a join link names
|
|
5477
|
+
// a channel by, a picture, a line.
|
|
5478
|
+
lineup: () => channels.list().map((one) => lineupEntry(listen, one)),
|
|
5311
5479
|
// From `nixamp login`. The directory will not list a stream it cannot
|
|
5312
5480
|
// attribute to somebody, because a listing is now a phone code that
|
|
5313
5481
|
// costs money to answer.
|
package/package.json
CHANGED
package/src/art.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A picture of a channel, for the places a picture goes: the card a link
|
|
3
|
+
* unfurls into on a chat or a timeline, the lock screen while it plays,
|
|
4
|
+
* the row in the directory.
|
|
5
|
+
*
|
|
6
|
+
* Three places one can come from, tried in this order by whoever asks.
|
|
7
|
+
* The site's own, when a pasted link resolved to a thumbnail -- that is a
|
|
8
|
+
* URL and costs nothing. The sleeve in the file, when a podcast's MP3
|
|
9
|
+
* carries its cover as an attached picture; ffmpeg reads that one frame
|
|
10
|
+
* out of the head of the file. And for anything with a picture, a frame
|
|
11
|
+
* of the picture itself: the channel keeps the last few seconds of what
|
|
12
|
+
* it is sending, and one frame of that is a screenshot of what is on --
|
|
13
|
+
* taken from our own output, never by opening the source a second time,
|
|
14
|
+
* because a panel that allows one connection per film counts that one
|
|
15
|
+
* against the channel and drops it.
|
|
16
|
+
*
|
|
17
|
+
* Every picture comes back as a JPEG no wider than a card wants.
|
|
18
|
+
*/
|
|
19
|
+
import { spawn } from "node:child_process";
|
|
20
|
+
|
|
21
|
+
/** As wide as a preview card is drawn; anything bigger is bytes for nothing. */
|
|
22
|
+
export const ART_WIDTH = 960;
|
|
23
|
+
/** A picture bigger than this is not a thumbnail. */
|
|
24
|
+
export const ART_MAX_BYTES = 2 * 1024 * 1024;
|
|
25
|
+
/** How long ffmpeg gets to produce one frame before it is not going to. */
|
|
26
|
+
export const ART_TIMEOUT_MS = 20_000;
|
|
27
|
+
/** How long a frame of a live picture stays the frame: it changes, but not that fast. */
|
|
28
|
+
export const STILL_TTL_MS = 60_000;
|
|
29
|
+
|
|
30
|
+
/** The ffmpeg arguments that turn whatever is on `-i` into one JPEG on stdout. */
|
|
31
|
+
function oneFrame(input: string[], scale = true): string[] {
|
|
32
|
+
return [
|
|
33
|
+
"-hide_banner", "-loglevel", "error", "-nostdin",
|
|
34
|
+
...input,
|
|
35
|
+
"-map", "0:v:0",
|
|
36
|
+
"-frames:v", "1",
|
|
37
|
+
// No wider than the card, never upscaled, and an even height so the
|
|
38
|
+
// encoder does not refuse an odd one.
|
|
39
|
+
...(scale ? ["-vf", `scale='min(${ART_WIDTH},iw)':-2`] : []),
|
|
40
|
+
"-c:v", "mjpeg", "-q:v", "4",
|
|
41
|
+
"-f", "image2", "pipe:1",
|
|
42
|
+
];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Run ffmpeg for one picture: feed it `stdin` when there is something to
|
|
47
|
+
* feed, read stdout until it ends, and answer the JPEG or null. Never
|
|
48
|
+
* throws: a source with no picture, a decoder that cannot start, a frame
|
|
49
|
+
* that takes too long, all answer null and the caller shows nothing.
|
|
50
|
+
*/
|
|
51
|
+
function picture(ffmpeg: string[], args: string[], stdin: Buffer[] | null): Promise<Buffer | null> {
|
|
52
|
+
const [command, ...prefix] = ffmpeg;
|
|
53
|
+
if (!command) return Promise.resolve(null);
|
|
54
|
+
return new Promise((done) => {
|
|
55
|
+
const chunks: Buffer[] = [];
|
|
56
|
+
let size = 0;
|
|
57
|
+
let settled = false;
|
|
58
|
+
const finish = (answer: Buffer | null): void => {
|
|
59
|
+
if (settled) return;
|
|
60
|
+
settled = true;
|
|
61
|
+
clearTimeout(timer);
|
|
62
|
+
done(answer);
|
|
63
|
+
};
|
|
64
|
+
let child: ReturnType<typeof spawn>;
|
|
65
|
+
try {
|
|
66
|
+
child = spawn(command, [...prefix, ...args], { stdio: [stdin ? "pipe" : "ignore", "pipe", "ignore"] });
|
|
67
|
+
} catch {
|
|
68
|
+
finish(null);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const timer = setTimeout(() => {
|
|
72
|
+
child.kill("SIGKILL");
|
|
73
|
+
finish(null);
|
|
74
|
+
}, ART_TIMEOUT_MS);
|
|
75
|
+
child.on("error", () => finish(null));
|
|
76
|
+
child.stdout?.on("data", (chunk: Buffer) => {
|
|
77
|
+
size += chunk.byteLength;
|
|
78
|
+
if (size <= ART_MAX_BYTES) chunks.push(chunk);
|
|
79
|
+
});
|
|
80
|
+
child.on("close", () => {
|
|
81
|
+
const bytes = Buffer.concat(chunks);
|
|
82
|
+
// A JPEG starts FF D8; anything else is ffmpeg having written nothing.
|
|
83
|
+
finish(size > 0 && size <= ART_MAX_BYTES && bytes[0] === 0xff && bytes[1] === 0xd8 ? bytes : null);
|
|
84
|
+
});
|
|
85
|
+
if (stdin && child.stdin) {
|
|
86
|
+
const input = child.stdin;
|
|
87
|
+
input.on("error", () => { /* ffmpeg stopped reading once it had its frame */ });
|
|
88
|
+
for (const piece of stdin) input.write(piece);
|
|
89
|
+
input.end();
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The sleeve in a file: the attached picture at the head of a podcast's
|
|
96
|
+
* MP3 or M4A, read with the same input arguments the channel dials with
|
|
97
|
+
* (a site's headers, say). ffmpeg stops after the one frame, so a file of
|
|
98
|
+
* an hour costs the head of it. Null when there is none.
|
|
99
|
+
*/
|
|
100
|
+
export function coverArtOf(ffmpeg: string[], source: string, input: string[] = []): Promise<Buffer | null> {
|
|
101
|
+
return picture(ffmpeg, oneFrame([...input, "-i", source]), null);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* One frame of what a channel is sending now, decoded from the opening
|
|
106
|
+
* boxes and the recent backlog it hands every new listener. The bytes are
|
|
107
|
+
* a fragmented MP4 that ffmpeg reads from a pipe as it would from a file.
|
|
108
|
+
* Null while the channel has no backlog yet, or for a channel of sound.
|
|
109
|
+
*/
|
|
110
|
+
export function stillFrom(ffmpeg: string[], opening: Buffer[]): Promise<Buffer | null> {
|
|
111
|
+
if (opening.length === 0 || opening.every((piece) => piece.byteLength === 0)) return Promise.resolve(null);
|
|
112
|
+
return picture(ffmpeg, oneFrame(["-f", "mp4", "-i", "pipe:0"]), opening);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The pictures already taken, so a card that is unfurled by five crawlers
|
|
117
|
+
* at once costs one ffmpeg, and a sleeve is read from the file once. A
|
|
118
|
+
* still of a live picture is kept for a minute and taken again; a sleeve
|
|
119
|
+
* is kept for as long as the channel is on.
|
|
120
|
+
*/
|
|
121
|
+
export class ArtCache {
|
|
122
|
+
private readonly kept = new Map<string, { at: number; forever: boolean; bytes: Buffer | null; pending?: Promise<Buffer | null> }>();
|
|
123
|
+
|
|
124
|
+
constructor(private readonly now: () => number = () => Date.now()) {}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The picture for a key, taking it with `take` when there is none fresh
|
|
128
|
+
* enough. A picture that came back null is remembered as null for the
|
|
129
|
+
* same while, so a channel with no picture is not asked every second.
|
|
130
|
+
*/
|
|
131
|
+
async get(key: string, forever: boolean, take: () => Promise<Buffer | null>): Promise<Buffer | null> {
|
|
132
|
+
const have = this.kept.get(key);
|
|
133
|
+
if (have) {
|
|
134
|
+
if (have.pending) return have.pending;
|
|
135
|
+
if (have.forever || this.now() - have.at < STILL_TTL_MS) return have.bytes;
|
|
136
|
+
}
|
|
137
|
+
const pending = take().then((bytes) => {
|
|
138
|
+
this.kept.set(key, { at: this.now(), forever: forever && bytes !== null, bytes });
|
|
139
|
+
return bytes;
|
|
140
|
+
});
|
|
141
|
+
this.kept.set(key, { at: this.now(), forever: false, bytes: null, pending });
|
|
142
|
+
return pending;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** A channel went off: its pictures with it. */
|
|
146
|
+
forget(prefix: string): void {
|
|
147
|
+
for (const key of this.kept.keys()) {
|
|
148
|
+
if (key === prefix || key.startsWith(`${prefix}:`)) this.kept.delete(key);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|