nixamp 0.7.4 → 0.7.5

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/README.md CHANGED
@@ -354,8 +354,14 @@ anywhere else with `--url` and `--key`.
354
354
  ╰───────────────────────────────────────────────────────────────────────╯
355
355
  ```
356
356
 
357
- Press `r` to re-stream: hand the running server a different URL or path and the
358
- listeners stay connected while what they are hearing changes under them.
357
+ Press `a` to add: hand the running server a folder, an album URL or a file and
358
+ it joins the playlist under its own heading, with the library still there and
359
+ the listeners still connected. Press `r` to replace instead, which is the
360
+ bigger thing — this server now serves that, and the library it had is gone
361
+ until you restart it.
362
+
363
+ An added block can be taken back out from the playlist itself: its heading
364
+ carries an `×`.
359
365
 
360
366
  ## How it works
361
367
 
package/dist/admin.d.ts CHANGED
@@ -68,6 +68,8 @@ export interface View {
68
68
  error: string;
69
69
  typing: boolean;
70
70
  restreaming: string;
71
+ /** Whether what is being typed replaces the playlist rather than joining it. */
72
+ replacing?: boolean;
71
73
  /** Labelled addresses, and the key that makes them work. */
72
74
  links: {
73
75
  label: string;
package/dist/admin.js CHANGED
@@ -110,6 +110,9 @@ export async function admin(argv) {
110
110
  let snapshot = null;
111
111
  let error = "";
112
112
  let restreaming = "";
113
+ // Which of the two things typing a source means. Adding is the ordinary one
114
+ // and has its own key; replacing throws the library away, so it has another.
115
+ let replacing = false;
113
116
  let typing = false;
114
117
  const app = await createApp({ theme: themes.matrix, title: "nixamp admin", quitKeys: ["ctrl+c"] });
115
118
  const refresh = async () => {
@@ -129,13 +132,16 @@ export async function admin(argv) {
129
132
  if (key === "escape") {
130
133
  typing = false;
131
134
  restreaming = "";
135
+ replacing = false;
132
136
  }
133
137
  else if (key === "enter") {
134
138
  const url = restreaming.trim();
139
+ const asReplacement = replacing;
135
140
  typing = false;
136
141
  restreaming = "";
142
+ replacing = false;
137
143
  if (url)
138
- void restream(target, headers, url).then(() => refresh());
144
+ void restream(target, headers, url, asReplacement).then(() => refresh());
139
145
  }
140
146
  else if (key === "backspace")
141
147
  restreaming = restreaming.slice(0, -1);
@@ -148,26 +154,38 @@ export async function admin(argv) {
148
154
  app.quit();
149
155
  return;
150
156
  }
157
+ if (key === "a") {
158
+ typing = true;
159
+ replacing = false;
160
+ app.invalidate();
161
+ }
151
162
  if (key === "r") {
152
163
  typing = true;
164
+ replacing = true;
153
165
  app.invalidate();
154
166
  }
155
167
  });
156
168
  app.on("exit", () => clearInterval(timer));
157
169
  app.render(({ ui, theme }) => draw(ui, theme, {
158
- url: target.url, report, snapshot, error, typing, restreaming,
170
+ url: target.url, report, snapshot, error, typing, restreaming, replacing,
159
171
  links: target.links, key: target.key, source: target.source,
160
172
  }));
161
173
  await app.start();
162
174
  clearInterval(timer);
163
175
  }
164
- /** Ask the server to play something else, which is what re-streaming is. */
165
- async function restream(target, headers, url) {
176
+ /**
177
+ * Hand the server something else to play.
178
+ *
179
+ * Two different asks down one route: adding puts an album on the end of the
180
+ * playlist, replacing points the server somewhere else entirely. The server
181
+ * adds unless told otherwise, so only the second one says anything.
182
+ */
183
+ async function restream(target, headers, url, replacing = false) {
166
184
  try {
167
185
  await fetch(`${target.url}/api/source`, {
168
186
  method: "POST",
169
187
  headers: { ...headers, "content-type": "application/json" },
170
- body: JSON.stringify({ source: url }),
188
+ body: JSON.stringify({ source: url, ...(replacing ? { replace: true } : {}) }),
171
189
  });
172
190
  }
173
191
  catch {
@@ -249,14 +267,20 @@ export function draw(ui, theme, view) {
249
267
  });
250
268
  });
251
269
  if (view.typing) {
252
- ui.panel({ title: "Re-stream a URL or a path", size: 4 }, (p) => {
270
+ ui.panel({
271
+ title: view.replacing ? "Replace the playlist with a URL or a path" : "Add a URL or a path",
272
+ size: 4,
273
+ }, (p) => {
253
274
  p.text(`${view.restreaming}_`, { fg: theme.accent });
254
- p.label("Enter plays it here. Escape forgets it.");
275
+ p.label(view.replacing
276
+ ? "Enter drops this library and serves that instead. Escape forgets it."
277
+ : "Enter adds it to the playlist. Escape forgets it.");
255
278
  });
256
279
  }
257
280
  ui.statusBar({
258
281
  items: [
259
- { key: "r", label: "Re-stream" },
282
+ { key: "a", label: "Add" },
283
+ { key: "r", label: "Replace" },
260
284
  { key: "q", label: "Quit" },
261
285
  ],
262
286
  right: [{ key: "", label: report ? `${report.connections.length} seen` : "connecting" }],
@@ -20,6 +20,14 @@ export interface RemoteTrack {
20
20
  * a browser could show played its soundtrack over a blank panel.
21
21
  */
22
22
  video?: boolean;
23
+ /**
24
+ * The source this track came in with, when it was not part of the library.
25
+ *
26
+ * Absent means it belongs to whatever this server was started on. Present
27
+ * means somebody added a folder or an album afterwards, and the name is what
28
+ * a client puts at the top of that block so the two are not one soup.
29
+ */
30
+ group?: string;
23
31
  }
24
32
  /** Everything a remote needs to draw the player. */
25
33
  export interface Snapshot {
package/dist/server.d.ts CHANGED
@@ -124,6 +124,17 @@ export declare function parseRange(header: string | undefined, size: number): By
124
124
  * `..` in a request path is the oldest bug in static file serving.
125
125
  */
126
126
  export declare function safeJoin(rootDir: string, urlPath: string): string | null;
127
+ /**
128
+ * A track and the source it arrived with.
129
+ *
130
+ * The library a server was started on has no group: it is simply what this
131
+ * machine has. Anything added afterwards carries the name of the folder or
132
+ * album it came from, which is what lets a client draw the two apart instead
133
+ * of running them together.
134
+ */
135
+ export type Loaded = Track & {
136
+ group?: string;
137
+ };
127
138
  /** What the HTTP layer needs from a player. Tests hand it a fake. */
128
139
  export interface Engine {
129
140
  /** `withTracks` false leaves the library out, for a frame that is only motion. */
@@ -133,11 +144,29 @@ export interface Engine {
133
144
  /** Absolute path of a track, or undefined when the index is not one. */
134
145
  trackPath(index: number): string | undefined;
135
146
  /**
136
- * Play something else instead. Re-streaming is the whole reason the admin
137
- * view exists: point a running server at a URL without restarting it and
138
- * dropping every listener.
147
+ * Play something else instead of everything here.
148
+ *
149
+ * The big hammer, and no longer what adding a folder does: this is "point
150
+ * this server somewhere else", which throws the library away on purpose.
139
151
  */
140
152
  replace(tracks: Track[], root: string): void;
153
+ /**
154
+ * Play something as well as everything here.
155
+ *
156
+ * What somebody means by putting a folder in a box: the album shows up at
157
+ * the bottom of the playlist under its own name, and the music that was
158
+ * already there is still there. Answers how many tracks were new.
159
+ */
160
+ add(tracks: Track[], from: string): number;
161
+ /**
162
+ * Take an added source back out again, by the name `add` gave it.
163
+ *
164
+ * Nothing that came with the library can be dropped this way; the library is
165
+ * what the server is, and there is a command line for changing that.
166
+ */
167
+ drop(group: string): number;
168
+ /** Every added source, in the order they were added. */
169
+ groups(): string[];
141
170
  /**
142
171
  * The same tracks, now with their tags.
143
172
  *
@@ -150,7 +179,7 @@ export interface Engine {
150
179
  retag(tracks: Track[], root: string): void;
151
180
  stop(): void;
152
181
  }
153
- export declare function toRemoteTracks(tracks: Track[]): RemoteTrack[];
182
+ export declare function toRemoteTracks(tracks: Loaded[]): RemoteTrack[];
154
183
  export declare function hasPicture(path: string): boolean;
155
184
  /**
156
185
  * The headless player: the terminal app's engine without the terminal.
@@ -172,7 +201,7 @@ export declare class PlayerEngine implements Engine {
172
201
  private timer;
173
202
  private dirty;
174
203
  private state;
175
- constructor(tracks: Track[], root: string, tools: Tools,
204
+ constructor(tracks: Loaded[], root: string, tools: Tools,
176
205
  /** Frames a second pushed to remotes. */
177
206
  fps?: number);
178
207
  private readonly silent;
@@ -199,6 +228,30 @@ export declare class PlayerEngine implements Engine {
199
228
  private push;
200
229
  stop(): void;
201
230
  replace(tracks: Track[], root: string): void;
231
+ /**
232
+ * Load something as well as what is already here.
233
+ *
234
+ * Adding a folder used to be `replace`, so pointing a server at an album on
235
+ * the web threw away the music on its disk: the playlist you were looking at
236
+ * turned into somebody else's twenty-eight tracks, and clicking your own
237
+ * files played theirs. Nothing about playback changes here -- whatever was
238
+ * playing keeps playing, at the same index, because the new tracks go on the
239
+ * end.
240
+ *
241
+ * Paths already loaded are skipped, so adding the same album twice is not
242
+ * two copies of it.
243
+ */
244
+ add(tracks: Track[], from: string): number;
245
+ /**
246
+ * Take an added source back out.
247
+ *
248
+ * The track that is playing is followed rather than an index: removing an
249
+ * album from above the current track would otherwise slide the playlist out
250
+ * from under a listener mid-song. If the playing track is itself in what is
251
+ * being removed, playback stops -- there is nothing to keep playing.
252
+ */
253
+ drop(group: string): number;
254
+ groups(): string[];
202
255
  retag(tracks: Track[], root: string): void;
203
256
  }
204
257
  /** An engine with no library behind it, for the hosted PWA. */
@@ -210,6 +263,9 @@ export declare class EmptyEngine implements Engine {
210
263
  subscribe(listener: (snapshot: Snapshot) => void): () => void;
211
264
  trackPath(): undefined;
212
265
  replace(): void;
266
+ add(): number;
267
+ drop(): number;
268
+ groups(): string[];
213
269
  retag(): void;
214
270
  stop(): void;
215
271
  }
package/dist/server.js CHANGED
@@ -39,7 +39,7 @@ import { Durable } from "./durable.js";
39
39
  import { notifyAll, resendEmail, webPush } from "./notify.js";
40
40
  import { confirm, DEFAULT_DIRECTORY, Publisher } from "./publish.js";
41
41
  import { applyRemoteConfig, createPaywall, FREE_LISTENERS, paywallFromEnv, } from "./paywall.js";
42
- import { isRemote, playsInBrowser } from "./sources.js";
42
+ import { isRemote, playsInBrowser, sourceLabel } from "./sources.js";
43
43
  import { codecsOf, videoArgs } from "./audio.js";
44
44
  import { allowedForListening, elevate, firewallInUse, keyCookie, keyFrom, lookupPublicIp, newKey, portCommands, reachableAddresses, scopeOf, shareLink, audioLink, } from "./share.js";
45
45
  import { extname, join, normalize, resolve, sep } from "node:path";
@@ -293,6 +293,9 @@ export function toRemoteTracks(tracks) {
293
293
  // every track to the audio element -- a film's soundtrack over a blank
294
294
  // panel, which is exactly what it looked like.
295
295
  ...(hasPicture(t.path) ? { video: true } : {}),
296
+ // Only for what was added; the library's own tracks say nothing, which is
297
+ // how a client knows they are the library.
298
+ ...(t.group ? { group: t.group } : {}),
296
299
  }));
297
300
  }
298
301
  /** Video containers, as opposed to the songs that are most of a library. */
@@ -501,15 +504,89 @@ export class PlayerEngine {
501
504
  this.state.note = "";
502
505
  this.push(true);
503
506
  }
507
+ /**
508
+ * Load something as well as what is already here.
509
+ *
510
+ * Adding a folder used to be `replace`, so pointing a server at an album on
511
+ * the web threw away the music on its disk: the playlist you were looking at
512
+ * turned into somebody else's twenty-eight tracks, and clicking your own
513
+ * files played theirs. Nothing about playback changes here -- whatever was
514
+ * playing keeps playing, at the same index, because the new tracks go on the
515
+ * end.
516
+ *
517
+ * Paths already loaded are skipped, so adding the same album twice is not
518
+ * two copies of it.
519
+ */
520
+ add(tracks, from) {
521
+ const group = sourceLabel(from);
522
+ const known = new Set(this.tracks.map((track) => track.path));
523
+ const fresh = tracks.filter((track) => !known.has(track.path)).map((track) => ({ ...track, group }));
524
+ if (fresh.length === 0)
525
+ return 0;
526
+ this.tracks = [...this.tracks, ...fresh];
527
+ // The list itself changed, so it has to ride this frame; a count nobody
528
+ // can index into is worse than no news at all.
529
+ this.push(true);
530
+ return fresh.length;
531
+ }
532
+ /**
533
+ * Take an added source back out.
534
+ *
535
+ * The track that is playing is followed rather than an index: removing an
536
+ * album from above the current track would otherwise slide the playlist out
537
+ * from under a listener mid-song. If the playing track is itself in what is
538
+ * being removed, playback stops -- there is nothing to keep playing.
539
+ */
540
+ drop(group) {
541
+ if (group === "")
542
+ return 0;
543
+ const playingPath = this.tracks[this.state.index]?.path;
544
+ const kept = this.tracks.filter((track) => track.group !== group);
545
+ const removed = this.tracks.length - kept.length;
546
+ if (removed === 0)
547
+ return 0;
548
+ this.tracks = kept;
549
+ const stillThere = kept.findIndex((track) => track.path === playingPath);
550
+ if (stillThere === -1) {
551
+ this.halt();
552
+ this.state.index = this.clamp(this.state.index);
553
+ }
554
+ else {
555
+ this.state.index = stillThere;
556
+ }
557
+ this.push(true);
558
+ return removed;
559
+ }
560
+ groups() {
561
+ const seen = [];
562
+ for (const track of this.tracks) {
563
+ if (track.group && !seen.includes(track.group))
564
+ seen.push(track.group);
565
+ }
566
+ return seen;
567
+ }
504
568
  retag(tracks, root) {
505
- // Dropped rather than applied if the library moved underneath: somebody
506
- // re-streamed while the tagging was still running, and these tags describe
507
- // something nobody is playing any more.
508
- if (root !== this.root || tracks.length !== this.tracks.length)
509
- return;
510
- if (tracks.some((track, at) => track.path !== this.tracks[at]?.path))
569
+ // Matched by path rather than by position, because the list is no longer
570
+ // required to be the one that was sent for tagging: somebody can add an
571
+ // album while a library's tags are still being read, and an exact-shape
572
+ // check would throw away every tag for it. Tags that describe tracks which
573
+ // are no longer here simply match nothing, which is the same protection
574
+ // the shape check was giving.
575
+ void root;
576
+ const byPath = new Map(tracks.map((track) => [track.path, track]));
577
+ let changed = false;
578
+ const merged = this.tracks.map((track) => {
579
+ const tagged = byPath.get(track.path);
580
+ if (!tagged || tagged === track)
581
+ return track;
582
+ changed = true;
583
+ // The group is ours, not the tagger's: it knows what a track is called,
584
+ // not which pile it is in.
585
+ return { ...tagged, ...(track.group ? { group: track.group } : {}) };
586
+ });
587
+ if (!changed)
511
588
  return;
512
- this.tracks = tracks;
589
+ this.tracks = merged;
513
590
  // No stop, no index reset: the only thing that changes is what the titles
514
591
  // say, and every remote finds out because a snapshot goes out -- carrying
515
592
  // the list, since the titles are the whole point of this one.
@@ -534,6 +611,15 @@ export class EmptyEngine {
534
611
  return undefined;
535
612
  }
536
613
  replace() { }
614
+ add() {
615
+ return 0;
616
+ }
617
+ drop() {
618
+ return 0;
619
+ }
620
+ groups() {
621
+ return [];
622
+ }
537
623
  retag() { }
538
624
  stop() { }
539
625
  }
@@ -845,7 +931,18 @@ export function createHandler(engine, options) {
845
931
  path !== "/api/health" &&
846
932
  path !== "/api/directory" &&
847
933
  !isSignInPath(path)) {
848
- const scope = scopeOf(keyFrom(request, url), key, listenKey);
934
+ let scope = scopeOf(keyFrom(request, url), key, listenKey);
935
+ // A key is how somebody who was invited proves it. It is not the only way
936
+ // to be allowed in: the person who owns this server is allowed in whether
937
+ // or not they still have the link, and their nixamp.com session says who
938
+ // they are. Without this, signing in as yourself and opening your own
939
+ // server was refused, and the address of a machine you administer was
940
+ // useless without a link you had to go and find.
941
+ if (scope === null && options.owner) {
942
+ const check = await options.owner.check(false, tokenFrom(request.headers));
943
+ if (check.allowed)
944
+ scope = "control";
945
+ }
849
946
  if (scope === null) {
850
947
  // Counted, not because a 128-bit key falls to guessing, but because
851
948
  // somebody hammering one should stop costing this server anything.
@@ -856,7 +953,9 @@ export function createHandler(engine, options) {
856
953
  response.end(JSON.stringify({ error: "too many attempts; wait a moment" }));
857
954
  return;
858
955
  }
859
- json(response, 401, { error: "this nixamp needs the key from its share link" });
956
+ json(response, 401, {
957
+ error: "this nixamp needs the key from its share link, or sign in as its owner",
958
+ });
860
959
  return;
861
960
  }
862
961
  if (scope === "listen" && !allowedForListening(path)) {
@@ -1719,16 +1818,50 @@ export function createHandler(engine, options) {
1719
1818
  json(response, 200, engine.snapshot());
1720
1819
  return;
1721
1820
  }
1722
- // Re-stream: hand the running server a different source. The listeners
1723
- // stay connected; what they are listening to changes under them.
1821
+ // Take an added source back out of the playlist. The library it was added
1822
+ // to is untouched -- there is no group name that names it.
1823
+ if (path === "/api/source/remove") {
1824
+ if (request.method !== "POST") {
1825
+ json(response, 405, { error: "POST only" });
1826
+ return;
1827
+ }
1828
+ let group = "";
1829
+ try {
1830
+ group = String(JSON.parse(await readBody(request)).group ?? "");
1831
+ }
1832
+ catch {
1833
+ json(response, 400, { error: "bad JSON" });
1834
+ return;
1835
+ }
1836
+ if (!group) {
1837
+ json(response, 400, { error: "no group given" });
1838
+ return;
1839
+ }
1840
+ const removed = engine.drop(group);
1841
+ if (removed === 0) {
1842
+ json(response, 404, { error: `nothing here came from ${group}` });
1843
+ return;
1844
+ }
1845
+ json(response, 200, { ...engine.snapshot(), removed, groups: engine.groups() });
1846
+ return;
1847
+ }
1848
+ // Hand the running server another source. The listeners stay connected;
1849
+ // by default they get more to listen to, and only an explicit `replace`
1850
+ // swaps what this server is for something else.
1724
1851
  if (path === "/api/source") {
1725
1852
  if (request.method !== "POST") {
1726
1853
  json(response, 405, { error: "POST only" });
1727
1854
  return;
1728
1855
  }
1729
1856
  let source = "";
1857
+ let replacing = false;
1730
1858
  try {
1731
- source = String(JSON.parse(await readBody(request)).source ?? "");
1859
+ const body = JSON.parse(await readBody(request));
1860
+ source = String(body.source ?? "");
1861
+ // Adding is what somebody means by putting a folder in a box, so it is
1862
+ // the default. Replacing is the much larger claim that this server now
1863
+ // serves that instead, so it is the one you have to ask for.
1864
+ replacing = body.replace === true;
1732
1865
  }
1733
1866
  catch {
1734
1867
  json(response, 400, { error: "bad JSON" });
@@ -1744,8 +1877,21 @@ export function createHandler(engine, options) {
1744
1877
  json(response, 422, { error: `nothing to play at ${source}` });
1745
1878
  return;
1746
1879
  }
1747
- engine.replace(tracks, source);
1748
- // Names now, tags later, here as much as at startup: re-streaming a
1880
+ let added = tracks.length;
1881
+ if (replacing) {
1882
+ engine.replace(tracks, source);
1883
+ }
1884
+ else {
1885
+ added = engine.add(tracks, source);
1886
+ if (added === 0) {
1887
+ // Everything there was already here. Not an error -- the playlist
1888
+ // is exactly what the caller asked for -- but worth saying, so a
1889
+ // client can tell that apart from having added an album.
1890
+ json(response, 200, { ...engine.snapshot(), added: 0, groups: engine.groups() });
1891
+ return;
1892
+ }
1893
+ }
1894
+ // Names now, tags later, here as much as at startup: loading a
1749
1895
  // directory of five thousand files used to read every tag before it
1750
1896
  // answered, with the event loop held the whole time.
1751
1897
  if (options.tag) {
@@ -1754,7 +1900,7 @@ export function createHandler(engine, options) {
1754
1900
  .then((tagged) => engine.retag(tagged, source))
1755
1901
  .catch(() => { });
1756
1902
  }
1757
- json(response, 200, engine.snapshot());
1903
+ json(response, 200, { ...engine.snapshot(), added, replaced: replacing, groups: engine.groups() });
1758
1904
  }
1759
1905
  catch (error) {
1760
1906
  json(response, 422, { error: error.message.replace(/^nixamp: /, "") });
package/dist/share.js CHANGED
@@ -180,7 +180,12 @@ export function scopeOf(offered, control, listen) {
180
180
  }
181
181
  /** Paths a listen key may have. Everything else needs the control key. */
182
182
  export function allowedForListening(path) {
183
- if (path === "/api/command" || path === "/api/source")
183
+ if (path === "/api/command")
184
+ return false;
185
+ // Prefix, not equality: everything under this changes what the server plays,
186
+ // and an exact check let a listen key reach /api/source/remove and delete an
187
+ // album out of somebody else's playlist.
188
+ if (path === "/api/source" || path.startsWith("/api/source/"))
184
189
  return false;
185
190
  return true;
186
191
  }
package/dist/sources.d.ts CHANGED
@@ -33,5 +33,14 @@ export declare function parseM3u(text: string, base: string): Entry[];
33
33
  /** A .pls, which Shoutcast and Icecast hand out as often as an .m3u. */
34
34
  export declare function parsePls(text: string, base: string): Entry[];
35
35
  /** The last useful part of a path or URL, for when nothing named the track. */
36
+ /**
37
+ * What to call a whole source, as a heading over the tracks it brought.
38
+ *
39
+ * `nameOf` answers for a file; this answers for the thing a person added --
40
+ * usually the last segment either way, but a URL that is only a host has no
41
+ * segment to take, and "the album at that address" reads better as the host
42
+ * than as the whole URL repeated over every row.
43
+ */
44
+ export declare function sourceLabel(source: string): string;
36
45
  export declare function nameOf(source: string): string;
37
46
  export declare function playsInBrowser(source: string): boolean;
package/dist/sources.js CHANGED
@@ -94,6 +94,30 @@ export function parsePls(text, base) {
94
94
  });
95
95
  }
96
96
  /** The last useful part of a path or URL, for when nothing named the track. */
97
+ /**
98
+ * What to call a whole source, as a heading over the tracks it brought.
99
+ *
100
+ * `nameOf` answers for a file; this answers for the thing a person added --
101
+ * usually the last segment either way, but a URL that is only a host has no
102
+ * segment to take, and "the album at that address" reads better as the host
103
+ * than as the whole URL repeated over every row.
104
+ */
105
+ export function sourceLabel(source) {
106
+ const trimmed = source.replace(/\/+$/, "");
107
+ if (trimmed === "")
108
+ return source;
109
+ const named = nameOf(trimmed);
110
+ if (named !== trimmed && named !== "")
111
+ return named;
112
+ if (!isRemote(trimmed))
113
+ return trimmed;
114
+ try {
115
+ return new URL(trimmed).host;
116
+ }
117
+ catch {
118
+ return trimmed;
119
+ }
120
+ }
97
121
  export function nameOf(source) {
98
122
  const remote = isRemote(source);
99
123
  const path = remote ? new URL(source).pathname : source;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.7.4",
3
+ "version": "0.7.5",
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",