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/README.md +13 -0
- 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-DOUPX_xZ.js → hls-3VKVEQE3-C6TjK2l6.js} +1 -1
- package/web/dist/assets/index-46pwGn5-.css +1 -0
- package/web/dist/assets/index-B55UODw_.js +1 -0
- package/web/dist/assets/{mpegts-XL4bjo7s.js → mpegts-BRcyaIhm.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-C9gfFFrM.js → mpegts-LO6RVLD6-DI1tgjsu.js} +1 -1
- package/web/dist/favicon.png +0 -0
- package/web/dist/hero.png +0 -0
- package/web/dist/index.html +5 -4
- package/web/dist/logo.png +0 -0
- package/web/dist/sw.js +9 -6
- package/web/dist/assets/index-BIL7sVli.js +0 -1
- package/web/dist/assets/index-D3Is8ykj.css +0 -1
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) {
|