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 +8 -2
- package/dist/admin.d.ts +2 -0
- package/dist/admin.js +32 -8
- package/dist/protocol.d.ts +8 -0
- package/dist/server.d.ts +61 -5
- package/dist/server.js +162 -16
- package/dist/share.js +6 -1
- package/dist/sources.d.ts +9 -0
- package/dist/sources.js +24 -0
- package/package.json +1 -1
- package/src/admin.ts +35 -10
- package/src/protocol.ts +8 -0
- package/src/server.ts +190 -20
- package/src/share.ts +5 -1
- package/src/sources.ts +21 -0
- package/web/dist/assets/{hls-3VKVEQE3-BirljKil.js → hls-3VKVEQE3-BsLZl7PK.js} +1 -1
- package/web/dist/assets/index-8D8K4vqw.css +1 -0
- package/web/dist/assets/index-a1aekKkP.js +1 -0
- package/web/dist/assets/{mpegts-Dd6YyA19.js → mpegts-DWMPccwQ.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-Cl9mowBJ.js → mpegts-LO6RVLD6-CviHHfb1.js} +1 -1
- package/web/dist/index.html +17 -7
- package/web/dist/sw.js +6 -6
- package/web/dist/assets/index-DSIDSSPF.css +0 -1
- package/web/dist/assets/index-U2odRmpd.js +0 -1
package/README.md
CHANGED
|
@@ -354,8 +354,14 @@ anywhere else with `--url` and `--key`.
|
|
|
354
354
|
╰───────────────────────────────────────────────────────────────────────╯
|
|
355
355
|
```
|
|
356
356
|
|
|
357
|
-
Press `
|
|
358
|
-
|
|
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
|
-
/**
|
|
165
|
-
|
|
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({
|
|
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(
|
|
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: "
|
|
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" }],
|
package/dist/protocol.d.ts
CHANGED
|
@@ -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
|
|
137
|
-
*
|
|
138
|
-
*
|
|
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:
|
|
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:
|
|
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
|
-
//
|
|
506
|
-
//
|
|
507
|
-
//
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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 =
|
|
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
|
-
|
|
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, {
|
|
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
|
-
//
|
|
1723
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
1748
|
-
|
|
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"
|
|
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;
|