nixamp 0.23.0 → 0.23.2

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/src/audio.ts CHANGED
@@ -425,6 +425,14 @@ export interface Codecs {
425
425
  */
426
426
  width?: number;
427
427
  height?: number;
428
+ /**
429
+ * Whether a sleeve is embedded: the attached picture an MP3 or M4A of a
430
+ * podcast carries. Not a picture to play, but a picture to show -- on
431
+ * the link preview and the lock screen -- and `coverArtOf` reads it out.
432
+ */
433
+ cover?: boolean;
434
+ /** What the file says about itself in its tags, for naming a bare file. */
435
+ tags?: { title?: string; artist?: string; album?: string };
428
436
  }
429
437
 
430
438
  /**
@@ -449,7 +457,7 @@ export async function codecsOf(tools: Tools, path: string, input: string[] = [])
449
457
  // The disposition too: an MP3 with its cover art in it carries that
450
458
  // art as a video stream of one JPEG, and a probe that took it for a
451
459
  // picture put a podcast on the air as a film with no frames.
452
- "-show_entries", "format=format_name,duration:stream=codec_type,codec_name,width,height:stream_disposition=attached_pic",
460
+ "-show_entries", "format=format_name,duration:format_tags=title,artist,album:stream=codec_type,codec_name,width,height:stream_disposition=attached_pic",
453
461
  // A transport stream needs looking further into than a file with an
454
462
  // index does: there is no header listing the tracks, only packets, and
455
463
  // a 4K recording can carry a second of null padding and a long gap to
@@ -475,9 +483,11 @@ export async function codecsOf(tools: Tools, path: string, input: string[] = [])
475
483
  codec_type?: string; codec_name?: string; width?: number; height?: number;
476
484
  disposition?: { attached_pic?: number };
477
485
  }[];
478
- format?: { format_name?: string; duration?: string };
486
+ format?: { format_name?: string; duration?: string; tags?: Record<string, unknown> };
479
487
  };
480
488
  const streams = parsed.streams ?? [];
489
+ const cover = streams.some((s) => s.codec_type === "video" && s.disposition?.attached_pic === 1);
490
+ const tags = tagsOf(parsed.format?.tags);
481
491
  // ffprobe prints seconds as a string, and "N/A" for a stream with no
482
492
  // end; both of those read as 0.
483
493
  const seconds = Number(parsed.format?.duration ?? 0);
@@ -489,6 +499,8 @@ export async function codecsOf(tools: Tools, path: string, input: string[] = [])
489
499
  duration: Number.isFinite(seconds) && seconds > 0 ? seconds : 0,
490
500
  ...(typeof picture?.width === "number" && picture.width > 0 ? { width: picture.width } : {}),
491
501
  ...(typeof picture?.height === "number" && picture.height > 0 ? { height: picture.height } : {}),
502
+ ...(cover ? { cover } : {}),
503
+ ...(tags ? { tags } : {}),
492
504
  });
493
505
  } catch {
494
506
  return done(empty);
@@ -497,6 +509,24 @@ export async function codecsOf(tools: Tools, path: string, input: string[] = [])
497
509
  });
498
510
  }
499
511
 
512
+ /**
513
+ * The title, artist and album a file carries in its tags, however the
514
+ * container spells the keys (ID3 says TIT2 but ffprobe says title; MP4 and
515
+ * Matroska say Title). Absent when it carries none worth keeping.
516
+ */
517
+ export function tagsOf(raw: Record<string, unknown> | undefined): Codecs["tags"] | undefined {
518
+ if (!raw || typeof raw !== "object") return undefined;
519
+ const lower: Record<string, string> = {};
520
+ for (const [key, value] of Object.entries(raw)) {
521
+ if (typeof value === "string") lower[key.toLowerCase()] = value.replace(/[\u0000-\u001F\u007F]+/g, " ").trim().slice(0, 200);
522
+ }
523
+ const tags: NonNullable<Codecs["tags"]> = {};
524
+ if (lower["title"]) tags.title = lower["title"];
525
+ if (lower["artist"]) tags.artist = lower["artist"];
526
+ if (lower["album"]) tags.album = lower["album"];
527
+ return Object.keys(tags).length > 0 ? tags : undefined;
528
+ }
529
+
500
530
  /**
501
531
  * Cover art is not a picture.
502
532
  *
package/src/channels.ts CHANGED
@@ -72,7 +72,7 @@ export interface ChannelInfo {
72
72
  position?: number;
73
73
  live?: boolean;
74
74
  /** What the source turned out to hold, so a restart need not ask again. */
