nixamp 0.7.3 → 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 +39 -10
- package/dist/daemon.d.ts +10 -0
- package/dist/daemon.js +30 -1
- 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 +41 -12
- package/src/daemon.ts +31 -1
- 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
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* or against a nixamp on a different machine entirely.
|
|
7
7
|
*/
|
|
8
8
|
import { createApp, themes } from "@profullstack/hqtui";
|
|
9
|
-
import { daemonUrl, readState } from "./daemon.js";
|
|
9
|
+
import { daemonUrl, isLoopbackTls, readState } from "./daemon.js";
|
|
10
10
|
import { KEY_HEADER } from "./share.js";
|
|
11
11
|
/** Where to point, from the flags or from the daemon that is running. */
|
|
12
12
|
export function resolveTarget(argv) {
|
|
@@ -23,8 +23,13 @@ export function resolveTarget(argv) {
|
|
|
23
23
|
if (state === null) {
|
|
24
24
|
throw new Error("nixamp: no daemon is running. Start one with `nixamp daemon start`, or pass --url.");
|
|
25
25
|
}
|
|
26
|
+
const url_ = daemonUrl(state);
|
|
27
|
+
// Talking to our own daemon, whose certificate names somewhere else. Nothing
|
|
28
|
+
// is in the way on loopback, so there is nothing for a certificate to prove.
|
|
29
|
+
if (isLoopbackTls(url_))
|
|
30
|
+
process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = "0";
|
|
26
31
|
return {
|
|
27
|
-
url:
|
|
32
|
+
url: url_,
|
|
28
33
|
key: key ?? state.key,
|
|
29
34
|
// A state file written before 0.5.3 has no list; loopback stands in.
|
|
30
35
|
links: state.urls ?? [{ label: "here", url: daemonUrl(state) }],
|
|
@@ -105,6 +110,9 @@ export async function admin(argv) {
|
|
|
105
110
|
let snapshot = null;
|
|
106
111
|
let error = "";
|
|
107
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;
|
|
108
116
|
let typing = false;
|
|
109
117
|
const app = await createApp({ theme: themes.matrix, title: "nixamp admin", quitKeys: ["ctrl+c"] });
|
|
110
118
|
const refresh = async () => {
|
|
@@ -124,13 +132,16 @@ export async function admin(argv) {
|
|
|
124
132
|
if (key === "escape") {
|
|
125
133
|
typing = false;
|
|
126
134
|
restreaming = "";
|
|
135
|
+
replacing = false;
|
|
127
136
|
}
|
|
128
137
|
else if (key === "enter") {
|
|
129
138
|
const url = restreaming.trim();
|
|
139
|
+
const asReplacement = replacing;
|
|
130
140
|
typing = false;
|
|
131
141
|
restreaming = "";
|
|
142
|
+
replacing = false;
|
|
132
143
|
if (url)
|
|
133
|
-
void restream(target, headers, url).then(() => refresh());
|
|
144
|
+
void restream(target, headers, url, asReplacement).then(() => refresh());
|
|
134
145
|
}
|
|
135
146
|
else if (key === "backspace")
|
|
136
147
|
restreaming = restreaming.slice(0, -1);
|
|
@@ -143,26 +154,38 @@ export async function admin(argv) {
|
|
|
143
154
|
app.quit();
|
|
144
155
|
return;
|
|
145
156
|
}
|
|
157
|
+
if (key === "a") {
|
|
158
|
+
typing = true;
|
|
159
|
+
replacing = false;
|
|
160
|
+
app.invalidate();
|
|
161
|
+
}
|
|
146
162
|
if (key === "r") {
|
|
147
163
|
typing = true;
|
|
164
|
+
replacing = true;
|
|
148
165
|
app.invalidate();
|
|
149
166
|
}
|
|
150
167
|
});
|
|
151
168
|
app.on("exit", () => clearInterval(timer));
|
|
152
169
|
app.render(({ ui, theme }) => draw(ui, theme, {
|
|
153
|
-
url: target.url, report, snapshot, error, typing, restreaming,
|
|
170
|
+
url: target.url, report, snapshot, error, typing, restreaming, replacing,
|
|
154
171
|
links: target.links, key: target.key, source: target.source,
|
|
155
172
|
}));
|
|
156
173
|
await app.start();
|
|
157
174
|
clearInterval(timer);
|
|
158
175
|
}
|
|
159
|
-
/**
|
|
160
|
-
|
|
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) {
|
|
161
184
|
try {
|
|
162
185
|
await fetch(`${target.url}/api/source`, {
|
|
163
186
|
method: "POST",
|
|
164
187
|
headers: { ...headers, "content-type": "application/json" },
|
|
165
|
-
body: JSON.stringify({ source: url }),
|
|
188
|
+
body: JSON.stringify({ source: url, ...(replacing ? { replace: true } : {}) }),
|
|
166
189
|
});
|
|
167
190
|
}
|
|
168
191
|
catch {
|
|
@@ -244,14 +267,20 @@ export function draw(ui, theme, view) {
|
|
|
244
267
|
});
|
|
245
268
|
});
|
|
246
269
|
if (view.typing) {
|
|
247
|
-
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) => {
|
|
248
274
|
p.text(`${view.restreaming}_`, { fg: theme.accent });
|
|
249
|
-
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.");
|
|
250
278
|
});
|
|
251
279
|
}
|
|
252
280
|
ui.statusBar({
|
|
253
281
|
items: [
|
|
254
|
-
{ key: "
|
|
282
|
+
{ key: "a", label: "Add" },
|
|
283
|
+
{ key: "r", label: "Replace" },
|
|
255
284
|
{ key: "q", label: "Quit" },
|
|
256
285
|
],
|
|
257
286
|
right: [{ key: "", label: report ? `${report.connections.length} seen` : "connecting" }],
|
package/dist/daemon.d.ts
CHANGED
|
@@ -48,6 +48,16 @@ export declare function status(): {
|
|
|
48
48
|
};
|
|
49
49
|
/** The URL an admin client should talk to. */
|
|
50
50
|
export declare function daemonUrl(state: DaemonState): string;
|
|
51
|
+
/**
|
|
52
|
+
* Whether this address is our own machine over TLS with a certificate that
|
|
53
|
+
* cannot possibly name it.
|
|
54
|
+
*
|
|
55
|
+
* A certificate proves you reached the host you asked for. Asking for loopback
|
|
56
|
+
* proves that already: nothing is in the way to impersonate. So a daemon with a
|
|
57
|
+
* certificate for some public name is still reachable at 127.0.0.1, and a
|
|
58
|
+
* client refusing to talk to it is protecting nobody from anything.
|
|
59
|
+
*/
|
|
60
|
+
export declare function isLoopbackTls(url: string): boolean;
|
|
51
61
|
/**
|
|
52
62
|
* What `nixamp daemon start` and `nixamp daemon status` print, as lines, so it
|
|
53
63
|
* can be tested without starting a daemon.
|
package/dist/daemon.js
CHANGED
|
@@ -61,8 +61,37 @@ export function status() {
|
|
|
61
61
|
}
|
|
62
62
|
/** The URL an admin client should talk to. */
|
|
63
63
|
export function daemonUrl(state) {
|
|
64
|
+
// A daemon serving https has a certificate for a name, and loopback is not
|
|
65
|
+
// that name. Asking it for https://localhost fails verification even though
|
|
66
|
+
// it is the same process on the same machine, which is how turning TLS on
|
|
67
|
+
// silently broke `nixamp admin` and `nixamp attach`. Prefer the address it
|
|
68
|
+
// was told to publish, which is the one the certificate is actually for.
|
|
69
|
+
const told = state.urls?.find((entry) => entry.label === "on the internet" && entry.url.startsWith("https://"));
|
|
70
|
+
if (told)
|
|
71
|
+
return told.url;
|
|
72
|
+
const scheme = state.urls?.[0]?.url.startsWith("https://") ? "https" : "http";
|
|
64
73
|
const host = state.host === "0.0.0.0" || state.host === "::" ? "127.0.0.1" : state.host;
|
|
65
|
-
return
|
|
74
|
+
return `${scheme}://${host.includes(":") ? `[${host}]` : host}:${state.port}`;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Whether this address is our own machine over TLS with a certificate that
|
|
78
|
+
* cannot possibly name it.
|
|
79
|
+
*
|
|
80
|
+
* A certificate proves you reached the host you asked for. Asking for loopback
|
|
81
|
+
* proves that already: nothing is in the way to impersonate. So a daemon with a
|
|
82
|
+
* certificate for some public name is still reachable at 127.0.0.1, and a
|
|
83
|
+
* client refusing to talk to it is protecting nobody from anything.
|
|
84
|
+
*/
|
|
85
|
+
export function isLoopbackTls(url) {
|
|
86
|
+
try {
|
|
87
|
+
const parsed = new URL(url);
|
|
88
|
+
if (parsed.protocol !== "https:")
|
|
89
|
+
return false;
|
|
90
|
+
return parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname === "::1";
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
66
95
|
}
|
|
67
96
|
/**
|
|
68
97
|
* How long it has been up, as a person would say it. Written here rather than
|
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;
|