nixamp 0.7.40 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/catalogs.d.ts +100 -0
- package/dist/catalogs.js +0 -0
- package/dist/channels.d.ts +17 -0
- package/dist/channels.js +45 -0
- package/dist/owner.js +4 -0
- package/dist/server.d.ts +9 -0
- package/dist/server.js +141 -0
- package/dist/session.js +19 -8
- package/package.json +1 -1
- package/src/catalogs.ts +0 -0
- package/src/channels.ts +41 -0
- package/src/owner.ts +3 -0
- package/src/server.ts +151 -0
- package/src/session.ts +16 -7
- package/web/dist/assets/{hls-3VKVEQE3-70uzupqn.js → hls-3VKVEQE3-DIpul2Yc.js} +1 -1
- package/web/dist/assets/index-BqirNVEK.js +1 -0
- package/web/dist/assets/index-C-gJFEFf.css +1 -0
- package/web/dist/assets/{mpegts-DQqgM7pi.js → mpegts-DQ14PwSl.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-CzrQKX7m.js → mpegts-LO6RVLD6-Cq2NKawM.js} +1 -1
- package/web/dist/index.html +23 -2
- package/web/dist/sw.js +6 -6
- package/web/dist/assets/index-ComwKkzf.js +0 -1
- package/web/dist/assets/index-D3xGDAOd.css +0 -1
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
export interface CatalogEntry {
|
|
2
|
+
/** Stable across refreshes: a hash of the source, so a link to it survives. */
|
|
3
|
+
id: string;
|
|
4
|
+
title: string;
|
|
5
|
+
group: string;
|
|
6
|
+
logo: string;
|
|
7
|
+
/** Where the bytes are. Never shown to a listener. */
|
|
8
|
+
source: string;
|
|
9
|
+
live: boolean;
|
|
10
|
+
/** Seconds, when the list said; 0 for live and for "did not say". */
|
|
11
|
+
duration: number;
|
|
12
|
+
}
|
|
13
|
+
export interface CatalogInfo {
|
|
14
|
+
id: string;
|
|
15
|
+
name: string;
|
|
16
|
+
/** Where the list is read from. Shown to administrators only. */
|
|
17
|
+
source: string;
|
|
18
|
+
addedAt: number;
|
|
19
|
+
refreshedAt: number;
|
|
20
|
+
entries: number;
|
|
21
|
+
live: number;
|
|
22
|
+
vod: number;
|
|
23
|
+
groups: number;
|
|
24
|
+
/** What went wrong the last time it was read, or "". */
|
|
25
|
+
error: string;
|
|
26
|
+
}
|
|
27
|
+
/** A list bigger than this is not a playlist, it is a mistake. */
|
|
28
|
+
export declare const MAX_LIST_BYTES: number;
|
|
29
|
+
/** How long to wait for a provider before giving up on the list. */
|
|
30
|
+
export declare const FETCH_TIMEOUT_MS = 30000;
|
|
31
|
+
/**
|
|
32
|
+
* Live, or on demand?
|
|
33
|
+
*
|
|
34
|
+
* The list rarely says outright. A stated length is a film; a file extension
|
|
35
|
+
* is a file; an Xtream-shaped URL says /movie/ or /series/ for on demand and
|
|
36
|
+
* /live/ for a channel; and a bare stream key, an .m3u8 or an .ts is a channel.
|
|
37
|
+
*/
|
|
38
|
+
export declare function isLiveEntry(source: string, duration: number): boolean;
|
|
39
|
+
export declare function entryId(source: string): string;
|
|
40
|
+
/**
|
|
41
|
+
* Parse an extended m3u: `#EXTINF:-1 tvg-name="CNN" group-title="News",CNN HD`
|
|
42
|
+
* decorates the URL on the next line, and `#EXTGRP:News` names the group for
|
|
43
|
+
* lists that write it that way instead.
|
|
44
|
+
*/
|
|
45
|
+
export declare function parseCatalog(text: string, base: string): CatalogEntry[];
|
|
46
|
+
export interface Group {
|
|
47
|
+
name: string;
|
|
48
|
+
count: number;
|
|
49
|
+
live: number;
|
|
50
|
+
vod: number;
|
|
51
|
+
}
|
|
52
|
+
/** Read a list, from disk or over the network, within reason. */
|
|
53
|
+
export declare function readCatalog(source: string, send?: typeof fetch): Promise<string>;
|
|
54
|
+
/**
|
|
55
|
+
* The catalogs on one server, and their entries.
|
|
56
|
+
*
|
|
57
|
+
* The index is small and written whenever it changes; each catalog's entries
|
|
58
|
+
* are kept in their own file so a restart does not have to ask every provider
|
|
59
|
+
* again before anybody can browse.
|
|
60
|
+
*/
|
|
61
|
+
export declare class Catalogs {
|
|
62
|
+
private readonly dir;
|
|
63
|
+
private readonly port;
|
|
64
|
+
private readonly send;
|
|
65
|
+
private readonly stored;
|
|
66
|
+
private readonly entries;
|
|
67
|
+
constructor(dir: string, port: number, send?: typeof fetch);
|
|
68
|
+
/** What was here last time, without asking any provider yet. */
|
|
69
|
+
load(): void;
|
|
70
|
+
/** Read every catalog that has no entries on disk. For boot. */
|
|
71
|
+
warm(): Promise<void>;
|
|
72
|
+
private cachePath;
|
|
73
|
+
private save;
|
|
74
|
+
private info;
|
|
75
|
+
list(): CatalogInfo[];
|
|
76
|
+
get(id: string): CatalogInfo | null;
|
|
77
|
+
/** Add a list and read it. The name is what a person called it, or the list's own filename. */
|
|
78
|
+
add(source: string, name: string): Promise<CatalogInfo>;
|
|
79
|
+
/** Read the list again. The old entries stay if the provider is down. */
|
|
80
|
+
refresh(id: string): Promise<CatalogInfo | null>;
|
|
81
|
+
remove(id: string): boolean;
|
|
82
|
+
groups(id: string): Group[] | null;
|
|
83
|
+
/** A page of entries: in one group, or all of them, matching a search. */
|
|
84
|
+
entries_(id: string, query: {
|
|
85
|
+
group?: string;
|
|
86
|
+
q?: string;
|
|
87
|
+
offset?: number;
|
|
88
|
+
limit?: number;
|
|
89
|
+
}): {
|
|
90
|
+
total: number;
|
|
91
|
+
entries: CatalogEntry[];
|
|
92
|
+
} | null;
|
|
93
|
+
entry(id: string, entryId_: string): CatalogEntry | null;
|
|
94
|
+
}
|
|
95
|
+
/** What a listener is shown of an entry: everything but where the bytes are. */
|
|
96
|
+
export declare function shownEntry(entry: CatalogEntry): Omit<CatalogEntry, "source">;
|
|
97
|
+
/** What a listener is shown of a catalog: everything but where the list is. */
|
|
98
|
+
export declare function shownCatalog(info: CatalogInfo, admin: boolean): Omit<CatalogInfo, "source"> & {
|
|
99
|
+
source?: string;
|
|
100
|
+
};
|
package/dist/catalogs.js
ADDED
|
Binary file
|
package/dist/channels.d.ts
CHANGED
|
@@ -40,6 +40,8 @@ export declare const GIVE_UP = 5;
|
|
|
40
40
|
* channel that is quiet for half a minute is not being quiet, it is dead.
|
|
41
41
|
*/
|
|
42
42
|
export declare const STALL = 30000;
|
|
43
|
+
/** How long an on-demand channel stays up with nobody watching. */
|
|
44
|
+
export declare const IDLE = 60000;
|
|
43
45
|
/**
|
|
44
46
|
* How much of the recent stream a newcomer is handed. About six seconds of
|
|
45
47
|
* 720p television, and a couple of seconds of 192k MP3: enough to play
|
|
@@ -53,6 +55,8 @@ export interface ChannelOptions {
|
|
|
53
55
|
ffmpeg: string[];
|
|
54
56
|
onStart?: (info: ChannelInfo) => void;
|
|
55
57
|
onEnd?: (info: ChannelInfo) => void;
|
|
58
|
+
/** How long an on-demand channel outlives its last viewer. Tests shorten it. */
|
|
59
|
+
idleMs?: number;
|
|
56
60
|
}
|
|
57
61
|
/**
|
|
58
62
|
* One live source, and its audience.
|
|
@@ -90,6 +94,13 @@ export declare class Channel {
|
|
|
90
94
|
*/
|
|
91
95
|
private recent;
|
|
92
96
|
private recentBytes;
|
|
97
|
+
/**
|
|
98
|
+
* Started for whoever asked and stopped when nobody is left. A catalog
|
|
99
|
+
* channel is one of thousands; keeping every one that was ever clicked
|
|
100
|
+
* running would be a decoder per click, for ever.
|
|
101
|
+
*/
|
|
102
|
+
ephemeral: boolean;
|
|
103
|
+
private idle;
|
|
93
104
|
constructor(info: ChannelInfo, options: ChannelOptions, onGone: (id: string) => void);
|
|
94
105
|
start(format: string): void;
|
|
95
106
|
/**
|
|
@@ -162,6 +173,8 @@ export declare class Channel {
|
|
|
162
173
|
/** Write to everyone, and drop anybody whose socket has gone. */
|
|
163
174
|
private send;
|
|
164
175
|
listen(listener: Listener): () => void;
|
|
176
|
+
/** Nobody is watching an on-demand channel: give it a minute, then stop. */
|
|
177
|
+
private idleOut;
|
|
165
178
|
close(): void;
|
|
166
179
|
}
|
|
167
180
|
/**
|
|
@@ -203,6 +216,10 @@ export declare class Channels {
|
|
|
203
216
|
restart(id: string): boolean;
|
|
204
217
|
/** Whether a channel is one we fetch ourselves, and so can start over. */
|
|
205
218
|
pulled(id: string): boolean;
|
|
219
|
+
/** Mark a channel as on demand: it stops itself a minute after its last viewer leaves. */
|
|
220
|
+
ephemeral(id: string): void;
|
|
221
|
+
/** How many on-demand channels are up, for a ceiling on decoders. */
|
|
222
|
+
get ephemeralCount(): number;
|
|
206
223
|
/** What a listener should be told this channel is. */
|
|
207
224
|
contentType(id: string): string;
|
|
208
225
|
/** Attach a listener, or null when nothing is playing on that channel. */
|
package/dist/channels.js
CHANGED
|
@@ -31,6 +31,8 @@ export const GIVE_UP = 5;
|
|
|
31
31
|
* channel that is quiet for half a minute is not being quiet, it is dead.
|
|
32
32
|
*/
|
|
33
33
|
export const STALL = 30_000;
|
|
34
|
+
/** How long an on-demand channel stays up with nobody watching. */
|
|
35
|
+
export const IDLE = 60_000;
|
|
34
36
|
/** How much of what ffmpeg said to keep, for the last line when it dies. */
|
|
35
37
|
const TAIL = 2000;
|
|
36
38
|
/**
|
|
@@ -111,6 +113,13 @@ export class Channel {
|
|
|
111
113
|
*/
|
|
112
114
|
recent = [];
|
|
113
115
|
recentBytes = 0;
|
|
116
|
+
/**
|
|
117
|
+
* Started for whoever asked and stopped when nobody is left. A catalog
|
|
118
|
+
* channel is one of thousands; keeping every one that was ever clicked
|
|
119
|
+
* running would be a decoder per click, for ever.
|
|
120
|
+
*/
|
|
121
|
+
ephemeral = false;
|
|
122
|
+
idle = null;
|
|
114
123
|
constructor(info, options, onGone) {
|
|
115
124
|
this.info = info;
|
|
116
125
|
this.options = options;
|
|
@@ -418,11 +427,27 @@ export class Channel {
|
|
|
418
427
|
}
|
|
419
428
|
this.listeners.add(listener);
|
|
420
429
|
this.info.listeners = this.listeners.size;
|
|
430
|
+
if (this.idle)
|
|
431
|
+
clearTimeout(this.idle);
|
|
432
|
+
this.idle = null;
|
|
421
433
|
return () => {
|
|
422
434
|
this.listeners.delete(listener);
|
|
423
435
|
this.info.listeners = this.listeners.size;
|
|
436
|
+
if (this.ephemeral && this.listeners.size === 0)
|
|
437
|
+
this.idleOut();
|
|
424
438
|
};
|
|
425
439
|
}
|
|
440
|
+
/** Nobody is watching an on-demand channel: give it a minute, then stop. */
|
|
441
|
+
idleOut() {
|
|
442
|
+
if (this.idle)
|
|
443
|
+
clearTimeout(this.idle);
|
|
444
|
+
this.idle = setTimeout(() => {
|
|
445
|
+
this.idle = null;
|
|
446
|
+
if (this.ephemeral && this.listeners.size === 0)
|
|
447
|
+
this.close();
|
|
448
|
+
}, this.options.idleMs ?? IDLE);
|
|
449
|
+
this.idle.unref?.();
|
|
450
|
+
}
|
|
426
451
|
close() {
|
|
427
452
|
if (this.closing)
|
|
428
453
|
return;
|
|
@@ -434,6 +459,9 @@ export class Channel {
|
|
|
434
459
|
if (this.watchdog)
|
|
435
460
|
clearTimeout(this.watchdog);
|
|
436
461
|
this.watchdog = null;
|
|
462
|
+
if (this.idle)
|
|
463
|
+
clearTimeout(this.idle);
|
|
464
|
+
this.idle = null;
|
|
437
465
|
const said = lastLine(this.stderr);
|
|
438
466
|
if (said && !this.info.error)
|
|
439
467
|
this.info.error = said;
|
|
@@ -551,6 +579,23 @@ export class Channels {
|
|
|
551
579
|
pulled(id) {
|
|
552
580
|
return this.open.get(id)?.info.via === "pull";
|
|
553
581
|
}
|
|
582
|
+
/** Mark a channel as on demand: it stops itself a minute after its last viewer leaves. */
|
|
583
|
+
ephemeral(id) {
|
|
584
|
+
const channel = this.open.get(id);
|
|
585
|
+
if (!channel)
|
|
586
|
+
return;
|
|
587
|
+
channel.ephemeral = true;
|
|
588
|
+
if (channel.listeners.size === 0)
|
|
589
|
+
channel.listen({ write: () => true, end: () => undefined })();
|
|
590
|
+
}
|
|
591
|
+
/** How many on-demand channels are up, for a ceiling on decoders. */
|
|
592
|
+
get ephemeralCount() {
|
|
593
|
+
let total = 0;
|
|
594
|
+
for (const channel of this.open.values())
|
|
595
|
+
if (channel.ephemeral)
|
|
596
|
+
total += 1;
|
|
597
|
+
return total;
|
|
598
|
+
}
|
|
554
599
|
/** What a listener should be told this channel is. */
|
|
555
600
|
contentType(id) {
|
|
556
601
|
return this.open.get(id)?.info.kind === "video" ? "video/mp4" : "audio/mpeg";
|
package/dist/owner.js
CHANGED
|
@@ -102,5 +102,9 @@ export function needsAdmin(path, method = "GET") {
|
|
|
102
102
|
// Listening to a channel is not: that is what the share link is for.
|
|
103
103
|
if (path.startsWith("/api/channels/") && method !== "GET")
|
|
104
104
|
return true;
|
|
105
|
+
// Adding, refreshing or removing a catalog is administering; browsing one,
|
|
106
|
+
// and picking something in it to play, is what the link is for.
|
|
107
|
+
if (path.startsWith("/api/catalogs") && method !== "GET" && !path.endsWith("/play"))
|
|
108
|
+
return true;
|
|
105
109
|
return false;
|
|
106
110
|
}
|
package/dist/server.d.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { Directory } from "./directory.ts";
|
|
|
13
13
|
import { PartyLine } from "./partyline.ts";
|
|
14
14
|
import { Follows } from "./follows.ts";
|
|
15
15
|
import { Favorites } from "./favorites.ts";
|
|
16
|
+
import { Catalogs } from "./catalogs.ts";
|
|
16
17
|
import { type Tools, type Track } from "./audio.ts";
|
|
17
18
|
import { type Command, type RemoteTrack, type Snapshot } from "./protocol.ts";
|
|
18
19
|
export declare const SERVE_BAND_COUNT = 24;
|
|
@@ -337,6 +338,12 @@ export declare class PlayerEngine implements Engine {
|
|
|
337
338
|
* looking at what is on wants "that album from the web", not every track in
|
|
338
339
|
* it. An entry names where to start, so clicking it plays.
|
|
339
340
|
*/
|
|
341
|
+
/**
|
|
342
|
+
* How many channels a server will start on demand at once. Each is an ffmpeg,
|
|
343
|
+
* and a catalog has thousands of entries; this is what keeps a room full of
|
|
344
|
+
* curious people from becoming a room full of decoders.
|
|
345
|
+
*/
|
|
346
|
+
export declare const MAX_ON_DEMAND = 4;
|
|
340
347
|
/**
|
|
341
348
|
* Probe a source and start carrying it as a channel of its own.
|
|
342
349
|
*
|
|
@@ -414,6 +421,8 @@ export interface HandlerOptions {
|
|
|
414
421
|
channels?: Channels;
|
|
415
422
|
/** Write down the channels this server pulls, so a restart puts them back. */
|
|
416
423
|
rememberChannels?: (list: RememberedChannel[]) => void;
|
|
424
|
+
/** The m3u catalogs this server keeps, browsable by group. */
|
|
425
|
+
catalogs?: Catalogs;
|
|
417
426
|
/** Live audio going out to RTMP. */
|
|
418
427
|
broadcaster?: Broadcaster;
|
|
419
428
|
/** Where a broadcast should send, and what it should look like. */
|
package/dist/server.js
CHANGED
|
@@ -38,6 +38,7 @@ import { CALL_IN_NUMBER, OPT_IN_PATH, optInPage } from "./optin.js";
|
|
|
38
38
|
import pg from "pg";
|
|
39
39
|
import { Follows, phoneFrom } from "./follows.js";
|
|
40
40
|
import { Favorites, favoriteUrl } from "./favorites.js";
|
|
41
|
+
import { Catalogs, shownCatalog, shownEntry } from "./catalogs.js";
|
|
41
42
|
import { Durable } from "./durable.js";
|
|
42
43
|
import { notifyAll, resendEmail, webPush } from "./notify.js";
|
|
43
44
|
import { confirm, DEFAULT_DIRECTORY, Publisher } from "./publish.js";
|
|
@@ -708,6 +709,12 @@ export class PlayerEngine {
|
|
|
708
709
|
* looking at what is on wants "that album from the web", not every track in
|
|
709
710
|
* it. An entry names where to start, so clicking it plays.
|
|
710
711
|
*/
|
|
712
|
+
/**
|
|
713
|
+
* How many channels a server will start on demand at once. Each is an ffmpeg,
|
|
714
|
+
* and a catalog has thousands of entries; this is what keeps a room full of
|
|
715
|
+
* curious people from becoming a room full of decoders.
|
|
716
|
+
*/
|
|
717
|
+
export const MAX_ON_DEMAND = 4;
|
|
711
718
|
/**
|
|
712
719
|
* Probe a source and start carrying it as a channel of its own.
|
|
713
720
|
*
|
|
@@ -1876,6 +1883,134 @@ export function createHandler(engine, options) {
|
|
|
1876
1883
|
}
|
|
1877
1884
|
// --- several streams at once ------------------------------------------
|
|
1878
1885
|
//
|
|
1886
|
+
// --- catalogs: m3u lists you can browse ---------------------------------
|
|
1887
|
+
//
|
|
1888
|
+
// An IPTV list is thousands of entries with groups and logos. Kept as a
|
|
1889
|
+
// catalog it stays browsable; poured into the playlist it was three
|
|
1890
|
+
// thousand flat rows. Anyone with the link browses and plays; adding,
|
|
1891
|
+
// refreshing and removing is administering (see needsAdmin).
|
|
1892
|
+
if ((path === "/api/catalogs" || path.startsWith("/api/catalogs/")) && options.catalogs) {
|
|
1893
|
+
const catalogs = options.catalogs;
|
|
1894
|
+
if (path === "/api/catalogs" && request.method === "GET") {
|
|
1895
|
+
// Where a list is read from is the administrator's business, not a
|
|
1896
|
+
// listener's: it can carry a provider's credentials in the URL.
|
|
1897
|
+
const holdsControl = key === null || scopeOf(keyFrom(request, url), key, null) === "control";
|
|
1898
|
+
const admin = options.owner
|
|
1899
|
+
? (await options.owner.check(holdsControl, tokenFrom(request.headers))).allowed
|
|
1900
|
+
: holdsControl;
|
|
1901
|
+
json(response, 200, { catalogs: catalogs.list().map((one) => shownCatalog(one, admin)) });
|
|
1902
|
+
return;
|
|
1903
|
+
}
|
|
1904
|
+
if (path === "/api/catalogs" && request.method === "POST") {
|
|
1905
|
+
let body = {};
|
|
1906
|
+
try {
|
|
1907
|
+
body = JSON.parse(await readBody(request));
|
|
1908
|
+
}
|
|
1909
|
+
catch {
|
|
1910
|
+
json(response, 400, { error: "bad JSON" });
|
|
1911
|
+
return;
|
|
1912
|
+
}
|
|
1913
|
+
try {
|
|
1914
|
+
const added = await catalogs.add(String(body.source ?? ""), String(body.name ?? ""));
|
|
1915
|
+
json(response, added.error ? 422 : 200, {
|
|
1916
|
+
ok: !added.error,
|
|
1917
|
+
catalog: shownCatalog(added, true),
|
|
1918
|
+
...(added.error ? { error: added.error } : {}),
|
|
1919
|
+
});
|
|
1920
|
+
}
|
|
1921
|
+
catch (error) {
|
|
1922
|
+
json(response, 422, { error: error.message.replace(/^nixamp: /, "") });
|
|
1923
|
+
}
|
|
1924
|
+
return;
|
|
1925
|
+
}
|
|
1926
|
+
const [rawId = "", action = "", entryId = "", sub = ""] = path.slice("/api/catalogs/".length).split("/");
|
|
1927
|
+
const id = decodeURIComponent(rawId);
|
|
1928
|
+
if (!catalogs.get(id)) {
|
|
1929
|
+
json(response, 404, { error: "no such catalog" });
|
|
1930
|
+
return;
|
|
1931
|
+
}
|
|
1932
|
+
if (action === "" && request.method === "DELETE") {
|
|
1933
|
+
json(response, 200, { ok: catalogs.remove(id) });
|
|
1934
|
+
return;
|
|
1935
|
+
}
|
|
1936
|
+
if (action === "refresh" && request.method === "POST") {
|
|
1937
|
+
const refreshed = await catalogs.refresh(id);
|
|
1938
|
+
json(response, refreshed && !refreshed.error ? 200 : 422, {
|
|
1939
|
+
ok: refreshed !== null && !refreshed.error,
|
|
1940
|
+
...(refreshed ? { catalog: shownCatalog(refreshed, true) } : {}),
|
|
1941
|
+
...(refreshed?.error ? { error: refreshed.error } : {}),
|
|
1942
|
+
});
|
|
1943
|
+
return;
|
|
1944
|
+
}
|
|
1945
|
+
if (action === "groups" && request.method === "GET") {
|
|
1946
|
+
json(response, 200, { groups: catalogs.groups(id) ?? [] });
|
|
1947
|
+
return;
|
|
1948
|
+
}
|
|
1949
|
+
if (action === "entries" && entryId === "" && request.method === "GET") {
|
|
1950
|
+
const page = catalogs.entries_(id, {
|
|
1951
|
+
group: url.searchParams.get("group") ?? "",
|
|
1952
|
+
q: url.searchParams.get("q") ?? "",
|
|
1953
|
+
offset: Number(url.searchParams.get("offset") ?? "0") || 0,
|
|
1954
|
+
limit: Number(url.searchParams.get("limit") ?? "200") || 200,
|
|
1955
|
+
}) ?? { total: 0, entries: [] };
|
|
1956
|
+
json(response, 200, { total: page.total, entries: page.entries.map(shownEntry) });
|
|
1957
|
+
return;
|
|
1958
|
+
}
|
|
1959
|
+
const entry = action === "entries" && entryId !== "" ? catalogs.entry(id, decodeURIComponent(entryId)) : null;
|
|
1960
|
+
if (!entry) {
|
|
1961
|
+
json(response, 404, { error: "no such entry" });
|
|
1962
|
+
return;
|
|
1963
|
+
}
|
|
1964
|
+
// Play. A live entry becomes a channel, started for whoever asked and
|
|
1965
|
+
// stopped a minute after the last viewer leaves; a film is played on
|
|
1966
|
+
// its own, straight from the source through ffmpeg.
|
|
1967
|
+
if (sub === "play" && request.method === "POST") {
|
|
1968
|
+
if (!entry.live) {
|
|
1969
|
+
json(response, 200, {
|
|
1970
|
+
kind: "vod",
|
|
1971
|
+
url: `/api/catalogs/${encodeURIComponent(id)}/entries/${encodeURIComponent(entry.id)}/stream`,
|
|
1972
|
+
name: entry.title,
|
|
1973
|
+
});
|
|
1974
|
+
return;
|
|
1975
|
+
}
|
|
1976
|
+
if (!options.channels) {
|
|
1977
|
+
json(response, 503, { error: "this server cannot carry channels" });
|
|
1978
|
+
return;
|
|
1979
|
+
}
|
|
1980
|
+
const channelId = cleanId(`cat-${entry.id}`);
|
|
1981
|
+
if (!options.channels.has(channelId)) {
|
|
1982
|
+
if (options.channels.ephemeralCount >= MAX_ON_DEMAND) {
|
|
1983
|
+
json(response, 429, { error: `this server is already carrying ${MAX_ON_DEMAND} channels on demand; try again in a minute` });
|
|
1984
|
+
return;
|
|
1985
|
+
}
|
|
1986
|
+
const started = await pullChannel(options.channels, options.ffprobe ?? ["ffprobe"], channelId, entry.title, entry.source);
|
|
1987
|
+
if (!started) {
|
|
1988
|
+
json(response, 409, { error: "that channel is already starting" });
|
|
1989
|
+
return;
|
|
1990
|
+
}
|
|
1991
|
+
options.channels.ephemeral(channelId);
|
|
1992
|
+
}
|
|
1993
|
+
json(response, 200, { kind: "live", channel: channelId, name: entry.title });
|
|
1994
|
+
return;
|
|
1995
|
+
}
|
|
1996
|
+
if (sub === "stream" && request.method === "GET") {
|
|
1997
|
+
if (!options.media) {
|
|
1998
|
+
json(response, 403, { error: "media streaming is off" });
|
|
1999
|
+
return;
|
|
2000
|
+
}
|
|
2001
|
+
watch(request, response, "stream", entry.title);
|
|
2002
|
+
const codecs = await codecsOf({ ffmpeg: [], ffprobe: options.ffprobe ?? ["ffprobe"], play: null }, entry.source);
|
|
2003
|
+
if (codecs.video !== "") {
|
|
2004
|
+
pipeFfmpeg(request, response, entry.source, options.ffmpeg ?? ["ffmpeg"], videoArgs(codecs), "video/mp4");
|
|
2005
|
+
}
|
|
2006
|
+
else {
|
|
2007
|
+
transcode(request, response, entry.source, options.ffmpeg ?? ["ffmpeg"]);
|
|
2008
|
+
}
|
|
2009
|
+
return;
|
|
2010
|
+
}
|
|
2011
|
+
json(response, 404, { error: "no such endpoint" });
|
|
2012
|
+
return;
|
|
2013
|
+
}
|
|
1879
2014
|
// A channel is one publisher and everybody listening to them. Two or three
|
|
1880
2015
|
// devices can publish at once, each to their own channel, and a listener
|
|
1881
2016
|
// picks which to hear.
|
|
@@ -2906,6 +3041,11 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
2906
3041
|
console.log(` "${one.id}" is already on.`);
|
|
2907
3042
|
});
|
|
2908
3043
|
}
|
|
3044
|
+
// The m3u catalogs kept here: read from disk now, and any that were never
|
|
3045
|
+
// read are fetched in the background so browsing does not wait on a provider.
|
|
3046
|
+
const catalogs = new Catalogs(stateDir(), options.port);
|
|
3047
|
+
catalogs.load();
|
|
3048
|
+
void catalogs.warm().catch(() => undefined);
|
|
2909
3049
|
const destinations = parseDestinations(options.rtmp);
|
|
2910
3050
|
const broadcaster = new Broadcaster(tools.ffmpeg);
|
|
2911
3051
|
const ingest = options.ingest
|
|
@@ -3092,6 +3232,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
3092
3232
|
owner,
|
|
3093
3233
|
channels,
|
|
3094
3234
|
rememberChannels: remembering,
|
|
3235
|
+
catalogs,
|
|
3095
3236
|
publishUrls: () => publishUrls,
|
|
3096
3237
|
serverName: options.name || hostname(),
|
|
3097
3238
|
homeSource: root,
|
package/dist/session.js
CHANGED
|
@@ -357,20 +357,31 @@ export async function chooseWay(ways, options) {
|
|
|
357
357
|
if (options.device)
|
|
358
358
|
return ways.device ? "device" : null;
|
|
359
359
|
// Naming an address is asking for the password flow by implication.
|
|
360
|
-
if (!ways.device || options.email
|
|
361
|
-
return "password";
|
|
362
|
-
if (ways.providers.length === 0)
|
|
360
|
+
if (!ways.device || options.email)
|
|
363
361
|
return "password";
|
|
362
|
+
// A site with no providers configured still has a browser to approve in,
|
|
363
|
+
// and that is the way in from a terminal: a code, a page, a click. This
|
|
364
|
+
// used to fall through to a password prompt the moment the provider list
|
|
365
|
+
// was empty, which is what nixamp.com answers, so nobody ever saw the
|
|
366
|
+
// browser flow that was built for exactly this. With no terminal to draw
|
|
367
|
+
// a menu on, the browser page offers the providers instead.
|
|
368
|
+
if (ways.providers.length === 0 || !process.stdin.isTTY)
|
|
369
|
+
return "device";
|
|
364
370
|
console.log("How would you like to sign in?");
|
|
365
371
|
ways.providers.forEach((provider, index) => console.log(` ${index + 1}) ${provider.name}`));
|
|
366
|
-
console.log(` ${ways.providers.length + 1})
|
|
372
|
+
console.log(` ${ways.providers.length + 1}) In a browser you are already signed in to`);
|
|
373
|
+
console.log(` ${ways.providers.length + 2}) Email and password`);
|
|
367
374
|
const typed = await ask(`Choose [1]: `);
|
|
368
375
|
const picked = typed === "" ? 1 : Number(typed);
|
|
369
|
-
if (!Number.isInteger(picked) || picked < 1 || picked > ways.providers.length +
|
|
370
|
-
console.log("Not one of those, so:
|
|
371
|
-
return "
|
|
376
|
+
if (!Number.isInteger(picked) || picked < 1 || picked > ways.providers.length + 2) {
|
|
377
|
+
console.log("Not one of those, so: in a browser.");
|
|
378
|
+
return "device";
|
|
372
379
|
}
|
|
373
|
-
|
|
380
|
+
if (picked === ways.providers.length + 1)
|
|
381
|
+
return "device";
|
|
382
|
+
if (picked === ways.providers.length + 2)
|
|
383
|
+
return "password";
|
|
384
|
+
return ways.providers[picked - 1]?.id ?? "device";
|
|
374
385
|
}
|
|
375
386
|
const day = (at) => (at ? new Date(at).toISOString().slice(0, 10) : "never");
|
|
376
387
|
/**
|
package/package.json
CHANGED
package/src/catalogs.ts
ADDED
|
Binary file
|
package/src/channels.ts
CHANGED
|
@@ -64,6 +64,8 @@ export const GIVE_UP = 5;
|
|
|
64
64
|
* channel that is quiet for half a minute is not being quiet, it is dead.
|
|
65
65
|
*/
|
|
66
66
|
export const STALL = 30_000;
|
|
67
|
+
/** How long an on-demand channel stays up with nobody watching. */
|
|
68
|
+
export const IDLE = 60_000;
|
|
67
69
|
/** How much of what ffmpeg said to keep, for the last line when it dies. */
|
|
68
70
|
const TAIL = 2000;
|
|
69
71
|
/**
|
|
@@ -116,6 +118,8 @@ export interface ChannelOptions {
|
|
|
116
118
|
ffmpeg: string[];
|
|
117
119
|
onStart?: (info: ChannelInfo) => void;
|
|
118
120
|
onEnd?: (info: ChannelInfo) => void;
|
|
121
|
+
/** How long an on-demand channel outlives its last viewer. Tests shorten it. */
|
|
122
|
+
idleMs?: number;
|
|
119
123
|
}
|
|
120
124
|
|
|
121
125
|
/**
|
|
@@ -151,6 +155,13 @@ export class Channel {
|
|
|
151
155
|
*/
|
|
152
156
|
private recent: Buffer[] = [];
|
|
153
157
|
private recentBytes = 0;
|
|
158
|
+
/**
|
|
159
|
+
* Started for whoever asked and stopped when nobody is left. A catalog
|
|
160
|
+
* channel is one of thousands; keeping every one that was ever clicked
|
|
161
|
+
* running would be a decoder per click, for ever.
|
|
162
|
+
*/
|
|
163
|
+
ephemeral = false;
|
|
164
|
+
private idle: ReturnType<typeof setTimeout> | null = null;
|
|
154
165
|
|
|
155
166
|
constructor(
|
|
156
167
|
readonly info: ChannelInfo,
|
|
@@ -466,12 +477,25 @@ export class Channel {
|
|
|
466
477
|
}
|
|
467
478
|
this.listeners.add(listener);
|
|
468
479
|
this.info.listeners = this.listeners.size;
|
|
480
|
+
if (this.idle) clearTimeout(this.idle);
|
|
481
|
+
this.idle = null;
|
|
469
482
|
return () => {
|
|
470
483
|
this.listeners.delete(listener);
|
|
471
484
|
this.info.listeners = this.listeners.size;
|
|
485
|
+
if (this.ephemeral && this.listeners.size === 0) this.idleOut();
|
|
472
486
|
};
|
|
473
487
|
}
|
|
474
488
|
|
|
489
|
+
/** Nobody is watching an on-demand channel: give it a minute, then stop. */
|
|
490
|
+
private idleOut(): void {
|
|
491
|
+
if (this.idle) clearTimeout(this.idle);
|
|
492
|
+
this.idle = setTimeout(() => {
|
|
493
|
+
this.idle = null;
|
|
494
|
+
if (this.ephemeral && this.listeners.size === 0) this.close();
|
|
495
|
+
}, this.options.idleMs ?? IDLE);
|
|
496
|
+
this.idle.unref?.();
|
|
497
|
+
}
|
|
498
|
+
|
|
475
499
|
close(): void {
|
|
476
500
|
if (this.closing) return;
|
|
477
501
|
this.closing = true;
|
|
@@ -480,6 +504,8 @@ export class Channel {
|
|
|
480
504
|
this.timer = null;
|
|
481
505
|
if (this.watchdog) clearTimeout(this.watchdog);
|
|
482
506
|
this.watchdog = null;
|
|
507
|
+
if (this.idle) clearTimeout(this.idle);
|
|
508
|
+
this.idle = null;
|
|
483
509
|
const said = lastLine(this.stderr);
|
|
484
510
|
if (said && !this.info.error) this.info.error = said;
|
|
485
511
|
const child = this.child;
|
|
@@ -615,6 +641,21 @@ export class Channels {
|
|
|
615
641
|
return this.open.get(id)?.info.via === "pull";
|
|
616
642
|
}
|
|
617
643
|
|
|
644
|
+
/** Mark a channel as on demand: it stops itself a minute after its last viewer leaves. */
|
|
645
|
+
ephemeral(id: string): void {
|
|
646
|
+
const channel = this.open.get(id);
|
|
647
|
+
if (!channel) return;
|
|
648
|
+
channel.ephemeral = true;
|
|
649
|
+
if (channel.listeners.size === 0) channel.listen({ write: () => true, end: () => undefined })();
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/** How many on-demand channels are up, for a ceiling on decoders. */
|
|
653
|
+
get ephemeralCount(): number {
|
|
654
|
+
let total = 0;
|
|
655
|
+
for (const channel of this.open.values()) if (channel.ephemeral) total += 1;
|
|
656
|
+
return total;
|
|
657
|
+
}
|
|
658
|
+
|
|
618
659
|
/** What a listener should be told this channel is. */
|
|
619
660
|
contentType(id: string): string {
|
|
620
661
|
return this.open.get(id)?.info.kind === "video" ? "video/mp4" : "audio/mpeg";
|
package/src/owner.ts
CHANGED
|
@@ -119,5 +119,8 @@ export function needsAdmin(path: string, method = "GET"): boolean {
|
|
|
119
119
|
// Publishing to a channel, or ending one, is administering the server.
|
|
120
120
|
// Listening to a channel is not: that is what the share link is for.
|
|
121
121
|
if (path.startsWith("/api/channels/") && method !== "GET") return true;
|
|
122
|
+
// Adding, refreshing or removing a catalog is administering; browsing one,
|
|
123
|
+
// and picking something in it to play, is what the link is for.
|
|
124
|
+
if (path.startsWith("/api/catalogs") && method !== "GET" && !path.endsWith("/play")) return true;
|
|
122
125
|
return false;
|
|
123
126
|
}
|