75
- codecs?: { video: string; audio: string; container: string; duration?: number; width?: number; height?: number };
75
+ codecs?: { video: string; audio: string; container: string; duration?: number; width?: number; height?: number; cover?: boolean };
76
76
  /**
77
77
  * What the channel itself is producing, which is not always what its source
78
78
  * holds: an H.265 source is usually re-encoded to H.264 on the way out,
@@ -95,6 +95,15 @@ export interface ChannelInfo {
95
95
  * plain URL is read this way, and only when a policy asks for it.
96
96
  */
97
97
  teed?: boolean;
98
+ /**
99
+ * A picture of it from somewhere else: the thumbnail the site offered
100
+ * for a pasted link, the logo a catalog gave a channel. A channel with
101
+ * none may still have one of its own -- a sleeve in the file, a frame of
102
+ * the picture -- which /api/channels/:id/art reads out on demand.
103
+ */
104
+ art?: string;
105
+ /** A line about it for a preview: the show, the uploader, the first line of the notes. */
106
+ about?: string;
98
107
  }
99
108
 
100
109
  /** Where a pulled source is picked up from, and whether it can be at all. */
@@ -1201,7 +1210,7 @@ export interface RememberedChannel {
1201
1210
  * air as sound alone.
1202
1211
  */
1203
1212
  kind?: "audio" | "video";
1204
- codecs?: { video: string; audio: string; container: string; duration?: number; width?: number; height?: number };
1213
+ codecs?: { video: string; audio: string; container: string; duration?: number; width?: number; height?: number; cover?: boolean };
1205
1214
  /** Where a film had got to, in seconds, so it picks up there. */
1206
1215
  position?: number;
1207
1216
  /** A live source has nowhere to pick up from. */
@@ -1211,6 +1220,9 @@ export interface RememberedChannel {
1211
1220
  /** For a list: every entry, and which was on, so it carries on from there. */
1212
1221
  playlist?: string[];
1213
1222
  playlistAt?: number;
1223
+ /** Its picture and its line, as the site gave them, so a restart need not ask. */
1224
+ art?: string;
1225
+ about?: string;
1214
1226
  }
1215
1227
 
1216
1228
  const REMEMBERED = "channels.json";
@@ -1242,6 +1254,9 @@ export function rememberedChannels(dir: string, port: number): RememberedChannel
1242
1254
  // channel comes back at a size that cannot keep up.
1243
1255
  if (typeof c["width"] === "number" && Number.isFinite(c["width"])) kept.codecs.width = c["width"];
1244
1256
  if (typeof c["height"] === "number" && Number.isFinite(c["height"])) kept.codecs.height = c["height"];
1257
+ // A sleeve in the file is a picture for the card, and knowing it
1258
+ // is there saves reading the file again to find out.
1259
+ if (c["cover"] === true) kept.codecs.cover = true;
1245
1260
  }
1246
1261
  }
1247
1262
  if (typeof one["position"] === "number" && Number.isFinite(one["position"]) && one["position"] > 0) kept.position = one["position"];
@@ -1252,6 +1267,8 @@ export function rememberedChannels(dir: string, port: number): RememberedChannel
1252
1267
  if (entries.length > 0) kept.playlist = entries;
1253
1268
  if (typeof one["playlistAt"] === "number" && Number.isInteger(one["playlistAt"]) && one["playlistAt"] >= 0) kept.playlistAt = one["playlistAt"];
1254
1269
  }
1270
+ if (typeof one["art"] === "string" && /^https?:\/\//i.test(one["art"])) kept.art = one["art"].slice(0, 2048);
1271
+ if (typeof one["about"] === "string" && one["about"] !== "") kept.about = one["about"].slice(0, 200);
1255
1272
  return kept;
1256
1273
  });
1257
1274
  } catch {
@@ -1279,6 +1296,8 @@ export function rememberedNow(channels: Channels): RememberedChannel[] {
1279
1296
  kept.playlist = one.playlist;
1280
1297
  kept.playlistAt = one.playlistAt ?? 0;
1281
1298
  }
1299
+ if (one.art) kept.art = one.art;
1300
+ if (one.about) kept.about = one.about;
1282
1301
  return kept;
1283
1302
  });
1284
1303
  }
