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/README.md
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<a href="https://nixamp.com"><img src="web/public/hero.png" alt="nixamp" width="800" /></a>
|
|
3
|
+
</p>
|
|
4
|
+
|
|
1
5
|
# nixamp
|
|
2
6
|
|
|
3
7
|
It really whips the terminal's ass.
|
|
@@ -378,8 +382,17 @@ its own audience.
|
|
|
378
382
|
GET /api/channels what is live now
|
|
379
383
|
POST /api/channels/<id> publish to one
|
|
380
384
|
GET /api/channels/<id> listen to one
|
|
385
|
+
GET /api/channels/<id>/art a picture of it, as JPEG
|
|
381
386
|
```
|
|
382
387
|
|
|
388
|
+
The picture is what a share link unfurls into on a chat or a timeline, and
|
|
389
|
+
what the lock screen shows while it plays. A pasted link's thumbnail is sent
|
|
390
|
+
on as it is; a podcast's sleeve is read out of the file; a moving picture
|
|
391
|
+
gets one frame of what the channel is sending, taken from the channel's own
|
|
392
|
+
backlog rather than by opening the source a second time. The page for a
|
|
393
|
+
share link (`/?url=...&play=channel:<id>`) carries it as `og:image` with a
|
|
394
|
+
Twitter card, on nixamp.com and on the server itself.
|
|
395
|
+
|
|
383
396
|
One ffmpeg decodes each publisher once and the result is written to every
|
|
384
397
|
listener on that channel. A decode per listener would cost a core each and, for
|
|
385
398
|
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
|
*
|
package/dist/channels.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/directory.d.ts
CHANGED
|
@@ -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) {
|