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/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
- export declare function joinSubject(url: URL, options: Pick<HandlerOptions, "directory" | "channels" | "serverName">): {
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
- } | null;
486
- /** The shell, titled for what a join link opens. */
487
- export declare function joinDocument(shell: string, subject: {
488
- title: string;
489
- where: string;
490
- }, site: string): string;
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
- const channel = channels.pull(id, name, source, encode, kind, true, undefined, opening, kind === "video" ? audio : "", { live, position: known.position ?? 0, ...(playlist ? { playlist } : {}) },
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 !== "" && listing.channels.includes(wanted))
1136
- return { title: wanted, where: listing.name };
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
- return channel ? { title: channel.name, where: name } : null;
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 description = subject.where === ""
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>", `<meta name="nixamp-shell-title" content="${htmlText(shellTitle)}" />\n </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, resolved.playlist ? { playlist: resolved.playlist } : {});
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) => console.log(` "${info.id}" stopped.`),
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.23.1",
3
+ "version": "0.23.3",
4
4
  "description": "It really whips the terminal's ass. A Winamp-shaped audio player for your terminal.",
5
5
  "license": "MIT",
6
6
  "type": "module",
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
+ }