nixamp 0.9.11 → 0.10.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/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. */
@@ -181,8 +187,30 @@ export declare class Directory {
181
187
  announce(announcement: Announcement, ownerId?: string): Listing;
182
188
  withdraw(id: string): void;
183
189
  list(): Listing[];
184
- /** The live stream on this code, if there is one. */
190
+ /**
191
+ * The live stream on this code, if there is one.
192
+ *
193
+ * A channel's code answers as the channel: the same listing, named for the
194
+ * channel and playing nothing else, so the phone line says "the live room
195
+ * for FIBA World Cup" rather than the server's name and whatever its own
196
+ * player has on.
197
+ */
185
198
  liveByCode(code: string): Listing | undefined;
199
+ /**
200
+ * A code for each channel, kept while the channel stays on.
201
+ *
202
+ * One code per server meant every live on it shared a room: somebody
203
+ * calling about the basketball landed with the people talking about the
204
+ * film. Each live is its own room now, and a channel that is still there at
205
+ * the next heartbeat keeps the code it was given.
206
+ */
207
+ private codesFor;
208
+ /**
209
+ * Whether a code is somebody's already: a stream's own, a channel's on any
210
+ * stream, or a recently-ended stream's. A listing renewing itself is not
211
+ * "somebody else", or its channels would be re-coded every heartbeat.
212
+ */
213
+ private taken;
186
214
  /**
187
215
  * Streams that stopped recently, most recent first.
188
216
  *
@@ -201,7 +229,7 @@ export declare class Directory {
201
229
  endedByCode(code: string): Ended | undefined;
202
230
  private endedByUrl;
203
231
  private remember;
204
- /** A code no live and no recently-ended stream is using. */
232
+ /** A code no live stream, no channel on one, and no recently-ended stream is using. */
205
233
  private freeCode;
206
234
  /** Forget anything that stopped renewing, keeping a note of when it did. */
207
235
  private sweep;
package/dist/directory.js CHANGED
@@ -179,6 +179,7 @@ export class Directory {
179
179
  // listing always meant, and no channels is the honest empty list.
180
180
  playing: announcement.playing ?? true,
181
181
  channels: announcement.channels ?? [],
182
+ channelCodes: this.codesFor(announcement.channels ?? [], existing?.channelCodes ?? (previously && "channelCodes" in previously ? previously.channelCodes : undefined), id),
182
183
  updatedAt: this.now(),
183
184
  // A stream that never stopped keeps its original start. One that did
184
185
  // starts again now, because that is what a caller is being told about.
@@ -200,10 +201,59 @@ export class Directory {
200
201
  this.sweep();
201
202
  return [...this.items.values()].sort((a, b) => b.updatedAt - a.updatedAt || a.id.localeCompare(b.id));
202
203
  }
203
- /** The live stream on this code, if there is one. */
204
+ /**
205
+ * The live stream on this code, if there is one.
206
+ *
207
+ * A channel's code answers as the channel: the same listing, named for the
208
+ * channel and playing nothing else, so the phone line says "the live room
209
+ * for FIBA World Cup" rather than the server's name and whatever its own
210
+ * player has on.
211
+ */
204
212
  liveByCode(code) {
205
213
  this.sweep();
206
- return [...this.items.values()].find((item) => item.code === code);
214
+ const own = [...this.items.values()].find((item) => item.code === code);
215
+ if (own !== undefined)
216
+ return own;
217
+ for (const item of this.items.values()) {
218
+ const channel = Object.entries(item.channelCodes).find(([, one]) => one === code)?.[0];
219
+ if (channel !== undefined)
220
+ return { ...item, code, name: channel, nowPlaying: "" };
221
+ }
222
+ return undefined;
223
+ }
224
+ /**
225
+ * A code for each channel, kept while the channel stays on.
226
+ *
227
+ * One code per server meant every live on it shared a room: somebody
228
+ * calling about the basketball landed with the people talking about the
229
+ * film. Each live is its own room now, and a channel that is still there at
230
+ * the next heartbeat keeps the code it was given.
231
+ */
232
+ codesFor(channels, had, own = "") {
233
+ const codes = {};
234
+ for (const name of channels) {
235
+ if (codes[name] !== undefined)
236
+ continue;
237
+ const kept = had?.[name];
238
+ codes[name] = kept !== undefined && !this.taken(kept, codes, own) ? kept : this.freeCode(codes, own);
239
+ }
240
+ return codes;
241
+ }
242
+ /**
243
+ * Whether a code is somebody's already: a stream's own, a channel's on any
244
+ * stream, or a recently-ended stream's. A listing renewing itself is not
245
+ * "somebody else", or its channels would be re-coded every heartbeat.
246
+ */
247
+ taken(code, besides = {}, own = "") {
248
+ if (Object.values(besides).includes(code))
249
+ return true;
250
+ for (const item of this.items.values()) {
251
+ if (item.code === code)
252
+ return true;
253
+ if (item.id !== own && Object.values(item.channelCodes).includes(code))
254
+ return true;
255
+ }
256
+ return [...this.ended.values()].some((i) => i.code === code);
207
257
  }
208
258
  /**
209
259
  * Streams that stopped recently, most recent first.
@@ -263,15 +313,13 @@ export class Directory {
263
313
  this.ended.set(item.id, record);
264
314
  this.mirror?.save(record);
265
315
  }
266
- /** A code no live and no recently-ended stream is using. */
267
- freeCode() {
316
+ /** A code no live stream, no channel on one, and no recently-ended stream is using. */
317
+ freeCode(besides = {}, own = "") {
268
318
  for (let tries = 0; tries < 40; tries += 1) {
269
319
  const code = this.randomCode();
270
320
  if (code.length !== 6)
271
321
  continue;
272
- const taken = [...this.items.values()].some((i) => i.code === code) ||
273
- [...this.ended.values()].some((i) => i.code === code);
274
- if (!taken)
322
+ if (!this.taken(code, besides, own))
275
323
  return code;
276
324
  }
277
325
  return "";
@@ -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/owner.js CHANGED
@@ -92,7 +92,7 @@ export const ADMIN_PATHS = [
92
92
  * `/api/live` itself is the public listen address -- the one the phone line is
93
93
  * handed. Gating it by prefix would shut the front door to lock the office.
94
94
  */
95
- const LIVE_CONTROL = ["/api/live/state", "/api/live/start", "/api/live/stop"];
95
+ const LIVE_CONTROL = ["/api/live/start", "/api/live/stop"];
96
96
  export function needsAdmin(path, method = "GET") {
97
97
  if (LIVE_CONTROL.includes(path))
98
98
  return true;
package/dist/publish.d.ts CHANGED
@@ -43,6 +43,16 @@ export interface PublishTarget {
43
43
  onConfig?: (config: unknown) => void;
44
44
  /** Called when the directory refused us for want of an account. */
45
45
  onRefused?: () => void;
46
+ /**
47
+ * Called with the listing every heartbeat, not only the first.
48
+ *
49
+ * The directory forgets everything when it restarts, and the next heartbeat
50
+ * lists this server again under a new phone code. A server that only kept
51
+ * the first answer went on showing the old code -- to the Share panel, the
52
+ * player, and anybody sent the link -- while the phone line knew only the
53
+ * new one.
54
+ */
55
+ onListed?: (listing: Listing) => void;
46
56
  }
47
57
  /**
48
58
  * Ask, with yes as the default. Returns false without asking when there is no
package/dist/publish.js CHANGED
@@ -82,6 +82,7 @@ export class Publisher {
82
82
  this.id = listing.id;
83
83
  if (listing.config !== undefined)
84
84
  this.target.onConfig?.(listing.config);
85
+ this.target.onListed?.(listing);
85
86
  return listing;
86
87
  }
87
88
  catch {
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 ?? "",
@@ -2267,6 +2306,117 @@ export function createHandler(engine, options) {
2267
2306
  json(response, 404, { error: "no such endpoint" });
2268
2307
  return;
2269
2308
  }
2309
+ // --- any link, played -------------------------------------------------
2310
+ //
2311
+ // Paste a page -- YouTube, a podcast, SoundCloud, a TikTok live -- and the
2312
+ // server works out where the media is and plays it as a channel of its
2313
+ // own, the way a catalog entry is played: started for whoever asked,
2314
+ // stopped a minute after the last viewer leaves. Open to anyone holding
2315
+ // the link, like picking something from a catalog.
2316
+ if (path === "/api/links/play" && request.method === "POST") {
2317
+ let body = {};
2318
+ try {
2319
+ body = JSON.parse(await readBody(request));
2320
+ }
2321
+ catch {
2322
+ json(response, 400, { error: "bad JSON" });
2323
+ return;
2324
+ }
2325
+ const link = playableLink(body.url);
2326
+ if (link === "") {
2327
+ json(response, 400, { error: "that is not a link this can play" });
2328
+ return;
2329
+ }
2330
+ if (!options.channels) {
2331
+ json(response, 503, { error: "this server cannot carry channels" });
2332
+ return;
2333
+ }
2334
+ const channelId = linkChannelId(link);
2335
+ const known = links.get(link);
2336
+ if (options.channels.has(channelId) && known) {
2337
+ json(response, 200, shownLink(channelId, known));
2338
+ return;
2339
+ }
2340
+ if (!options.channels.has(channelId) && options.channels.ephemeralCount >= MAX_ON_DEMAND) {
2341
+ json(response, 429, { error: `this server is already carrying ${MAX_ON_DEMAND} channels on demand; try again in a minute` });
2342
+ return;
2343
+ }
2344
+ const resolved = known ?? await resolveLink(options.ytdlp ?? null, link, { cookies: options.cookies ?? "" });
2345
+ if ("error" in resolved) {
2346
+ json(response, 422, { error: resolved.error });
2347
+ return;
2348
+ }
2349
+ links.set(link, resolved);
2350
+ if (!options.channels.has(channelId)) {
2351
+ const started = await pullChannel(options.channels, options.ffprobe ?? ["ffprobe"], channelId, resolved.title, resolved.media, inputArgsFor(resolved.headers));
2352
+ if (!started) {
2353
+ json(response, 409, { error: "that link is already starting" });
2354
+ return;
2355
+ }
2356
+ options.channels.ephemeral(channelId);
2357
+ }
2358
+ json(response, 200, shownLink(channelId, resolved));
2359
+ return;
2360
+ }
2361
+ // The whole thing, to keep. The server fetches it through yt-dlp and
2362
+ // hands the bytes straight on as a download, so the person's own machine
2363
+ // ends up with the file and the server keeps nothing. A live stream has
2364
+ // no whole to hand over.
2365
+ if (path === "/api/links/download") {
2366
+ const link = playableLink(url.searchParams.get("url"));
2367
+ if (link === "") {
2368
+ json(response, 400, { error: "that is not a link this can fetch" });
2369
+ return;
2370
+ }
2371
+ if (!options.ytdlp || options.ytdlp.length === 0) {
2372
+ json(response, 503, { error: "this server has no yt-dlp to fetch with" });
2373
+ return;
2374
+ }
2375
+ const resolved = links.get(link) ?? await resolveLink(options.ytdlp, link, { cookies: options.cookies ?? "" });
2376
+ if ("error" in resolved) {
2377
+ json(response, 422, { error: resolved.error });
2378
+ return;
2379
+ }
2380
+ links.set(link, resolved);
2381
+ if (resolved.live) {
2382
+ json(response, 409, { error: "that is live; there is no whole file to download yet" });
2383
+ return;
2384
+ }
2385
+ const audioOnly = url.searchParams.get("audio") === "1" || !resolved.video;
2386
+ // Named for the format the download will actually take, which is not
2387
+ // always the one played: a track played from an HLS playlist is saved
2388
+ // as the plain MP3 the site also offers, and ".m4a" on an MP3 is a
2389
+ // file nothing will open.
2390
+ const saved = await resolveLink(options.ytdlp, link, { cookies: options.cookies ?? "", format: saveFormat(audioOnly) });
2391
+ const fileName = fileNameFor("error" in saved ? resolved : { ...resolved, ext: saved.ext || resolved.ext }, audioOnly);
2392
+ const [command, ...prefix] = options.ytdlp;
2393
+ const child = spawn(command, [...prefix, ...downloadArgs(link, audioOnly, options.cookies ?? "")], {
2394
+ stdio: ["ignore", "pipe", "pipe"],
2395
+ });
2396
+ response.writeHead(200, {
2397
+ "content-type": contentTypeFor(fileName),
2398
+ "content-disposition": `attachment; filename="${fileName.replace(/["\\]/g, "")}"; filename*=UTF-8''${encodeURIComponent(fileName)}`,
2399
+ "cache-control": "no-store",
2400
+ });
2401
+ child.stdout?.pipe(response);
2402
+ let complaint = "";
2403
+ child.stderr?.on("data", (chunk) => {
2404
+ if (complaint.length < 20_000)
2405
+ complaint += chunk.toString("utf8");
2406
+ });
2407
+ child.on("error", () => response.end());
2408
+ child.on("close", (code) => {
2409
+ if (code !== 0)
2410
+ console.log(` a download of ${link} failed: ${complaint.split("\n").filter((one) => one.startsWith("ERROR")).pop() ?? code}`);
2411
+ response.end();
2412
+ });
2413
+ // The person closed the tab: stop fetching what nobody will keep.
2414
+ response.on("close", () => {
2415
+ if (child.exitCode === null)
2416
+ child.kill("SIGKILL");
2417
+ });
2418
+ return;
2419
+ }
2270
2420
  // A channel is one publisher and everybody listening to them. Two or three
2271
2421
  // devices can publish at once, each to their own channel, and a listener
2272
2422
  // picks which to hear.
@@ -3613,6 +3763,7 @@ export async function serve(argv, version = "0.1.0") {
3613
3763
  status: () => ({
3614
3764
  live: publisher !== null,
3615
3765
  code: listing?.code ?? "",
3766
+ channelCodes: listing?.channelCodes ?? {},
3616
3767
  name: listing?.name ?? (options.name || hostname()),
3617
3768
  url: listing?.url ?? (publishable_ ? shareLink(publishable_.url, listenKey, false) : ""),
3618
3769
  // Whether going live is even possible here. A laptop behind a router
@@ -3663,6 +3814,11 @@ export async function serve(argv, version = "0.1.0") {
3663
3814
  paywall,
3664
3815
  ffmpeg: tools.ffmpeg,
3665
3816
  ffprobe: tools.ffprobe,
3817
+ // For a pasted link: where its media is, and the whole of it to keep.
3818
+ // A cookie jar beside the state, when the operator has put one there,
3819
+ // for the sites that will not talk to a datacenter without one.
3820
+ ytdlp: tools.ytdlp ?? null,
3821
+ cookies: cookiesFile(),
3666
3822
  ...(tls ? { tls } : {}),
3667
3823
  // Untagged, so a directory of five thousand files answers at once; the
3668
3824
  // tags follow through `tag` below.
@@ -3987,6 +4143,14 @@ export async function serve(argv, version = "0.1.0") {
3987
4143
  console.log(" nixamp.com would not list this stream: it needs an account.");
3988
4144
  console.log(" Run `nixamp login` (or `nixamp signup`) and start again.");
3989
4145
  },
4146
+ // Every heartbeat, so the code this server shows is the code the
4147
+ // phone line knows, whatever the directory has forgotten meanwhile.
4148
+ onListed: (fresh) => {
4149
+ if (listing && listing.code !== fresh.code) {
4150
+ console.log(` nixamp.com listed this stream again; the phone code is now ${fresh.code}.`);
4151
+ }
4152
+ listing = fresh;
4153
+ },
3990
4154
  nowPlaying: () => {
3991
4155
  const snapshot = engine.snapshot();
3992
4156
  return snapshot.tracks?.[snapshot.index]?.title ?? "";
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.9.11",
3
+ "version": "0.10.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",