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.
@@ -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
+ };
Binary file
@@ -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 || !process.stdin.isTTY)
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}) Email and password`);
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 + 1) {
370
- console.log("Not one of those, so: email and password.");
371
- return "password";
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
- return picked === ways.providers.length + 1 ? "password" : (ways.providers[picked - 1]?.id ?? "password");
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.7.40",
3
+ "version": "0.8.0",
4
4
  "description": "It really whips the terminal's ass. A Winamp-shaped audio player for your terminal.",
5
5
  "license": "MIT",
6
6
  "type": "module",
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
  }