package/src/directory.ts CHANGED
@@ -79,6 +79,12 @@ export interface Listing {
79
79
  * about another on the same server.
80
80
  */
81
81
  channelCodes: Record<string, string>;
82
+ /**
83
+ * The same channels with what a preview needs: the id a join link names
84
+ * them by, a picture, a line. `channels` stays the list of names for
85
+ * the phone line and for anything reading an older listing.
86
+ */
87
+ lineup: LineupEntry[];
82
88
  /** Set by the directory from the request, never by the publisher. */
83
89
  updatedAt: number;
84
90
  /** When this stream first announced itself: the "started at" a caller hears. */
@@ -131,9 +137,27 @@ export interface Announcement {
131
137
  playing?: boolean;
132
138
  /** Names of the live channels on it. Absent from an older publisher. */
133
139
  channels?: string[];
140
+ /** Each of those with its id, picture and line. Absent from an older publisher. */
141
+ lineup?: LineupEntry[];
142
+ }
143
+
144
+ /**
145
+ * One live channel as a preview sees it. The picture is a web address the
146
+ * publisher vouches for -- its own art route, or the thumbnail a site gave
147
+ * it -- and a crawler fetches it, so it is only ever http(s).
148
+ */
149
+ export interface LineupEntry {
150
+ id: string;
151
+ name: string;
152
+ kind: "audio" | "video";
153
+ art: string;
154
+ about: string;
134
155
  }
135
156
 
136
157
  const MAX_NAME = 60;
158
+ const MAX_ID = 64;
159
+ const MAX_ABOUT = 200;
160
+ const MAX_ART = 2048;
137
161
  const MAX_TRACK = 120;
138
162
  /** How many channel names a listing carries. A multiview is four; eight is plenty. */
139
163
  const MAX_CHANNELS = 8;
@@ -206,6 +230,28 @@ export function parseAnnouncement(input: unknown): Announcement | null {
206
230
  .slice(0, MAX_CHANNELS),
207
231
  }
208
232
  : {}),
233
+ ...(Array.isArray(record["lineup"])
234
+ ? { lineup: (record["lineup"] as unknown[]).map(parseLineupEntry).filter((one): one is LineupEntry => one !== null).slice(0, MAX_CHANNELS) }
235
+ : {}),
236
+ };
237
+ }
238
+
239
+ /** One lineup entry as a publisher sent it, cleaned, or null when it is not one. */
240
+ export function parseLineupEntry(input: unknown): LineupEntry | null {
241
+ if (typeof input !== "object" || input === null) return null;
242
+ const record = input as Record<string, unknown>;
243
+ const id = clean(record["id"], MAX_ID);
244
+ const name = clean(record["name"], MAX_NAME);
245
+ if (id === "" || name === "") return null;
246
+ const art = clean(record["art"], MAX_ART);
247
+ return {
248
+ id,
249
+ name,
250
+ kind: record["kind"] === "video" ? "video" : "audio",
251
+ // A picture is somewhere a crawler can go, or nothing: a data: URL is a
252
+ // page in a tag, and javascript: is a page in a tag that runs.
253
+ art: publishable(art) !== null ? art : "",
254
+ about: clean(record["about"], MAX_ABOUT),
209
255
  };
210
256
  }
211
257
 
