nixamp 0.9.12 → 0.10.1

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/audio.d.ts CHANGED
@@ -13,6 +13,8 @@ export interface Tools {
13
13
  ffprobe: string[];
14
14
  /** Argv prefix for the player, or null when nothing can make sound here. */
15
15
  play: string[] | null;
16
+ /** yt-dlp, which turns a pasted page into a media address; null when there is none. */
17
+ ytdlp?: string[] | null;
16
18
  }
17
19
  /**
18
20
  * Find the tools. A bare `ffmpeg` on PATH is tried first; mise shims are common
@@ -98,7 +100,7 @@ export interface Codecs {
98
100
  * answering other requests, and a synchronous probe per media request is how
99
101
  * the whole library came to be tagged with the process wedged solid.
100
102
  */
101
- export declare function codecsOf(tools: Tools, path: string): Promise<Codecs>;
103
+ export declare function codecsOf(tools: Tools, path: string, input?: string[]): Promise<Codecs>;
102
104
  /**
103
105
  * How to get this file into a browser, given what is inside it.
104
106
  *
package/dist/audio.js CHANGED
@@ -12,11 +12,11 @@ import { homedir } from "node:os";
12
12
  import { join } from "node:path";
13
13
  export const RATE = 44100;
14
14
  export const CHANNELS = 2;
15
- function works(argv) {
15
+ function works(argv, flag = "-version") {
16
16
  const [cmd, ...rest] = argv;
17
17
  if (!cmd)
18
18
  return false;
19
- const r = spawnSync(cmd, [...rest, "-version"], { encoding: "utf8", timeout: 10_000 });
19
+ const r = spawnSync(cmd, [...rest, flag], { encoding: "utf8", timeout: 10_000 });
20
20
  return !r.error && r.status === 0;
21
21
  }
22
22
  /**
@@ -73,10 +73,16 @@ export function detectTools() {
73
73
  const ffmpeg = pick("ffmpeg");
74
74
  const ffprobe = pick("ffprobe");
75
75
  const play = pick("ffplay");
76
+ // yt-dlp is not an ffmpeg, so mise's ffmpeg tree is not where it lives;
77
+ // the installer puts it beside nixamp, and pip puts it in ~/.local/bin too.
78
+ // Asked with its own spelling: ffmpeg answers -version, yt-dlp only --version.
79
+ const ytdlp = [["yt-dlp"], [join(homedir(), ".local", "bin", "yt-dlp")], ["/usr/local/bin/yt-dlp"], ["/usr/bin/yt-dlp"], ["/opt/homebrew/bin/yt-dlp"]]
80
+ .find((argv) => works(argv, "--version")) ?? null;
76
81
  return {
77
82
  ffmpeg: ffmpeg ?? ["ffmpeg"],
78
83
  ffprobe: ffprobe ?? ["ffprobe"],
79
84
  play,
85
+ ytdlp,
80
86
  };
81
87
  }
82
88
  export function probe(tools, path) {
@@ -341,7 +347,7 @@ function readTags(stdout, fallback) {
341
347
  * answering other requests, and a synchronous probe per media request is how
342
348
  * the whole library came to be tagged with the process wedged solid.
343
349
  */
