nixamp 0.7.34 → 0.7.35

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.
@@ -11,11 +11,23 @@ export interface ChannelInfo {
11
11
  /** The container it is sending, e.g. webm from a browser, flv over RTMP. */
12
12
  format: string;
13
13
  /** How it arrived. */
14
- via: "http" | "rtmp";
14
+ via: "http" | "rtmp" | "pull";
15
15
  startedAt: number;
16
16
  bytes: number;
17
17
  listeners: number;
18
+ /**
19
+ * Whether there is a picture, which decides what a listener is sent and
20
+ * what the response calls it. A channel that says audio/mpeg while sending
21
+ * MP4 plays as nothing at all.
22
+ */
23
+ kind?: "audio" | "video";
24
+ /** For a channel we pull ourselves: where from. Never shown to a listener. */
25
+ source?: string;
18
26
  }
27
+ /** How long to wait before dialling a dropped source again. */
28
+ export declare const REDIAL = 2000;
29
+ /** How many times in a row a source may fail without ever sending anything. */
30
+ export declare const GIVE_UP = 5;
19
31
  /** A name that can sit in a URL and be read back in a list. */
20
32
  export declare function cleanId(value: unknown, fallback?: string): string;
21
33
  export interface ChannelOptions {
@@ -36,8 +48,44 @@ export declare class Channel {
36
48
  readonly listeners: Set<Listener>;
37
49
  private child;
38
50
  private closing;
51
+ /** Set for a channel that carries pictures, which cannot be joined blind. */
52
+ private fragments;
53
+ /** For a pulled channel: what to run, and how many times it has failed. */
54
+ private redial;
55
+ private failures;
56
+ private timer;
39
57
  constructor(info: ChannelInfo, options: ChannelOptions, onGone: (id: string) => void);
40
58
  start(format: string): void;
59
+ /**
60
+ * Fetch a source ourselves, rather than waiting to be sent one.
61
+ *
62
+ * This is what makes a re-stream a channel instead of a track. A track is
63
+ * played by the one player a server has, so a second one is a second thing
64
+ * that server cannot do at the same time; a channel is its own process with
65
+ * its own audience, and a server can carry as many as it can decode. Two
66
+ * channels means two tabs, or two panels of a multiview.
67
+ *
68
+ * It keeps running with nobody listening. Live television does not pause
69
+ * because you looked away, and a room where the picture depends on who is
70
+ * in it is not a room anybody can be invited to.
71
+ */
72
+ pull(source: string, encode: string[], paced?: boolean): void;
73
+ /**
74
+ * A source that stopped. Try it again, unless it never worked at all.
75
+ *
76
+ * The difference matters: a channel that ran for six hours and dropped is
77
+ * worth dialling again, and a URL that has never once produced a byte is a
78
+ * mistake somebody made, and retrying it for ever helps nobody.
79
+ */
80
+ private dropped;
81
+ /**
82
+ * Out to the audience, whole boxes at a time when there are boxes.
83
+ *
84
+ * Video listeners are only ever sent complete boxes, so that a new one can
85
+ * be given the opening boxes and then join at the next fragment and have it
86
+ * make sense.
87
+ */
88
+ private emit;
41
89
  /** Feed the source. */
42
90
  write(chunk: Buffer): boolean;
43
91
  pump(body: Readable): Promise<void>;
@@ -75,6 +123,16 @@ export declare class Channels {
75
123
  * *different* channel is exactly what this class exists for.
76
124
  */
77
125
  publish(id: string, name: string, format: string, via: ChannelInfo["via"]): Channel | null;
126
+ /**
127
+ * Carry a source of our own: a re-stream, or a film on this disk shown live.
128
+ *
129
+ * Null when that channel is taken, the same as publishing. Everything else
130
+ * about it is the same too, which is the point -- a re-stream stops being a
131
+ * special case and becomes one more thing that is on.
132
+ */
133
+ pull(id: string, name: string, source: string, encode: string[], kind: "audio" | "video", paced?: boolean): Channel | null;
134
+ /** What a listener should be told this channel is. */
135
+ contentType(id: string): string;
78
136
  /** Attach a listener, or null when nothing is playing on that channel. */
79
137
  listen(id: string, listener: Listener): (() => void) | null;
80
138
  /** Feed a channel that already exists, for a publisher sending chunks. */
package/dist/channels.js CHANGED
@@ -16,6 +16,11 @@
16
16
  */
17
17
  import { spawn } from "node:child_process";
18
18
  import { randomBytes } from "node:crypto";
19
+ import { Fragments } from "./fragments.js";
20
+ /** How long to wait before dialling a dropped source again. */
21
+ export const REDIAL = 2000;
22
+ /** How many times in a row a source may fail without ever sending anything. */
23
+ export const GIVE_UP = 5;
19
24
  /** A name that can sit in a URL and be read back in a list. */
20
25
  export function cleanId(value, fallback = "main") {
21
26
  if (typeof value !== "string")
@@ -36,6 +41,12 @@ export class Channel {
36
41
  listeners = new Set();
37
42
  child = null;
38
43
  closing = false;
44
+ /** Set for a channel that carries pictures, which cannot be joined blind. */
45
+ fragments = null;
46
+ /** For a pulled channel: what to run, and how many times it has failed. */
47
+ redial = null;
48
+ failures = 0;
49
+ timer = null;
39
50
  constructor(info, options, onGone) {
40
51
  this.info = info;
41
52
  this.options = options;
@@ -70,6 +81,97 @@ export class Channel {
70
81
  this.child = child;
71
82
  this.options.onStart?.(this.info);
72
83
  }
84
+ /**
85
+ * Fetch a source ourselves, rather than waiting to be sent one.
86
+ *
87
+ * This is what makes a re-stream a channel instead of a track. A track is
88
+ * played by the one player a server has, so a second one is a second thing
89
+ * that server cannot do at the same time; a channel is its own process with
90
+ * its own audience, and a server can carry as many as it can decode. Two
91
+ * channels means two tabs, or two panels of a multiview.
92
+ *
93
+ * It keeps running with nobody listening. Live television does not pause
94
+ * because you looked away, and a room where the picture depends on who is
95
+ * in it is not a room anybody can be invited to.
96
+ */
97
+ pull(source, encode, paced = true) {
98
+ if (this.info.kind === "video")
99
+ this.fragments = new Fragments();
100
+ const [command, ...prefix] = this.options.ffmpeg;
101
+ const remote = /^https?:\/\//i.test(source);
102
+ const dial = () => {
103
+ if (this.closing)
104
+ return;
105
+ const child = spawn(command, [
106
+ ...prefix,
107
+ "-hide_banner",
108
+ "-loglevel", "error",
109
+ // A dropped source is normal over hours, and a channel that dies
110
+ // the first time a CDN hiccups is not a channel anybody can rely
111
+ // on. ffmpeg redials on its own before we have to.
112
+ ...(remote ? ["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5"] : []),
113
+ // Real time, always. A file read as fast as the disk allows is an
114
+ // hour of film in ninety seconds and a room that cannot be in it
115
+ // together; a live source is already paced and loses nothing.
116
+ ...(paced ? ["-re"] : []),
117
+ "-i", source,
118
+ ...encode,
119
+ "pipe:1",
120
+ ], { stdio: ["ignore", "pipe", "pipe"] });
121
+ let sent = false;
122
+ child.stdout?.on("data", (chunk) => {
123
+ sent = true;
124
+ this.info.bytes += chunk.byteLength;
125
+ this.emit(chunk);
126
+ });
127
+ child.stdout?.on("error", () => undefined);
128
+ child.on("error", () => this.dropped(sent));
129
+ child.on("close", () => this.dropped(sent));
130
+ this.child = child;
131
+ };
132
+ this.redial = dial;
133
+ dial();
134
+ this.options.onStart?.(this.info);
135
+ }
136
+ /**
137
+ * A source that stopped. Try it again, unless it never worked at all.
138
+ *
139
+ * The difference matters: a channel that ran for six hours and dropped is
140
+ * worth dialling again, and a URL that has never once produced a byte is a
141
+ * mistake somebody made, and retrying it for ever helps nobody.
142
+ */
143
+ dropped(sent) {
144
+ if (this.closing || !this.redial)
145
+ return;
146
+ this.child = null;
147
+ this.failures = sent ? 0 : this.failures + 1;
148
+ if (this.failures >= GIVE_UP) {
149
+ this.close();
150
+ return;
151
+ }
152
+ const dial = this.redial;
153
+ this.timer = setTimeout(() => {
154
+ this.timer = null;
155
+ dial();
156
+ }, REDIAL);
157
+ // A redial is not a reason to keep the process alive at exit.
158
+ this.timer.unref?.();
159
+ }
160
+ /**
161
+ * Out to the audience, whole boxes at a time when there are boxes.
162
+ *
163
+ * Video listeners are only ever sent complete boxes, so that a new one can
164
+ * be given the opening boxes and then join at the next fragment and have it
165
+ * make sense.
166
+ */
167
+ emit(chunk) {
168
+ if (!this.fragments) {
169
+ this.send(chunk);
170
+ return;
171
+ }
172
+ for (const box of this.fragments.push(chunk))
173
+ this.send(box);
174
+ }
73
175
  /** Feed the source. */
74
176
  write(chunk) {
75
177
  return this.child?.stdin?.write(chunk) ?? false;
@@ -106,6 +208,17 @@ export class Channel {
106
208
  this.info.listeners = this.listeners.size;
107
209
  }
108
210
  listen(listener) {
211
+ // What the stream is, before any of what it is currently saying. Without
212
+ // this a listener who arrives after the first second gets fragments that
213
+ // reference tracks they were never told about: a blank panel, no error.
214
+ if (this.fragments?.ready) {
215
+ try {
216
+ listener.write(this.fragments.header);
217
+ }
218
+ catch {
219
+ // Gone before it began; the detach below still tidies up.
220
+ }
221
+ }
109
222
  this.listeners.add(listener);
110
223
  this.info.listeners = this.listeners.size;
111
224
  return () => {
@@ -117,6 +230,10 @@ export class Channel {
117
230
  if (this.closing)
118
231
  return;
119
232
  this.closing = true;
233
+ this.redial = null;
234
+ if (this.timer)
235
+ clearTimeout(this.timer);
236
+ this.timer = null;
120
237
  const child = this.child;
121
238
  this.child = null;
122
239
  try {
@@ -194,6 +311,35 @@ export class Channels {
194
311
  channel.start(format);
195
312
  return channel;
196
313
  }
314
+ /**
315
+ * Carry a source of our own: a re-stream, or a film on this disk shown live.
316
+ *
317
+ * Null when that channel is taken, the same as publishing. Everything else
318
+ * about it is the same too, which is the point -- a re-stream stops being a
319
+ * special case and becomes one more thing that is on.
320
+ */
321
+ pull(id, name, source, encode, kind, paced = true) {
322
+ if (this.open.has(id))
323
+ return null;
324
+ const channel = new Channel({
325
+ id,
326
+ name: name || source,
327
+ format: kind === "video" ? "mp4" : "mp3",
328
+ via: "pull",
329
+ startedAt: Date.now(),
330
+ bytes: 0,
331
+ listeners: 0,
332
+ kind,
333
+ source,
334
+ }, this.options, (gone) => this.open.delete(gone));
335
+ this.open.set(id, channel);
336
+ channel.pull(source, encode, paced);
337
+ return channel;
338
+ }
339
+ /** What a listener should be told this channel is. */
340
+ contentType(id) {
341
+ return this.open.get(id)?.info.kind === "video" ? "video/mp4" : "audio/mpeg";
342
+ }
197
343
  /** Attach a listener, or null when nothing is playing on that channel. */
198
344
  listen(id, listener) {
199
345
  const channel = this.open.get(id);
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Splitting a fragmented MP4 into the pieces a late arrival needs.
3
+ *
4
+ * MP3 can be joined halfway through because every frame says what it is: a
5
+ * player finds the next frame boundary and carries on. Fragmented MP4 cannot.
6
+ * It opens with an `ftyp` and a `moov` that describe the tracks -- how many,
7
+ * which codecs, what timescale -- and everything after that is a `moof` and an
8
+ * `mdat` that mean nothing without them. Hand somebody the middle of that
9
+ * stream and their browser has no idea what it is holding, which is a black
10
+ * panel and no error.
11
+ *
12
+ * So the opening boxes are kept, and a listener who arrives an hour late is
13
+ * given them before the live bytes. Fragments written with `frag_keyframe`
14
+ * each begin at a keyframe, so the picture starts at the first one rather than
15
+ * with a screen of blocks catching up.
16
+ *
17
+ * This also means listeners are only ever written whole boxes. A chunk from a
18
+ * pipe ends wherever the pipe felt like ending it, and half a `moof` is not
19
+ * something to send anybody.
20
+ */
21
+ export interface Box {
22
+ type: string;
23
+ bytes: Buffer;
24
+ }
25
+ /**
26
+ * The first whole box in a buffer, or null when it has not all arrived.
27
+ *
28
+ * Null is also the answer for anything malformed, because the difference does
29
+ * not matter to a caller who can only wait or give up, and guessing at a
30
+ * broken length walks off the end of the stream.
31
+ */
32
+ export declare function firstBox(buffer: Buffer): {
33
+ box: Box;
34
+ rest: Buffer;
35
+ } | null;
36
+ /** The boxes that describe the stream rather than carry it. */
37
+ export declare function isOpening(type: string): boolean;
38
+ /**
39
+ * A fragmented MP4 arriving in pieces, handed back a box at a time.
40
+ *
41
+ * Keeps the opening boxes so they can be replayed to whoever turns up later.
42
+ * If the bytes turn out not to be an MP4 at all -- a source that failed, a
43
+ * format nobody expected -- it stops trying to parse and passes them through,
44
+ * on the grounds that a stream somebody might be able to play beats a stream
45
+ * nobody can.
46
+ */
47
+ export declare class Fragments {
48
+ private held;
49
+ private opening;
50
+ private confused;
51
+ /** The `ftyp` and `moov` seen so far, ready to send to a new listener. */
52
+ get header(): Buffer;
53
+ /** Whether enough has arrived to describe the stream to somebody new. */
54
+ get ready(): boolean;
55
+ /** Feed bytes in; get whole boxes out, in order. */
56
+ push(chunk: Buffer): Buffer[];
57
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Splitting a fragmented MP4 into the pieces a late arrival needs.
3
+ *
4
+ * MP3 can be joined halfway through because every frame says what it is: a
5
+ * player finds the next frame boundary and carries on. Fragmented MP4 cannot.
6
+ * It opens with an `ftyp` and a `moov` that describe the tracks -- how many,
7
+ * which codecs, what timescale -- and everything after that is a `moof` and an
8
+ * `mdat` that mean nothing without them. Hand somebody the middle of that
9
+ * stream and their browser has no idea what it is holding, which is a black
10
+ * panel and no error.
11
+ *
12
+ * So the opening boxes are kept, and a listener who arrives an hour late is
13
+ * given them before the live bytes. Fragments written with `frag_keyframe`
14
+ * each begin at a keyframe, so the picture starts at the first one rather than
15
+ * with a screen of blocks catching up.
16
+ *
17
+ * This also means listeners are only ever written whole boxes. A chunk from a
18
+ * pipe ends wherever the pipe felt like ending it, and half a `moof` is not
19
+ * something to send anybody.
20
+ */
21
+ /** The header of an MP4 box: four bytes of length, four of name. */
22
+ const HEADER = 8;
23
+ /** A length of 1 means the real one is the eight bytes that follow. */
24
+ const BIG = 16;
25
+ /**
26
+ * The first whole box in a buffer, or null when it has not all arrived.
27
+ *
28
+ * Null is also the answer for anything malformed, because the difference does
29
+ * not matter to a caller who can only wait or give up, and guessing at a
30
+ * broken length walks off the end of the stream.
31
+ */
32
+ export function firstBox(buffer) {
33
+ if (buffer.length < HEADER)
34
+ return null;
35
+ const stated = buffer.readUInt32BE(0);
36
+ const type = buffer.toString("latin1", 4, HEADER);
37
+ // A name is four printable characters. Anything else means we are not
38
+ // looking at a box header, and no length read from here can be trusted.
39
+ if (!/^[\x20-\x7e]{4}$/.test(type))
40
+ return null;
41
+ let size = stated;
42
+ let header = HEADER;
43
+ if (stated === 1) {
44
+ if (buffer.length < BIG)
45
+ return null;
46
+ const large = buffer.readBigUInt64BE(HEADER);
47
+ if (large > BigInt(Number.MAX_SAFE_INTEGER))
48
+ return null;
49
+ size = Number(large);
50
+ header = BIG;
51
+ }
52
+ // Zero means "to the end of the file", which a live stream does not have.
53
+ if (size < header)
54
+ return null;
55
+ if (buffer.length < size)
56
+ return null;
57
+ return { box: { type, bytes: buffer.subarray(0, size) }, rest: buffer.subarray(size) };
58
+ }
59
+ /** The boxes that describe the stream rather than carry it. */
60
+ export function isOpening(type) {
61
+ return type === "ftyp" || type === "moov";
62
+ }
63
+ /**
64
+ * A fragmented MP4 arriving in pieces, handed back a box at a time.
65
+ *
66
+ * Keeps the opening boxes so they can be replayed to whoever turns up later.
67
+ * If the bytes turn out not to be an MP4 at all -- a source that failed, a
68
+ * format nobody expected -- it stops trying to parse and passes them through,
69
+ * on the grounds that a stream somebody might be able to play beats a stream
70
+ * nobody can.
71
+ */
72
+ export class Fragments {
73
+ held = Buffer.alloc(0);
74
+ opening = [];
75
+ confused = false;
76
+ /** The `ftyp` and `moov` seen so far, ready to send to a new listener. */
77
+ get header() {
78
+ return this.opening.length === 0 ? Buffer.alloc(0) : Buffer.concat(this.opening);
79
+ }
80
+ /** Whether enough has arrived to describe the stream to somebody new. */
81
+ get ready() {
82
+ return this.opening.length > 0;
83
+ }
84
+ /** Feed bytes in; get whole boxes out, in order. */
85
+ push(chunk) {
86
+ if (this.confused)
87
+ return [chunk];
88
+ this.held = this.held.length === 0 ? chunk : Buffer.concat([this.held, chunk]);
89
+ const out = [];
90
+ for (;;) {
91
+ const next = firstBox(this.held);
92
+ if (!next)
93
+ break;
94
+ this.held = next.rest;
95
+ if (isOpening(next.box.type))
96
+ this.opening.push(next.box.bytes);
97
+ out.push(next.box.bytes);
98
+ }
99
+ // Nothing parses and the buffer keeps growing: this is not an MP4. Let it
100
+ // through rather than swallowing a stream into memory for ever.
101
+ if (this.opening.length === 0 && this.held.length > 4 * 1024 * 1024) {
102
+ this.confused = true;
103
+ const everything = this.held;
104
+ this.held = Buffer.alloc(0);
105
+ return [everything];
106
+ }
107
+ return out;
108
+ }
109
+ }
package/dist/server.js CHANGED
@@ -19,7 +19,7 @@ import { readFileSync } from "node:fs";
19
19
  import { Connections } from "./connections.js";
20
20
  import { Broadcaster, DEFAULT_ENCODER, PRESETS, redact, } from "./broadcast.js";
21
21
  import { Ingest, normaliseFormat } from "./ingest.js";
22
- import { Channels, cleanId } from "./channels.js";
22
+ import { Channels, cleanId, generatedId } from "./channels.js";
23
23
  import { RtmpListeners } from "./rtmp-in.js";
24
24
  import { Accounts, clearedCookie, sessionCookie, tokenFrom } from "./accounts.js";
25
25
  import { anonymousHandle, Handles } from "./handles.js";
@@ -1810,17 +1810,32 @@ export function createHandler(engine, options) {
1810
1810
  if (action === undefined && request.method === "GET") {
1811
1811
  // Listening. The response is the fan-out target: whatever ffmpeg
1812
1812
  // produces for this channel is written to it until one end goes away.
1813
- const detach = channels.listen(id, response);
1814
- if (detach === null) {
1813
+ if (!channels.has(id)) {
1815
1814
  json(response, 404, { error: "nothing is playing on that channel" });
1816
1815
  return;
1817
1816
  }
1818
1817
  watch(request, response, "stream", id);
1818
+ // Headers first, and then the listener.
1819
+ //
1820
+ // Attaching first was fine while a channel only ever wrote future
1821
+ // bytes. A video channel writes the opening boxes to a new listener
1822
+ // the moment it joins, and those went out before this response had
1823
+ // any headers at all -- so it committed as a bare 200 with no
1824
+ // content-type, ended immediately, and the picture was one kilobyte
1825
+ // long. Whether it happened depended on whether ffmpeg had produced
1826
+ // its header yet, which is why it looked intermittent.
1819
1827
  response.writeHead(200, {
1820
1828
  ...CORS,
1821
- "content-type": "audio/mpeg",
1829
+ // Asked of the channel rather than assumed: a channel carrying
1830
+ // pictures that calls itself audio/mpeg plays as nothing at all.
1831
+ "content-type": channels.contentType(id),
1822
1832
  "cache-control": "no-store",
1823
1833
  });
1834
+ const detach = channels.listen(id, response);
1835
+ if (detach === null) {
1836
+ response.end();
1837
+ return;
1838
+ }
1824
1839
  const leave = () => detach();
1825
1840
  request.on("close", leave);
1826
1841
  response.on("close", leave);
@@ -1837,6 +1852,64 @@ export function createHandler(engine, options) {
1837
1852
  json(response, 405, { error: "GET, POST or DELETE" });
1838
1853
  return;
1839
1854
  }
1855
+ /**
1856
+ * Carry a source of our own, rather than waiting to be sent one.
1857
+ *
1858
+ * A re-stream used to be added to the playlist, where it became one
1859
+ * more track -- and a server plays one track at a time, so the second
1860
+ * channel you added sat there saying "stopped". Two channels are two
1861
+ * processes with two audiences and two addresses, which is what lets
1862
+ * one person watch the baseball while another watches the news, in two
1863
+ * tabs or in two panels of the same multiview.
1864
+ */
1865
+ if (action === "pull") {
1866
+ let source = "";
1867
+ let called = "";
1868
+ try {
1869
+ const body = JSON.parse(await readBody(request));
1870
+ called = String(body.name ?? "").replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, 80);
1871
+ // A track number rather than a path: it is the server's own library
1872
+ // either way, and a number cannot name a file outside it.
1873
+ if (typeof body.at === "number" && Number.isInteger(body.at) && body.at >= 0) {
1874
+ source = engine.trackPath(body.at) ?? "";
1875
+ if (source === "") {
1876
+ json(response, 404, { error: "no track there" });
1877
+ return;
1878
+ }
1879
+ }
1880
+ else {
1881
+ source = String(body.source ?? "").trim();
1882
+ }
1883
+ }
1884
+ catch {
1885
+ json(response, 400, { error: "bad JSON" });
1886
+ return;
1887
+ }
1888
+ if (source === "") {
1889
+ json(response, 400, { error: "give a URL to carry, or a track to show" });
1890
+ return;
1891
+ }
1892
+ const wanted = cleanId(rawId, generatedId());
1893
+ if (channels.has(wanted)) {
1894
+ json(response, 409, { error: "that channel is already on" });
1895
+ return;
1896
+ }
1897
+ const probe = options.ffprobe ?? ["ffprobe"];
1898
+ const codecs = await codecsOf({ ffmpeg: [], ffprobe: probe, play: null }, source);
1899
+ const kind = codecs.video === "" ? "audio" : "video";
1900
+ const encode = kind === "video"
1901
+ ? videoArgs(codecs)
1902
+ // No picture in it, so none is invented: MP3 is the thing every
1903
+ // browser plays and the thing a listener can join halfway through.
1904
+ : ["-vn", "-c:a", "libmp3lame", "-b:a", "192k", "-f", "mp3"];
1905
+ const channel = channels.pull(wanted, called, source, encode, kind);
1906
+ if (!channel) {
1907
+ json(response, 409, { error: "that channel is already on" });
1908
+ return;
1909
+ }
1910
+ json(response, 200, { ok: true, channel: channel.info });
1911
+ return;
1912
+ }
1840
1913
  const format = normaliseFormat(url.searchParams.get("format") ?? request.headers["content-type"]);
1841
1914
  if (format === null) {
1842
1915
  json(response, 415, { error: "give a container ffmpeg knows: webm, ogg, mp4, mp3, wav" });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.7.34",
3
+ "version": "0.7.35",
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",