@@ -304,6 +350,7 @@ export class Directory {
304
350
  // listing always meant, and no channels is the honest empty list.
305
351
  playing: announcement.playing ?? true,
306
352
  channels: announcement.channels ?? [],
353
+ lineup: announcement.lineup ?? [],
307
354
  channelCodes: this.codesFor(
308
355
  announcement.channels ?? [],
309
356
  // What each channel has been called before, on this listing: a
package/src/links.ts CHANGED
@@ -51,6 +51,17 @@ export interface ResolvedLink {
51
51
  ext: string;
52
52
  /** The page it came from. */
53
53
  page: string;
54
+ /**
55
+ * A picture of it, when the site offered one: the video's thumbnail, the
56
+ * podcast's cover. This is what a link preview shows, and the lock screen
57
+ * while it plays. "" when there is none.
58
+ */
59
+ thumbnail: string;
60
+ /**
61
+ * A line about it for a preview: who put it up, and the first line of
62
+ * what they said about it. "" when the site said nothing.
63
+ */
64
+ about: string;
54
65
  /**
55
66
  * For a pasted .m3u: every entry in it, in order. The channel plays them
56
67
  * one after another and starts over at the end, a station rather than a
@@ -184,6 +195,8 @@ export async function resolvePlaylist(
184
195
  extractor: "playlist",
185
196
  ext: "",
186
197
  page: url,
198
+ thumbnail: "",
199
+ about: "",
187
200
  playlist: list.sources,
188
201
  };
189
202
  }
@@ -315,9 +328,58 @@ export function parseResolved(json: unknown, page: string): ResolvedLink | null
315
328
  extractor: typeof record["extractor"] === "string" ? record["extractor"] : "",
316
329
  ext: typeof record["ext"] === "string" && /^[a-z0-9]{1,5}$/i.test(record["ext"]) ? record["ext"].toLowerCase() : "",
317
330
  page,
331
+ thumbnail: thumbnailOf(record),
332
+ about: aboutOf(record),
318
333
  };
319
334
  }
320
335
 
336
+ /** A web address a preview can fetch, or "". Only http(s): a data: URL is a page in a tag. */
337
+ export function pictureUrl(value: unknown): string {
338
+ if (typeof value !== "string" || value.length > 2048) return "";
339
+ try {
340
+ const parsed = new URL(value);
341
+ return parsed.protocol === "https:" || parsed.protocol === "http:" ? parsed.toString() : "";
342
+ } catch {
343
+ return "";
344
+ }
345
+ }
346
+
347
+ /**
348
+ * The picture yt-dlp found: `thumbnail` is its pick, and failing that the
349
+ * last of `thumbnails`, which it lists smallest first.
350
+ */
351
+ function thumbnailOf(record: Record<string, unknown>): string {
352
+ const chosen = pictureUrl(record["thumbnail"]);
353
+ if (chosen !== "") return chosen;
354
+ const list = Array.isArray(record["thumbnails"]) ? (record["thumbnails"] as unknown[]) : [];
355
+ for (let index = list.length - 1; index >= 0; index -= 1) {
356
+ const one = list[index];
357
+ const url = one && typeof one === "object" ? pictureUrl((one as Record<string, unknown>)["url"]) : "";
358
+ if (url !== "") return url;
359
+ }
360
+ return "";
361
+ }
362
+
363
+ /** How much of a description a preview gets. A card is a line or two, not the notes. */
364
+ export const ABOUT_MAX = 200;
365
+
366
+ /** Who put it up, and the first line they wrote about it, as one line. */
367
+ function aboutOf(record: Record<string, unknown>): string {
368
+ const who = ["uploader", "channel", "artist", "album"]
369
+ .map((field) => (typeof record[field] === "string" ? (record[field] as string).trim() : ""))
370
+ .find((one) => one !== "") ?? "";
371
+ const said = typeof record["description"] === "string"
372
+ ? record["description"].split(/\r?\n/).map((line) => line.trim()).find((line) => line !== "") ?? ""
373
+ : "";
374
+ return oneLine([who, said].filter((one) => one !== "").join(" — "));
375
+ }
376
+
377
+ /** One line of plain text, no longer than a card wants. */
378
+ export function oneLine(text: string): string {
379
+ const flat = text.replace(/[\u0000-\u001F\u007F]+/g, " ").replace(/\s+/g, " ").trim();
380
+ return flat.length > ABOUT_MAX ? `${flat.slice(0, ABOUT_MAX - 1).trimEnd()}…` : flat;
381
+ }
382
+
321
383
  /** A bare file link, described without asking anybody. */
322
384
  export function directLink(url: string): ResolvedLink {
323
385
  return {
@@ -332,6 +394,8 @@ export function directLink(url: string): ResolvedLink {
332
394
  extractor: "direct",
333
395
  ext: (url.match(/\.([a-z0-9]{2,5})(\?.*)?$/i)?.[1] ?? "").toLowerCase(),
334
396
  page: url,
397
+ thumbnail: "",
398
+ about: "",
335
399
  };
336
400
  }
337
401
 
package/src/publish.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * defaults to no, because "there was nobody to ask" is not consent.
7
7
  */
8
8
  import { createInterface } from "node:readline/promises";
9
- import { DEFAULT_DIRECTORY, HEARTBEAT_MS, type Listing } from "./directory.ts";
9
+ import { DEFAULT_DIRECTORY, HEARTBEAT_MS, type LineupEntry, type Listing } from "./directory.ts";
10
10
 
11
11
  export interface PublishTarget {
12
12
  directory: string;
@@ -36,6 +36,8 @@ export interface PublishTarget {
36
36
  playing?: () => boolean;
37
37
  /** The live channels on this server, by name, for the listing to show. */
38
38
  channels?: () => string[];
39
+ /** The same channels with their id, picture and line, for a link preview. */
40
+ lineup?: () => LineupEntry[];
39
41
  /**
40
42
  * The account this stream belongs to, from `nixamp login`.
41
43
  *
@@ -122,6 +124,7 @@ export class Publisher {
122
124
  nowPlaying: this.target.nowPlaying(),
123
125
  ...(this.target.playing ? { playing: this.target.playing() } : {}),
124
126
  ...(this.target.channels ? { channels: this.target.channels() } : {}),
127
+ ...(this.target.lineup ? { lineup: this.target.lineup() } : {}),
125
128
  }),
126
129
  });
127
130
  if (!response.ok) {