344
- export async function codecsOf(tools, path) {
350
+ export async function codecsOf(tools, path, input = []) {
345
351
  const [cmd, ...rest] = tools.ffprobe;
346
352
  const empty = { video: "", audio: "", container: "" };
347
353
  if (!cmd)
@@ -352,6 +358,8 @@ export async function codecsOf(tools, path) {
352
358
  "-v", "quiet",
353
359
  "-print_format", "json",
354
360
  "-show_entries", "format=format_name:stream=codec_type,codec_name",
361
+ // Headers the source's site expects, for a link resolved by yt-dlp.
362
+ ...input,
355
363
  path,
356
364
  ], { stdio: ["ignore", "pipe", "ignore"] });
357
365
  let out = "";
@@ -116,7 +116,7 @@ export declare class Channel {
116
116
  * because you looked away, and a room where the picture depends on who is
117
117
  * in it is not a room anybody can be invited to.
118
118
  */
119
- pull(source: string, encode: string[], paced?: boolean, stall?: number): void;
119
+ pull(source: string, encode: string[], paced?: boolean, stall?: number, input?: string[]): void;
120
120
  /**
121
121
  * Start the source over, now.
122
122
  *
@@ -209,7 +209,7 @@ export declare class Channels {
209
209
  * about it is the same too, which is the point -- a re-stream stops being a
210
210
  * special case and becomes one more thing that is on.
211
211
  */
212
- pull(id: string, name: string, source: string, encode: string[], kind: "audio" | "video", paced?: boolean, stall?: number): Channel | null;
212
+ pull(id: string, name: string, source: string, encode: string[], kind: "audio" | "video", paced?: boolean, stall?: number, input?: string[]): Channel | null;
213
213
  /**
214
214
  * Dial a pulled channel's source again, now. False for a channel that is
215
215
  * not there or is not ours to dial: a publisher's stream restarts at the
package/dist/channels.js CHANGED
@@ -168,7 +168,7 @@ export class Channel {
168
168
  * because you looked away, and a room where the picture depends on who is
169
169
  * in it is not a room anybody can be invited to.
170
170
  */
171
- pull(source, encode, paced = true, stall = STALL) {
171
+ pull(source, encode, paced = true, stall = STALL, input = []) {
172
172
  this.stall = stall;
173
173
  if (this.info.kind === "video")
174
174
  this.fragments = new Fragments();
@@ -195,6 +195,10 @@ export class Channel {
195
195
  // hour of film in ninety seconds and a room that cannot be in it
196
196
  // together; a live source is already paced and loses nothing.
197
197
  ...(paced ? ["-re"] : []),
198
+ // What the source's site expects on the request: a user agent, a
199
+ // referer, a cookie. A link resolved by yt-dlp comes with these,
200
+ // and a CDN that got them from yt-dlp and not from us answers 403.
201
+ ...input,
198
202
  "-i", source,
199
203
  ...encode,
200
204
  "pipe:1",
@@ -556,7 +560,7 @@ export class Channels {
556
560
  * about it is the same too, which is the point -- a re-stream stops being a
557
561
  * special case and becomes one more thing that is on.
558
562
  */
559
- pull(id, name, source, encode, kind, paced = true, stall = STALL) {
563
+ pull(id, name, source, encode, kind, paced = true, stall = STALL, input = []) {
560
564
  if (this.open.has(id))
561
565
  return null;
562
566
  const channel = new Channel({
@@ -571,7 +575,7 @@ export class Channels {
571
575
  source,
572
576
  }, this.options, (gone) => this.open.delete(gone));
573
577
  this.open.set(id, channel);
574
- channel.pull(source, encode, paced, stall);
578
+ channel.pull(source, encode, paced, stall, input);
575
579
  return channel;
576
580
  }
577
581
  /**
@@ -69,6 +69,12 @@ export interface Listing {
69
69
  playing: boolean;
70
70
  /** The live channels on it, by name: what a visitor could actually watch. */
71
71
  channels: string[];
72
+ /**
73
+ * A phone code for each of those channels, by name: its own room, so the
74
+ * people calling about one live are not put in with the people calling
75
+ * about another on the same server.
76
+ */
77
+ channelCodes: Record<string, string>;
72
78
  /** Set by the directory from the request, never by the publisher. */
73
79
  updatedAt: number;
74
80
  /** When this stream first announced itself: the "started at" a caller hears. */
@@ -143,6 +149,8 @@ export declare class Directory {
143
149
  */
144
150
  private readonly onLive;
145
151
  private readonly items;
152
+ /** Every code a channel has had on each listing, so a returning channel keeps its own. */
153
+ private readonly channelHistory;
146
154
  /** Streams that stopped, so the phone line can say when. */
147
155
  private readonly ended;
148
156
  private sequence;
@@ -181,8 +189,30 @@ export declare class Directory {
181
189
  announce(announcement: Announcement, ownerId?: string): Listing;
182
190
  withdraw(id: string): void;
183
191
  list(): Listing[];
184
- /** The live stream on this code, if there is one. */
192
+ /**
193
+ * The live stream on this code, if there is one.
194
+ *
195
+ * A channel's code answers as the channel: the same listing, named for the
196
+ * channel and playing nothing else, so the phone line says "the live room
197
+ * for FIBA World Cup" rather than the server's name and whatever its own
198
+ * player has on.
199
+ */
185
200
  liveByCode(code: string): Listing | undefined;
201
+ /**
202
+ * A code for each channel, kept while the channel stays on.
203
+ *
204
+ * One code per server meant every live on it shared a room: somebody
205
+ * calling about the basketball landed with the people talking about the
206
+ * film. Each live is its own room now, and a channel that is still there at
207
+ * the next heartbeat keeps the code it was given.
208
+ */
209
+ private codesFor;
210
+ /**
211
+ * Whether a code is somebody's already: a stream's own, a channel's on any
212
+ * stream, or a recently-ended stream's. A listing renewing itself is not
213
+ * "somebody else", or its channels would be re-coded every heartbeat.
214
+ */
215
+ private taken;
186
216
  /**
187
217
  * Streams that stopped recently, most recent first.
188
218
  *
@@ -201,7 +231,7 @@ export declare class Directory {
201
231
  endedByCode(code: string): Ended | undefined;
202
232
  private endedByUrl;
203
233
  private remember;
204
- /** A code no live and no recently-ended stream is using. */
234
+ /** A code no live stream, no channel on one, and no recently-ended stream is using. */
205
235
  private freeCode;
206
236
  /** Forget anything that stopped renewing, keeping a note of when it did. */
207
237
  private sweep;
package/dist/directory.js CHANGED
@@ -104,6 +104,8 @@ export class Directory {
104
104
  randomCode;
105
105
  onLive;
106
106
  items = new Map();
107
+ /** Every code a channel has had on each listing, so a returning channel keeps its own. */
108
+ channelHistory = new Map();
107
109
  /** Streams that stopped, so the phone line can say when. */
108
110
  ended = new Map();
109
111
  sequence = 0;
@@ -179,12 +181,19 @@ export class Directory {
179
181
  // listing always meant, and no channels is the honest empty list.
180
182
  playing: announcement.playing ?? true,
181
183
  channels: announcement.channels ?? [],
184
+ channelCodes: this.codesFor(announcement.channels ?? [],
185
+ // What each channel has been called before, on this listing: a
186
+ // channel that was gone for a heartbeat -- a server restarting puts
187
+ // its channels back a moment after it announces -- comes back to the
188
+ // code people were given, not a new one.
189
+ { ...(this.channelHistory.get(id) ?? {}), ...(existing?.channelCodes ?? {}) }, id),
182
190
  updatedAt: this.now(),
183
191
  // A stream that never stopped keeps its original start. One that did
184
192
  // starts again now, because that is what a caller is being told about.
185
193
  startedAt: existing?.startedAt ?? this.now(),
186
194
  };
187
195
  this.items.set(id, listing);
196
+ this.channelHistory.set(id, { ...(this.channelHistory.get(id) ?? {}), ...listing.channelCodes });
188
197
  // The transition, not the heartbeat: existing means it was already live.
189
198
  if (existing === undefined)
190
199
  this.onLive(listing);
@@ -195,15 +204,69 @@ export class Directory {
195
204
  if (item !== undefined)
196
205
  this.remember(item);
197
206
  this.items.delete(id);
207
+ this.channelHistory.delete(id);
198
208
  }
199
209
  list() {
200
210
  this.sweep();
201
211
  return [...this.items.values()].sort((a, b) => b.updatedAt - a.updatedAt || a.id.localeCompare(b.id));
202
212
  }
203
- /** The live stream on this code, if there is one. */
213
+ /**
214
+ * The live stream on this code, if there is one.
215
+ *
216
+ * A channel's code answers as the channel: the same listing, named for the
217
+ * channel and playing nothing else, so the phone line says "the live room
218
+ * for FIBA World Cup" rather than the server's name and whatever its own
219
+ * player has on.
220
+ */
204
221
  liveByCode(code) {
205
222
  this.sweep();
206
- return [...this.items.values()].find((item) => item.code === code);
223
+ const own = [...this.items.values()].find((item) => item.code === code);
224
+ if (own !== undefined)
225
+ return own;
226
+ for (const item of this.items.values()) {
227
+ const channel = Object.entries(item.channelCodes).find(([, one]) => one === code)?.[0];
228
+ if (channel !== undefined)
229
+ return { ...item, code, name: channel, nowPlaying: "" };
230
+ }
231
+ return undefined;
232
+ }
233
+ /**
234
+ * A code for each channel, kept while the channel stays on.
235
+ *
236
+ * One code per server meant every live on it shared a room: somebody
237
+ * calling about the basketball landed with the people talking about the
238
+ * film. Each live is its own room now, and a channel that is still there at
239
+ * the next heartbeat keeps the code it was given.
240
+ */
241
+ codesFor(channels, had, own = "") {
242
+ const codes = {};
243
+ for (const name of channels) {
244
+ if (codes[name] !== undefined)
245
+ continue;
246
+ const kept = had?.[name];
247
+ codes[name] = kept !== undefined && !this.taken(kept, codes, own) ? kept : this.freeCode(codes, own);
248
+ }
249
+ return codes;
250
+ }
251
+ /**
252
+ * Whether a code is somebody's already: a stream's own, a channel's on any
253
+ * stream, or a recently-ended stream's. A listing renewing itself is not
254
+ * "somebody else", or its channels would be re-coded every heartbeat.
255
+ */
256
+ taken(code, besides = {}, own = "") {
257
+ if (Object.values(besides).includes(code))
258
+ return true;
259
+ for (const item of this.items.values()) {
260
+ if (item.code === code)
261
+ return true;
262
+ }
263
+ // Every code a channel has ever had on a listing that is still up is
264
+ // that channel's to come back to, on every listing but the one asking.
265
+ for (const [id, codes] of this.channelHistory) {
266
+ if (id !== own && Object.values(codes).includes(code))
267
+ return true;
268
+ }
269
+ return [...this.ended.values()].some((i) => i.code === code);
207
270
  }
208
271
  /**
209
272
  * Streams that stopped recently, most recent first.
@@ -263,15 +326,13 @@ export class Directory {
263
326
  this.ended.set(item.id, record);
264
327
  this.mirror?.save(record);
265
328
  }
266
- /** A code no live and no recently-ended stream is using. */
267
- freeCode() {
329
+ /** A code no live stream, no channel on one, and no recently-ended stream is using. */
330
+ freeCode(besides = {}, own = "") {
268
331
  for (let tries = 0; tries < 40; tries += 1) {
269
332
  const code = this.randomCode();
270
333
  if (code.length !== 6)
271
334
  continue;
272
- const taken = [...this.items.values()].some((i) => i.code === code) ||
273
- [...this.ended.values()].some((i) => i.code === code);
274
- if (!taken)
335
+ if (!this.taken(code, besides, own))
275
336
  return code;
276
337
  }
277
338
  return "";
@@ -283,6 +344,7 @@ export class Directory {
283
344
  if (item.updatedAt < cutoff) {
284
345
  this.remember(item);
285
346
  this.items.delete(id);
347
+ this.channelHistory.delete(id);
286
348
  }
287
349
  }
288
350
  const forget = this.now() - ENDED_TTL_MS;
@@ -0,0 +1,62 @@
1
+ export interface ResolvedLink {
2
+ /** What to call it: the page's title, or the file's name for a bare file. */
3
+ title: string;
4
+ /** Where the bytes are: what ffmpeg is pointed at. */
5
+ media: string;
6
+ /** Whether it is on now, which decides pacing and whether it can be saved. */
7
+ live: boolean;
8
+ /** Seconds, when known. */
9
+ duration: number;
10
+ /** Whether there is a picture. Unknown reads as yes; ffprobe settles it. */
11
+ video: boolean;
12
+ /** Headers the site expects on the media request, if any. */
13
+ headers: Record<string, string>;
14
+ /** Which site, as yt-dlp names it -- "soundcloud", "youtube", "generic". */
15
+ extractor: string;
16
+ /** The file type yt-dlp would save, for naming a download. */
17
+ ext: string;
18
+ /** The page it came from. */
19
+ page: string;
20
+ }
21
+ export declare function isDirectMedia(url: string): boolean;
22
+ /** A link somebody pasted, or "" when it is not one this can play. */
23
+ export declare function playableLink(entered: unknown): string;
24
+ /** A channel id for a link: stable, so two people pasting the same link share one decoder. */
25
+ export declare function linkChannelId(url: string): string;
26
+ /** The format a download would pick, so it can be named before it is fetched. */
27
+ export declare function saveFormat(audioOnly: boolean): string;
28
+ /** How yt-dlp is asked where a link's media is, for playing or for the format a download would take. */
29
+ export declare function resolveArgs(url: string, cookies?: string, format?: string): string[];
30
+ /** How yt-dlp is asked to hand over the whole thing, down a pipe. */
31
+ export declare function downloadArgs(url: string, audioOnly: boolean, cookies?: string): string[];
32
+ /** yt-dlp's answer, read into what the player needs. Null when it is not an answer. */
33
+ export declare function parseResolved(json: unknown, page: string): ResolvedLink | null;
34
+ /** A bare file link, described without asking anybody. */
35
+ export declare function directLink(url: string): ResolvedLink;
36
+ /**
37
+ * ffmpeg's input options for a site that wants headers on the media request.
38
+ *
39
+ * yt-dlp's answer came with the headers it would have sent; ffmpeg has to
40
+ * send the same ones or the CDN answers 403 to a request that worked a
41
+ * second ago. The user agent has its own flag; the rest go as one block.
42
+ */
43
+ export declare function inputArgsFor(headers: Record<string, string>): string[];
44
+ /** A file name for a download: the title, made safe, with the right ending. */
45
+ export declare function fileNameFor(link: ResolvedLink, audioOnly: boolean): string;
46
+ /** What a browser should call the bytes, from the file name's ending. */
47
+ export declare function contentTypeFor(fileName: string): string;
48
+ /** The one line of yt-dlp's complaint worth repeating to a person. */
49
+ export declare function reasonFrom(stderr: string): string;
50
+ /** How long resolving may take before it is a link that is not going to answer. */
51
+ export declare const RESOLVE_TIMEOUT_MS = 60000;
52
+ /**
53
+ * Where a link's media is, by asking yt-dlp. A bare file is answered without
54
+ * asking. An error is a sentence for the person who pasted it.
55
+ */
56
+ export declare function resolveLink(ytdlp: string[] | null, url: string, options?: {
57
+ cookies?: string;
58
+ timeoutMs?: number;
59
+ format?: string;
60
+ }): Promise<ResolvedLink | {
61
+ error: string;
62
+ }>;
package/dist/links.js ADDED
Binary file
package/dist/server.d.ts CHANGED
@@ -368,7 +368,15 @@ export declare const MAX_ON_DEMAND = 4;
368
368
  * ones back, so that both agree on what a source is encoded as. Null when
369
369
  * that channel id is already on.
370
370
  */
371
- export declare function pullChannel(channels: Channels, ffprobe: string[], id: string, name: string, source: string): Promise<Channel | null>;
371
+ export declare function pullChannel(channels: Channels, ffprobe: string[], id: string, name: string, source: string, input?: string[]): Promise<Channel | null>;
372
+ /**
373
+ * A Netscape cookies file beside the state, if the operator has put one there.
374
+ *
375
+ * YouTube and Vimeo refuse a datacenter without a signed-in cookie; the
376
+ * person who runs the server can export one from their browser and drop it
377
+ * at ~/.local/state/nixamp/cookies.txt, and every link is asked for with it.
378
+ */
379
+ export declare function cookiesFile(): string;
372
380
  export declare function liveOnes(engine: Engine): {
373
381
  name: string;
374
382
  at: number;
@@ -412,6 +420,10 @@ export interface HandlerOptions {
412
420
  ffmpeg?: string[];
413
421
  /** Where ffprobe is, for asking what is inside a file before re-encoding it. */
414
422
  ffprobe?: string[];
423
+ /** yt-dlp, which turns a pasted page into a media address. Null when there is none. */
424
+ ytdlp?: string[] | null;
425
+ /** A Netscape cookies file for sites that want a signed-in browser, when there is one. */
426
+ cookies?: string;
415
427
  /** Who is listening, for the admin view. */
416
428
  connections?: Connections;
417
429
  /**
@@ -476,6 +488,8 @@ export interface HandlerOptions {
476
488
  name: string;
477
489
  url: string;
478
490
  possible: boolean;
491
+ /** A phone code per live channel, by name, as the directory assigned them. */
492
+ channelCodes?: Record<string, string>;
479
493
  };
480
494
  start: () => Promise<{
481
495
  live: boolean;
package/dist/server.js CHANGED
@@ -34,6 +34,7 @@ import { stateDir } from "./daemon.js";
34
34
  import { readSession } from "./session.js";
35
35
  import { Directory, ENDED_TTL_MS, parseAnnouncement } from "./directory.js";
36
36
  import { PartyLine, telnyxSms } from "./partyline.js";
37
+ import { contentTypeFor, downloadArgs, fileNameFor, inputArgsFor, linkChannelId, playableLink, resolveLink, saveFormat, } from "./links.js";
37
38
  import { CALL_IN_NUMBER, OPT_IN_PATH, optInPage } from "./optin.js";
38
39
  import pg from "pg";
39
40
  import { Follows, phoneFrom } from "./follows.js";
@@ -766,15 +767,47 @@ export const MAX_ON_DEMAND = 4;
766
767
  * ones back, so that both agree on what a source is encoded as. Null when
767
768
  * that channel id is already on.
768
769
  */
769
- export async function pullChannel(channels, ffprobe, id, name, source) {
770
- const codecs = await codecsOf({ ffmpeg: [], ffprobe, play: null }, source);
770
+ export async function pullChannel(channels, ffprobe, id, name, source, input = []) {
771
+ const codecs = await codecsOf({ ffmpeg: [], ffprobe, play: null }, source, input);
771
772
  const kind = codecs.video === "" ? "audio" : "video";
772
773
  const encode = kind === "video"
773
774
  ? videoArgs(codecs)
774
775
  // No picture in it, so none is invented: MP3 is the thing every browser
775
776
  // plays and the thing a listener can join halfway through.
776
777
  : ["-vn", "-c:a", "libmp3lame", "-b:a", "192k", "-f", "mp3"];
777
- return channels.pull(id, name, source, encode, kind);
778
+ return channels.pull(id, name, source, encode, kind, true, undefined, input);
779
+ }
780
+ /** What a link resolves to, kept so a download can be named without asking twice. */
781
+ const links = new Map();
782
+ /**
783
+ * A Netscape cookies file beside the state, if the operator has put one there.
784
+ *
785
+ * YouTube and Vimeo refuse a datacenter without a signed-in cookie; the
786
+ * person who runs the server can export one from their browser and drop it
787
+ * at ~/.local/state/nixamp/cookies.txt, and every link is asked for with it.
788
+ */
789
+ export function cookiesFile() {
790
+ const path = join(stateDir(), "cookies.txt");
791
+ try {
792
+ return statSync(path).isFile() ? path : "";
793
+ }
794
+ catch {
795
+ return "";
796
+ }
797
+ }
798
+ /** What the page is told about a link it asked to play. */
799
+ function shownLink(channelId, link) {
800
+ return {
801
+ kind: "live",
802
+ channel: channelId,
803
+ name: link.title,
804
+ live: link.live,
805
+ video: link.video,
806
+ duration: link.duration,
807
+ extractor: link.extractor,
808
+ // A live has no whole to keep; a bare file can be fetched by the browser itself.
809
+ download: !link.live && link.extractor !== "direct",
810
+ };
778
811
  }
779
812
  export function liveOnes(engine) {
780
813
  const tracks = engine.snapshot().tracks ?? [];
@@ -1925,6 +1958,9 @@ export function createHandler(engine, options) {
1925
1958
  const streams = options.directory.list().map(({ admin, ...stream }) => ({
1926
1959
  ...stream,
1927
1960
  callers: onThePhone ? onThePhone.listenersOn(stream.code) : 0,
1961
+ // And on the phone for each live on it, by name: every live is its
1962
+ // own room, so each has its own count.
1963
+ channelCallers: Object.fromEntries(Object.entries(stream.channelCodes).map(([name, code]) => [name, onThePhone ? onThePhone.listenersOn(code) : 0])),
1928
1964
  ...(admin && me !== null && stream.ownerId === me.id ? { admin } : {}),
1929
1965
  }));
1930
1966
  // Recently ended too, because following exists to hear about
@@ -2089,6 +2125,9 @@ export function createHandler(engine, options) {
2089
2125
  // Whether it has a picture, so the page puts it in the element
2090
2126
  // that can show one. Never the source: that is the owner's.
2091
2127
  kind: one.kind ?? "audio",
2128
+ // Its own room on the phone line, as the directory assigned it;
2129
+ // empty until the next heartbeat has told the directory it is on.
2130
+ code: state?.channelCodes?.[one.name] ?? "",
2092
2131
  // How it has been going, for whoever may do something about it.
2093
2132
  redials: one.redials ?? 0,
2094
2133
  error: one.error ?? "",
@@ -2217,6 +2256,9 @@ export function createHandler(engine, options) {
2217
2256
  return;
2218
2257
  }
2219
2258
  options.channels.ephemeral(channelId);
2259
+ // Told to the directory now, so the room code arrives with the
2260
+ // channel rather than at the next heartbeat, ninety seconds on.
2261
+ void options.live?.announce?.();
2220
2262
  }
2221
2263
  json(response, 200, { kind: "live", channel: channelId, name: entry.title });
2222
2264
  return;
@@ -2267,6 +2309,120 @@ export function createHandler(engine, options) {
2267
2309
  json(response, 404, { error: "no such endpoint" });
2268
2310
  return;
2269
2311
  }
2312
+ // --- any link, played -------------------------------------------------
2313
+ //
2314
+ // Paste a page -- YouTube, a podcast, SoundCloud, a TikTok live -- and the
2315
+ // server works out where the media is and plays it as a channel of its
2316
+ // own, the way a catalog entry is played: started for whoever asked,
2317
+ // stopped a minute after the last viewer leaves. Open to anyone holding
2318
+ // the link, like picking something from a catalog.
2319
+ if (path === "/api/links/play" && request.method === "POST") {
2320
+ let body = {};
2321
+ try {
2322
+ body = JSON.parse(await readBody(request));
2323
+ }
2324
+ catch {
2325
+ json(response, 400, { error: "bad JSON" });
2326
+ return;
2327
+ }
2328
+ const link = playableLink(body.url);
2329
+ if (link === "") {
2330
+ json(response, 400, { error: "that is not a link this can play" });
2331
+ return;
2332
+ }
2333
+ if (!options.channels) {
2334
+ json(response, 503, { error: "this server cannot carry channels" });
2335
+ return;
2336
+ }
2337
+ const channelId = linkChannelId(link);
2338
+ const known = links.get(link);
2339
+ if (options.channels.has(channelId) && known) {
2340
+ json(response, 200, shownLink(channelId, known));
2341
+ return;
2342
+ }
2343
+ if (!options.channels.has(channelId) && options.channels.ephemeralCount >= MAX_ON_DEMAND) {
2344
+ json(response, 429, { error: `this server is already carrying ${MAX_ON_DEMAND} channels on demand; try again in a minute` });
2345
+ return;
2346
+ }
2347
+ const resolved = known ?? await resolveLink(options.ytdlp ?? null, link, { cookies: options.cookies ?? "" });
2348
+ if ("error" in resolved) {
2349
+ json(response, 422, { error: resolved.error });
2350
+ return;
2351
+ }
2352
+ links.set(link, resolved);
2353
+ if (!options.channels.has(channelId)) {
2354
+ const started = await pullChannel(options.channels, options.ffprobe ?? ["ffprobe"], channelId, resolved.title, resolved.media, inputArgsFor(resolved.headers));
2355
+ if (!started) {
2356
+ json(response, 409, { error: "that link is already starting" });
2357
+ return;
2358
+ }
2359
+ options.channels.ephemeral(channelId);
2360
+ // Told to the directory now, so the room code arrives with the
2361
+ // channel rather than at the next heartbeat, ninety seconds on.
2362
+ void options.live?.announce?.();
2363
+ }
2364
+ json(response, 200, shownLink(channelId, resolved));
2365
+ return;
2366
+ }
2367
+ // The whole thing, to keep. The server fetches it through yt-dlp and
2368
+ // hands the bytes straight on as a download, so the person's own machine
2369
+ // ends up with the file and the server keeps nothing. A live stream has
2370
+ // no whole to hand over.
2371
+ if (path === "/api/links/download") {
2372
+ const link = playableLink(url.searchParams.get("url"));
2373
+ if (link === "") {
2374
+ json(response, 400, { error: "that is not a link this can fetch" });
2375
+ return;
2376
+ }
2377
+ if (!options.ytdlp || options.ytdlp.length === 0) {
2378
+ json(response, 503, { error: "this server has no yt-dlp to fetch with" });
2379
+ return;
2380
+ }
2381
+ const resolved = links.get(link) ?? await resolveLink(options.ytdlp, link, { cookies: options.cookies ?? "" });
2382
+ if ("error" in resolved) {
2383
+ json(response, 422, { error: resolved.error });
2384
+ return;
2385
+ }
2386
+ links.set(link, resolved);
2387
+ if (resolved.live) {
2388
+ json(response, 409, { error: "that is live; there is no whole file to download yet" });
2389
+ return;
2390
+ }
2391
+ const audioOnly = url.searchParams.get("audio") === "1" || !resolved.video;
2392
+ // Named for the format the download will actually take, which is not
2393
+ // always the one played: a track played from an HLS playlist is saved
2394
+ // as the plain MP3 the site also offers, and ".m4a" on an MP3 is a
2395
+ // file nothing will open.
2396
+ const saved = await resolveLink(options.ytdlp, link, { cookies: options.cookies ?? "", format: saveFormat(audioOnly) });
2397
+ const fileName = fileNameFor("error" in saved ? resolved : { ...resolved, ext: saved.ext || resolved.ext }, audioOnly);
2398
+ const [command, ...prefix] = options.ytdlp;
2399
+ const child = spawn(command, [...prefix, ...downloadArgs(link, audioOnly, options.cookies ?? "")], {
2400
+ stdio: ["ignore", "pipe", "pipe"],
2401
+ });
2402
+ response.writeHead(200, {
2403
+ "content-type": contentTypeFor(fileName),
2404
+ "content-disposition": `attachment; filename="${fileName.replace(/["\\]/g, "")}"; filename*=UTF-8''${encodeURIComponent(fileName)}`,
2405
+ "cache-control": "no-store",
2406
+ });
2407
+ child.stdout?.pipe(response);
2408
+ let complaint = "";
2409
+ child.stderr?.on("data", (chunk) => {
2410
+ if (complaint.length < 20_000)
2411
+ complaint += chunk.toString("utf8");
2412
+ });
2413
+ child.on("error", () => response.end());
2414
+ child.on("close", (code) => {
2415
+ if (code !== 0)
2416
+ console.log(` a download of ${link} failed: ${complaint.split("\n").filter((one) => one.startsWith("ERROR")).pop() ?? code}`);
2417
+ response.end();
2418
+ });
2419
+ // The person closed the tab: stop fetching what nobody will keep.
2420
+ response.on("close", () => {
2421
+ if (child.exitCode === null)
2422
+ child.kill("SIGKILL");
2423
+ });
2424
+ return;
2425
+ }
2270
2426
  // A channel is one publisher and everybody listening to them. Two or three
2271
2427
  // devices can publish at once, each to their own channel, and a listener
2272
2428
  // picks which to hear.
@@ -3613,6 +3769,7 @@ export async function serve(argv, version = "0.1.0") {
3613
3769
  status: () => ({
3614
3770
  live: publisher !== null,
3615
3771
  code: listing?.code ?? "",
3772
+ channelCodes: listing?.channelCodes ?? {},
3616
3773
  name: listing?.name ?? (options.name || hostname()),
3617
3774
  url: listing?.url ?? (publishable_ ? shareLink(publishable_.url, listenKey, false) : ""),
3618
3775
  // Whether going live is even possible here. A laptop behind a router
@@ -3663,6 +3820,11 @@ export async function serve(argv, version = "0.1.0") {
3663
3820
  paywall,
3664
3821
  ffmpeg: tools.ffmpeg,
3665
3822
  ffprobe: tools.ffprobe,
3823
+ // For a pasted link: where its media is, and the whole of it to keep.
3824
+ // A cookie jar beside the state, when the operator has put one there,
3825
+ // for the sites that will not talk to a datacenter without one.
3826
+ ytdlp: tools.ytdlp ?? null,
3827
+ cookies: cookiesFile(),
3666
3828
  ...(tls ? { tls } : {}),
3667
3829
  // Untagged, so a directory of five thousand files answers at once; the
3668
3830
  // tags follow through `tag` below.
package/dist/share.js CHANGED
@@ -327,8 +327,10 @@ export function allowedForListening(path) {
327
327
  return false;
328
328
  // Listing this machine in a public directory is not listening to it. The
329
329
  // listen address itself, /api/live, stays open: that is the thing a listen
330
- // key is for.
331
- if (path === "/api/live/state" || path === "/api/live/start" || path === "/api/live/stop")
330
+ // key is for -- and so is knowing the phone code, which /api/live/state
331
+ // answers: a joiner is told the number to call and the code to key, and
332
+ // the code is in the public directory anyway.
333
+ if (path === "/api/live/start" || path === "/api/live/stop")
332
334
  return false;
333
335
  return true;
334
336
  }