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 CHANGED
@@ -307,8 +307,10 @@ with the account's public handle, never its address.
307
307
 
308
308
  A line can be said rather than typed. The microphone button beside the box
309
309
  is tap, talk, tap: the page records, brings the sound to 16 kHz mono itself,
310
- and sends nixamp.com a small WAV; the words come back into the box, and
311
- **Send** is still yours, so a misheard word is fixed before the room sees it.
310
+ and sends nixamp.com a small WAV with the room's name on it; the ear posts
311
+ the words to the room in the same request, and the line appears. Said is
312
+ sent. A line you would rather see first is typed; a wrong one is taken down
313
+ with its ✕.
312
314
  The ear is [Whisper](https://github.com/openai/whisper) run through
313
315
  [Transformers.js](https://github.com/huggingface/transformers.js), an
314
316
  Apache-2.0 library carrying MIT-licensed models, on nixamp.com's own CPU.
@@ -382,8 +384,17 @@ its own audience.
382
384
  GET /api/channels what is live now
383
385
  POST /api/channels/<id> publish to one
384
386
  GET /api/channels/<id> listen to one
387
+ GET /api/channels/<id>/art a picture of it, as JPEG
385
388
  ```
386
389
 
390
+ The picture is what a share link unfurls into on a chat or a timeline, and
391
+ what the lock screen shows while it plays. A pasted link's thumbnail is sent
392
+ on as it is; a podcast's sleeve is read out of the file; a moving picture
393
+ gets one frame of what the channel is sending, taken from the channel's own
394
+ backlog rather than by opening the source a second time. The page for a
395
+ share link (`/?url=...&play=channel:<id>`) carries it as `og:image` with a
396
+ Twitter card, on nixamp.com and on the server itself.
397
+
387
398
  One ffmpeg decodes each publisher once and the result is written to every
388
399
  listener on that channel. A decode per listener would cost a core each and, for
389
400
  a live stream, would not even agree with itself about what "now" is.
package/dist/art.d.ts ADDED
@@ -0,0 +1,41 @@
1
+ /** As wide as a preview card is drawn; anything bigger is bytes for nothing. */
2
+ export declare const ART_WIDTH = 960;
3
+ /** A picture bigger than this is not a thumbnail. */
4
+ export declare const ART_MAX_BYTES: number;
5
+ /** How long ffmpeg gets to produce one frame before it is not going to. */
6
+ export declare const ART_TIMEOUT_MS = 20000;
7
+ /** How long a frame of a live picture stays the frame: it changes, but not that fast. */
8
+ export declare const STILL_TTL_MS = 60000;
9
+ /**
10
+ * The sleeve in a file: the attached picture at the head of a podcast's
11
+ * MP3 or M4A, read with the same input arguments the channel dials with
12
+ * (a site's headers, say). ffmpeg stops after the one frame, so a file of
13
+ * an hour costs the head of it. Null when there is none.
14
+ */
15
+ export declare function coverArtOf(ffmpeg: string[], source: string, input?: string[]): Promise<Buffer | null>;
16
+ /**
17
+ * One frame of what a channel is sending now, decoded from the opening
18
+ * boxes and the recent backlog it hands every new listener. The bytes are
19
+ * a fragmented MP4 that ffmpeg reads from a pipe as it would from a file.
20
+ * Null while the channel has no backlog yet, or for a channel of sound.
21
+ */
22
+ export declare function stillFrom(ffmpeg: string[], opening: Buffer[]): Promise<Buffer | null>;
23
+ /**
24
+ * The pictures already taken, so a card that is unfurled by five crawlers
25
+ * at once costs one ffmpeg, and a sleeve is read from the file once. A
26
+ * still of a live picture is kept for a minute and taken again; a sleeve
27
+ * is kept for as long as the channel is on.
28
+ */
29
+ export declare class ArtCache {
30
+ private readonly now;
31
+ private readonly kept;
32
+ constructor(now?: () => number);
33
+ /**
34
+ * The picture for a key, taking it with `take` when there is none fresh
35
+ * enough. A picture that came back null is remembered as null for the
36
+ * same while, so a channel with no picture is not asked every second.
37
+ */
38
+ get(key: string, forever: boolean, take: () => Promise<Buffer | null>): Promise<Buffer | null>;
39
+ /** A channel went off: its pictures with it. */
40
+ forget(prefix: string): void;
41
+ }
package/dist/art.js ADDED
@@ -0,0 +1,154 @@
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
+ /** As wide as a preview card is drawn; anything bigger is bytes for nothing. */
21
+ export const ART_WIDTH = 960;
22
+ /** A picture bigger than this is not a thumbnail. */
23
+ export const ART_MAX_BYTES = 2 * 1024 * 1024;
24
+ /** How long ffmpeg gets to produce one frame before it is not going to. */
25
+ export const ART_TIMEOUT_MS = 20_000;
26
+ /** How long a frame of a live picture stays the frame: it changes, but not that fast. */
27
+ export const STILL_TTL_MS = 60_000;
28
+ /** The ffmpeg arguments that turn whatever is on `-i` into one JPEG on stdout. */
29
+ function oneFrame(input, scale = true) {
30
+ return [
31
+ "-hide_banner", "-loglevel", "error", "-nostdin",
32
+ ...input,
33
+ "-map", "0:v:0",
34
+ "-frames:v", "1",
35
+ // No wider than the card, never upscaled, and an even height so the
36
+ // encoder does not refuse an odd one.
37
+ ...(scale ? ["-vf", `scale='min(${ART_WIDTH},iw)':-2`] : []),
38
+ "-c:v", "mjpeg", "-q:v", "4",
39
+ "-f", "image2", "pipe:1",
40
+ ];
41
+ }
42
+ /**
43
+ * Run ffmpeg for one picture: feed it `stdin` when there is something to
44
+ * feed, read stdout until it ends, and answer the JPEG or null. Never
45
+ * throws: a source with no picture, a decoder that cannot start, a frame
46
+ * that takes too long, all answer null and the caller shows nothing.
47
+ */
48
+ function picture(ffmpeg, args, stdin) {
49
+ const [command, ...prefix] = ffmpeg;
50
+ if (!command)
51
+ return Promise.resolve(null);
52
+ return new Promise((done) => {
53
+ const chunks = [];
54
+ let size = 0;
55
+ let settled = false;
56
+ const finish = (answer) => {
57
+ if (settled)
58
+ return;
59
+ settled = true;
60
+ clearTimeout(timer);
61
+ done(answer);
62
+ };
63
+ let child;
64
+ try {
65
+ child = spawn(command, [...prefix, ...args], { stdio: [stdin ? "pipe" : "ignore", "pipe", "ignore"] });
66
+ }
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) => {
77
+ size += chunk.byteLength;
78
+ if (size <= ART_MAX_BYTES)
79
+ chunks.push(chunk);
80
+ });
81
+ child.on("close", () => {
82
+ const bytes = Buffer.concat(chunks);
83
+ // A JPEG starts FF D8; anything else is ffmpeg having written nothing.
84
+ finish(size > 0 && size <= ART_MAX_BYTES && bytes[0] === 0xff && bytes[1] === 0xd8 ? bytes : null);
85
+ });
86
+ if (stdin && child.stdin) {
87
+ const input = child.stdin;
88
+ input.on("error", () => { });
89
+ for (const piece of stdin)
90
+ input.write(piece);
91
+ input.end();
92
+ }
93
+ });
94
+ }
95
+ /**
96
+ * The sleeve in a file: the attached picture at the head of a podcast's
97
+ * MP3 or M4A, read with the same input arguments the channel dials with
98
+ * (a site's headers, say). ffmpeg stops after the one frame, so a file of
99
+ * an hour costs the head of it. Null when there is none.
100
+ */
101
+ export function coverArtOf(ffmpeg, source, input = []) {
102
+ return picture(ffmpeg, oneFrame([...input, "-i", source]), null);
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, opening) {
111
+ if (opening.length === 0 || opening.every((piece) => piece.byteLength === 0))
112
+ return Promise.resolve(null);
113
+ return picture(ffmpeg, oneFrame(["-f", "mp4", "-i", "pipe:0"]), opening);
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
+ now;
123
+ kept = new Map();
124
+ constructor(now = () => Date.now()) {
125
+ this.now = now;
126
+ }
127
+ /**
128
+ * The picture for a key, taking it with `take` when there is none fresh
129
+ * enough. A picture that came back null is remembered as null for the
130
+ * same while, so a channel with no picture is not asked every second.
131
+ */
132
+ async get(key, forever, take) {
133
+ const have = this.kept.get(key);
134
+ if (have) {
135
+ if (have.pending)
136
+ return have.pending;
137
+ if (have.forever || this.now() - have.at < STILL_TTL_MS)
138
+ return have.bytes;
139
+ }
140
+ const pending = take().then((bytes) => {
141
+ this.kept.set(key, { at: this.now(), forever: forever && bytes !== null, bytes });
142
+ return bytes;
143
+ });
144
+ this.kept.set(key, { at: this.now(), forever: false, bytes: null, pending });
145
+ return pending;
146
+ }
147
+ /** A channel went off: its pictures with it. */
148
+ forget(prefix) {
149
+ for (const key of this.kept.keys()) {
150
+ if (key === prefix || key.startsWith(`${prefix}:`))
151
+ this.kept.delete(key);
152
+ }
153
+ }
154
+ }
package/dist/audio.d.ts CHANGED
@@ -117,6 +117,18 @@ export interface Codecs {
117
117
  */
118
118
  width?: number;
119
119
  height?: number;
120
+ /**
121
+ * Whether a sleeve is embedded: the attached picture an MP3 or M4A of a
122
+ * podcast carries. Not a picture to play, but a picture to show -- on
123
+ * the link preview and the lock screen -- and `coverArtOf` reads it out.
124
+ */
125
+ cover?: boolean;
126
+ /** What the file says about itself in its tags, for naming a bare file. */
127
+ tags?: {
128
+ title?: string;
129
+ artist?: string;
130
+ album?: string;
131
+ };
120
132
  }
121
133
  /**
122
134
  * Ask ffprobe what the streams are, without holding the event loop.
@@ -126,6 +138,12 @@ export interface Codecs {
126
138
  * the whole library came to be tagged with the process wedged solid.
127
139
  */
128
140
  export declare function codecsOf(tools: Tools, path: string, input?: string[]): Promise<Codecs>;
141
+ /**
142
+ * The title, artist and album a file carries in its tags, however the
143
+ * container spells the keys (ID3 says TIT2 but ffprobe says title; MP4 and
144
+ * Matroska say Title). Absent when it carries none worth keeping.
145
+ */
146
+ export declare function tagsOf(raw: Record<string, unknown> | undefined): Codecs["tags"] | undefined;
129
147
  export declare function isCoverArt(stream: {
130
148
  codec_name?: string;
131
149
  disposition?: {
package/dist/audio.js CHANGED
@@ -369,7 +369,7 @@ export async function codecsOf(tools, path, input = []) {
369
369
  // The disposition too: an MP3 with its cover art in it carries that
370
370
  // art as a video stream of one JPEG, and a probe that took it for a
371
371
  // picture put a podcast on the air as a film with no frames.
372
- "-show_entries", "format=format_name,duration:stream=codec_type,codec_name,width,height:stream_disposition=attached_pic",
372
+ "-show_entries", "format=format_name,duration:format_tags=title,artist,album:stream=codec_type,codec_name,width,height:stream_disposition=attached_pic",
373
373
  // A transport stream needs looking further into than a file with an
374
374
  // index does: there is no header listing the tracks, only packets, and
375
375
  // a 4K recording can carry a second of null padding and a long gap to
@@ -390,6 +390,8 @@ export async function codecsOf(tools, path, input = []) {
390
390
  try {
391
391
  const parsed = JSON.parse(out);
392
392
  const streams = parsed.streams ?? [];
393
+ const cover = streams.some((s) => s.codec_type === "video" && s.disposition?.attached_pic === 1);
394
+ const tags = tagsOf(parsed.format?.tags);
393
395
  // ffprobe prints seconds as a string, and "N/A" for a stream with no
394
396
  // end; both of those read as 0.
395
397
  const seconds = Number(parsed.format?.duration ?? 0);
@@ -401,6 +403,8 @@ export async function codecsOf(tools, path, input = []) {
401
403
  duration: Number.isFinite(seconds) && seconds > 0 ? seconds : 0,
402
404
  ...(typeof picture?.width === "number" && picture.width > 0 ? { width: picture.width } : {}),
403
405
  ...(typeof picture?.height === "number" && picture.height > 0 ? { height: picture.height } : {}),
406
+ ...(cover ? { cover } : {}),
407
+ ...(tags ? { tags } : {}),
404
408
  });
405
409
  }
406
410
  catch {
@@ -409,6 +413,28 @@ export async function codecsOf(tools, path, input = []) {
409
413
  });
410
414
  });
411
415
  }
416
+ /**
417
+ * The title, artist and album a file carries in its tags, however the
418
+ * container spells the keys (ID3 says TIT2 but ffprobe says title; MP4 and
419
+ * Matroska say Title). Absent when it carries none worth keeping.
420
+ */
421
+ export function tagsOf(raw) {
422
+ if (!raw || typeof raw !== "object")
423
+ return undefined;
424
+ const lower = {};
425
+ for (const [key, value] of Object.entries(raw)) {
426
+ if (typeof value === "string")
427
+ lower[key.toLowerCase()] = value.replace(/[\u0000-\u001F\u007F]+/g, " ").trim().slice(0, 200);
428
+ }
429
+ const tags = {};
430
+ if (lower["title"])
431
+ tags.title = lower["title"];
432
+ if (lower["artist"])
433
+ tags.artist = lower["artist"];
434
+ if (lower["album"])
435
+ tags.album = lower["album"];
436
+ return Object.keys(tags).length > 0 ? tags : undefined;
437
+ }
412
438
  /**
413
439
  * Cover art is not a picture.
414
440
  *
@@ -56,6 +56,7 @@ export interface ChannelInfo {
56
56
  duration?: number;
57
57
  width?: number;
58
58
  height?: number;
59
+ cover?: boolean;
59
60
  };
60
61
  /**
61
62
  * What the channel itself is producing, which is not always what its source
@@ -79,6 +80,15 @@ export interface ChannelInfo {
79
80
  * plain URL is read this way, and only when a policy asks for it.
80
81
  */
81
82
  teed?: boolean;
83
+ /**
84
+ * A picture of it from somewhere else: the thumbnail the site offered
85
+ * for a pasted link, the logo a catalog gave a channel. A channel with
86
+ * none may still have one of its own -- a sleeve in the file, a frame of
87
+ * the picture -- which /api/channels/:id/art reads out on demand.
88
+ */
89
+ art?: string;
90
+ /** A line about it for a preview: the show, the uploader, the first line of the notes. */
91
+ about?: string;
82
92
  }
83
93
  /** Where a pulled source is picked up from, and whether it can be at all. */
84
94
  export interface PullResume {
@@ -483,6 +493,7 @@ export interface RememberedChannel {
483
493
  duration?: number;
484
494
  width?: number;
485
495
  height?: number;
496
+ cover?: boolean;
486
497
  };
487
498
  /** Where a film had got to, in seconds, so it picks up there. */
488
499
  position?: number;
@@ -493,6 +504,9 @@ export interface RememberedChannel {
493
504
  /** For a list: every entry, and which was on, so it carries on from there. */
494
505
  playlist?: string[];
495
506
  playlistAt?: number;
507
+ /** Its picture and its line, as the site gave them, so a restart need not ask. */
508
+ art?: string;
509
+ about?: string;
496
510
  }
497
511
  export declare function rememberedChannels(dir: string, port: number): RememberedChannel[];
498
512
  /**
package/dist/channels.js CHANGED
@@ -1053,6 +1053,10 @@ export function rememberedChannels(dir, port) {
1053
1053
  kept.codecs.width = c["width"];
1054
1054
  if (typeof c["height"] === "number" && Number.isFinite(c["height"]))
1055
1055
  kept.codecs.height = c["height"];
1056
+ // A sleeve in the file is a picture for the card, and knowing it
1057
+ // is there saves reading the file again to find out.
1058
+ if (c["cover"] === true)
1059
+ kept.codecs.cover = true;
1056
1060
  }
1057
1061
  }
1058
1062
  if (typeof one["position"] === "number" && Number.isFinite(one["position"]) && one["position"] > 0)
@@ -1068,6 +1072,10 @@ export function rememberedChannels(dir, port) {
1068
1072
  if (typeof one["playlistAt"] === "number" && Number.isInteger(one["playlistAt"]) && one["playlistAt"] >= 0)
1069
1073
  kept.playlistAt = one["playlistAt"];
1070
1074
  }
1075
+ if (typeof one["art"] === "string" && /^https?:\/\//i.test(one["art"]))
1076
+ kept.art = one["art"].slice(0, 2048);
1077
+ if (typeof one["about"] === "string" && one["about"] !== "")
1078
+ kept.about = one["about"].slice(0, 200);
1071
1079
  return kept;
1072
1080
  });
1073
1081
  }
@@ -1100,6 +1108,10 @@ export function rememberedNow(channels) {
1100
1108
  kept.playlist = one.playlist;
1101
1109
  kept.playlistAt = one.playlistAt ?? 0;
1102
1110
  }
1111
+ if (one.art)
1112
+ kept.art = one.art;
1113
+ if (one.about)
1114
+ kept.about = one.about;
1103
1115
  return kept;
1104
1116
  });
1105
1117
  }
@@ -75,6 +75,12 @@ export interface Listing {
75
75
  * about another on the same server.
76
76
  */
77
77
  channelCodes: Record<string, string>;
78
+ /**
79
+ * The same channels with what a preview needs: the id a join link names
80
+ * them by, a picture, a line. `channels` stays the list of names for
81
+ * the phone line and for anything reading an older listing.
82
+ */
83
+ lineup: LineupEntry[];
78
84
  /** Set by the directory from the request, never by the publisher. */
79
85
  updatedAt: number;
80
86
  /** When this stream first announced itself: the "started at" a caller hears. */
@@ -124,6 +130,20 @@ export interface Announcement {
124
130
  playing?: boolean;
125
131
  /** Names of the live channels on it. Absent from an older publisher. */
126
132
  channels?: string[];
133
+ /** Each of those with its id, picture and line. Absent from an older publisher. */
134
+ lineup?: LineupEntry[];
135
+ }
136
+ /**
137
+ * One live channel as a preview sees it. The picture is a web address the
138
+ * publisher vouches for -- its own art route, or the thumbnail a site gave
139
+ * it -- and a crawler fetches it, so it is only ever http(s).
140
+ */
141
+ export interface LineupEntry {
142
+ id: string;
143
+ name: string;
144
+ kind: "audio" | "video";
145
+ art: string;
146
+ about: string;
127
147
  }
128
148
  /** Trim and flatten, so one publisher cannot draw a box in someone's terminal. */
129
149
  export declare function clean(value: unknown, max: number): string;
@@ -135,6 +155,8 @@ export declare function clean(value: unknown, max: number): string;
135
155
  */
136
156
  export declare function publishable(raw: string): URL | null;
137
157
  export declare function parseAnnouncement(input: unknown): Announcement | null;
158
+ /** One lineup entry as a publisher sent it, cleaned, or null when it is not one. */
159
+ export declare function parseLineupEntry(input: unknown): LineupEntry | null;
138
160
  /**
139
161
  * The registry. In memory on purpose: see the note at the top of the file.
140
162
  * One entry per URL, so a publisher restarting does not leave a ghost of
package/dist/directory.js CHANGED
@@ -19,6 +19,9 @@ export const DEFAULT_DIRECTORY = "https://nixamp.com";
19
19
  /** How long an ended stream is still worth telling a caller about. */
20
20
  export const ENDED_TTL_MS = 24 * 60 * 60 * 1000;
21
21
  const MAX_NAME = 60;
22
+ const MAX_ID = 64;
23
+ const MAX_ABOUT = 200;
24
+ const MAX_ART = 2048;
22
25
  const MAX_TRACK = 120;
23
26
  /** How many channel names a listing carries. A multiview is four; eight is plenty. */
24
27
  const MAX_CHANNELS = 8;
@@ -91,6 +94,29 @@ export function parseAnnouncement(input) {
91
94
  .slice(0, MAX_CHANNELS),
92
95
  }
93
96
  : {}),
97
+ ...(Array.isArray(record["lineup"])
98
+ ? { lineup: record["lineup"].map(parseLineupEntry).filter((one) => one !== null).slice(0, MAX_CHANNELS) }
99
+ : {}),
100
+ };
101
+ }
102
+ /** One lineup entry as a publisher sent it, cleaned, or null when it is not one. */
103
+ export function parseLineupEntry(input) {
104
+ if (typeof input !== "object" || input === null)
105
+ return null;
106
+ const record = input;
107
+ const id = clean(record["id"], MAX_ID);
108
+ const name = clean(record["name"], MAX_NAME);
109
+ if (id === "" || name === "")
110
+ return null;
111
+ const art = clean(record["art"], MAX_ART);
112
+ return {
113
+ id,
114
+ name,
115
+ kind: record["kind"] === "video" ? "video" : "audio",
116
+ // A picture is somewhere a crawler can go, or nothing: a data: URL is a
117
+ // page in a tag, and javascript: is a page in a tag that runs.
118
+ art: publishable(art) !== null ? art : "",
119
+ about: clean(record["about"], MAX_ABOUT),
94
120
  };
95
121
  }
96
122
  /**
@@ -187,6 +213,7 @@ export class Directory {
187
213
  // listing always meant, and no channels is the honest empty list.
188
214
  playing: announcement.playing ?? true,
189
215
  channels: announcement.channels ?? [],
216
+ lineup: announcement.lineup ?? [],
190
217
  channelCodes: this.codesFor(announcement.channels ?? [],
191
218
  // What each channel has been called before, on this listing: a
192
219
  // channel that was gone for a heartbeat -- a server restarting puts
package/dist/links.d.ts CHANGED
@@ -26,6 +26,17 @@ export interface ResolvedLink {
26
26
  ext: string;
27
27
  /** The page it came from. */
28
28
  page: string;
29
+ /**
30
+ * A picture of it, when the site offered one: the video's thumbnail, the
31
+ * podcast's cover. This is what a link preview shows, and the lock screen
32
+ * while it plays. "" when there is none.
33
+ */
34
+ thumbnail: string;
35
+ /**
36
+ * A line about it for a preview: who put it up, and the first line of
37
+ * what they said about it. "" when the site said nothing.
38
+ */
39
+ about: string;
29
40
  /**
30
41
  * For a pasted .m3u: every entry in it, in order. The channel plays them
31
42
  * one after another and starts over at the end, a station rather than a
@@ -81,6 +92,12 @@ export declare function resolveArgs(url: string, cookies?: string, format?: stri
81
92
  export declare function downloadArgs(url: string, audioOnly: boolean, cookies?: string): string[];
82
93
  /** yt-dlp's answer, read into what the player needs. Null when it is not an answer. */
83
94
  export declare function parseResolved(json: unknown, page: string): ResolvedLink | null;
95
+ /** A web address a preview can fetch, or "". Only http(s): a data: URL is a page in a tag. */
96
+ export declare function pictureUrl(value: unknown): string;
97
+ /** How much of a description a preview gets. A card is a line or two, not the notes. */
98
+ export declare const ABOUT_MAX = 200;
99
+ /** One line of plain text, no longer than a card wants. */
100
+ export declare function oneLine(text: string): string;
84
101
  /** A bare file link, described without asking anybody. */
85
102
  export declare function directLink(url: string): ResolvedLink;
86
103
  /**
package/dist/links.js CHANGED
@@ -147,6 +147,8 @@ export async function resolvePlaylist(url, options = {}) {
147
147
  extractor: "playlist",
148
148
  ext: "",
149
149
  page: url,
150
+ thumbnail: "",
151
+ about: "",
150
152
  playlist: list.sources,
151
153
  };
152
154
  }
@@ -278,8 +280,56 @@ export function parseResolved(json, page) {
278
280
  extractor: typeof record["extractor"] === "string" ? record["extractor"] : "",
279
281
  ext: typeof record["ext"] === "string" && /^[a-z0-9]{1,5}$/i.test(record["ext"]) ? record["ext"].toLowerCase() : "",
280
282
  page,
283
+ thumbnail: thumbnailOf(record),
284
+ about: aboutOf(record),
281
285
  };
282
286
  }
287
+ /** A web address a preview can fetch, or "". Only http(s): a data: URL is a page in a tag. */
288
+ export function pictureUrl(value) {
289
+ if (typeof value !== "string" || value.length > 2048)
290
+ return "";
291
+ try {
292
+ const parsed = new URL(value);
293
+ return parsed.protocol === "https:" || parsed.protocol === "http:" ? parsed.toString() : "";
294
+ }
295
+ catch {
296
+ return "";
297
+ }
298
+ }
299
+ /**
300
+ * The picture yt-dlp found: `thumbnail` is its pick, and failing that the
301
+ * last of `thumbnails`, which it lists smallest first.
302
+ */
303
+ function thumbnailOf(record) {
304
+ const chosen = pictureUrl(record["thumbnail"]);
305
+ if (chosen !== "")
306
+ return chosen;
307
+ const list = Array.isArray(record["thumbnails"]) ? record["thumbnails"] : [];
308
+ for (let index = list.length - 1; index >= 0; index -= 1) {
309
+ const one = list[index];
310
+ const url = one && typeof one === "object" ? pictureUrl(one["url"]) : "";
311
+ if (url !== "")
312
+ return url;
313
+ }
314
+ return "";
315
+ }
316
+ /** How much of a description a preview gets. A card is a line or two, not the notes. */
317
+ export const ABOUT_MAX = 200;
318
+ /** Who put it up, and the first line they wrote about it, as one line. */
319
+ function aboutOf(record) {
320
+ const who = ["uploader", "channel", "artist", "album"]
321
+ .map((field) => (typeof record[field] === "string" ? record[field].trim() : ""))
322
+ .find((one) => one !== "") ?? "";
323
+ const said = typeof record["description"] === "string"
324
+ ? record["description"].split(/\r?\n/).map((line) => line.trim()).find((line) => line !== "") ?? ""
325
+ : "";
326
+ return oneLine([who, said].filter((one) => one !== "").join(" — "));
327
+ }
328
+ /** One line of plain text, no longer than a card wants. */
329
+ export function oneLine(text) {
330
+ const flat = text.replace(/[\u0000-\u001F\u007F]+/g, " ").replace(/\s+/g, " ").trim();
331
+ return flat.length > ABOUT_MAX ? `${flat.slice(0, ABOUT_MAX - 1).trimEnd()}…` : flat;
332
+ }
283
333
  /** A bare file link, described without asking anybody. */
284
334
  export function directLink(url) {
285
335
  return {
@@ -294,6 +344,8 @@ export function directLink(url) {
294
344
  extractor: "direct",
295
345
  ext: (url.match(/\.([a-z0-9]{2,5})(\?.*)?$/i)?.[1] ?? "").toLowerCase(),
296
346
  page: url,
347
+ thumbnail: "",
348
+ about: "",
297
349
  };
298
350
  }
299
351
  function fileNameOf(url) {
package/dist/publish.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { DEFAULT_DIRECTORY, type Listing } from "./directory.ts";
1
+ import { DEFAULT_DIRECTORY, type LineupEntry, type Listing } from "./directory.ts";
2
2
  export interface PublishTarget {
3
3
  directory: string;
4
4
  name: string;
@@ -27,6 +27,8 @@ export interface PublishTarget {
27
27
  playing?: () => boolean;
28
28
  /** The live channels on this server, by name, for the listing to show. */
29
29
  channels?: () => string[];
30
+ /** The same channels with their id, picture and line, for a link preview. */
31
+ lineup?: () => LineupEntry[];
30
32
  /**
31
33
  * The account this stream belongs to, from `nixamp login`.
32
34
  *
package/dist/publish.js CHANGED
@@ -65,6 +65,7 @@ export class Publisher {
65
65
  nowPlaying: this.target.nowPlaying(),
66
66
  ...(this.target.playing ? { playing: this.target.playing() } : {}),
67
67
  ...(this.target.channels ? { channels: this.target.channels() } : {}),
68
+ ...(this.target.lineup ? { lineup: this.target.lineup() } : {}),
68
69
  }),
69
70
  });
70
71
  if (!response.ok) {