plexsonic 0.1.10 → 0.1.11
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/package.json +1 -1
- package/src/html.js +35 -3
- package/src/server.js +128 -0
package/package.json
CHANGED
package/src/html.js
CHANGED
|
@@ -71,6 +71,8 @@ export function pageTemplate({ title, body, notice = '' }) {
|
|
|
71
71
|
--primary-hover: #1d4ed8;
|
|
72
72
|
--danger: #dc2626;
|
|
73
73
|
--danger-bg: #fef2f2;
|
|
74
|
+
--success: #059669;
|
|
75
|
+
--success-bg: #ecfdf5;
|
|
74
76
|
--border: #e5e7eb;
|
|
75
77
|
--font-sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
76
78
|
}
|
|
@@ -128,6 +130,12 @@ export function pageTemplate({ title, body, notice = '' }) {
|
|
|
128
130
|
text-align: center;
|
|
129
131
|
}
|
|
130
132
|
|
|
133
|
+
.notice.success {
|
|
134
|
+
background: var(--success-bg);
|
|
135
|
+
color: var(--success);
|
|
136
|
+
border-color: var(--success);
|
|
137
|
+
}
|
|
138
|
+
|
|
131
139
|
form {
|
|
132
140
|
display: flex;
|
|
133
141
|
flex-direction: column;
|
|
@@ -257,6 +265,12 @@ export function pageTemplate({ title, body, notice = '' }) {
|
|
|
257
265
|
word-break: break-all;
|
|
258
266
|
}
|
|
259
267
|
|
|
268
|
+
.status.success {
|
|
269
|
+
color: var(--success);
|
|
270
|
+
background: var(--success-bg);
|
|
271
|
+
border-color: var(--success);
|
|
272
|
+
}
|
|
273
|
+
|
|
260
274
|
.test-output {
|
|
261
275
|
white-space: pre-wrap;
|
|
262
276
|
overflow: auto;
|
|
@@ -279,7 +293,11 @@ export function pageTemplate({ title, body, notice = '' }) {
|
|
|
279
293
|
</head>
|
|
280
294
|
<body>
|
|
281
295
|
<main>
|
|
282
|
-
${
|
|
296
|
+
${(() => {
|
|
297
|
+
if (!notice) return '';
|
|
298
|
+
const isSuccess = /successfully|linked|connected|verified|saved|complete|updated|signed out|unlinked/i.test(notice);
|
|
299
|
+
return `<div class="notice ${isSuccess ? 'success' : ''}">${escapeHtml(notice)}</div>`;
|
|
300
|
+
})()}
|
|
283
301
|
${body}
|
|
284
302
|
</main>
|
|
285
303
|
</body>
|
|
@@ -379,7 +397,7 @@ export function linkedPlexPage({
|
|
|
379
397
|
}) {
|
|
380
398
|
const statusLines = [
|
|
381
399
|
`<p><strong>User:</strong> ${escapeHtml(username)}</p>`,
|
|
382
|
-
`<p><strong>Plex link:</strong> Connected</p>`,
|
|
400
|
+
`<p><strong>Plex link:</strong> <span style="color: var(--success); font-weight: 600;">Connected</span></p>`,
|
|
383
401
|
`<p><strong>Server:</strong> ${serverName ? escapeHtml(serverName) : 'Not selected yet'}</p>`,
|
|
384
402
|
`<p><strong>Music library:</strong> ${libraryName ? escapeHtml(libraryName) : 'Not selected yet'}</p>`,
|
|
385
403
|
].join('');
|
|
@@ -479,6 +497,7 @@ export function plexPinPage({ authUrl, sid, phase }) {
|
|
|
479
497
|
if (!closed) {
|
|
480
498
|
hintEl.textContent = 'Plex linked. You can close this page now.';
|
|
481
499
|
statusEl.textContent = 'Plex authorization completed.';
|
|
500
|
+
statusEl.classList.add('success');
|
|
482
501
|
manualLinkEl.style.display = 'none';
|
|
483
502
|
}
|
|
484
503
|
}
|
|
@@ -489,6 +508,7 @@ export function plexPinPage({ authUrl, sid, phase }) {
|
|
|
489
508
|
const data = await res.json();
|
|
490
509
|
if (data.status === 'linked') {
|
|
491
510
|
statusEl.textContent = 'Plex linked. Attempting to close...';
|
|
511
|
+
statusEl.classList.add('success');
|
|
492
512
|
closeOrShowMessage(data.next || '/link/plex/server');
|
|
493
513
|
return;
|
|
494
514
|
}
|
|
@@ -629,9 +649,21 @@ export function testPage({ username }) {
|
|
|
629
649
|
},
|
|
630
650
|
});
|
|
631
651
|
const text = await res.text();
|
|
632
|
-
|
|
652
|
+
const formatted = formatResponse(text);
|
|
653
|
+
out.textContent = formatted;
|
|
654
|
+
try {
|
|
655
|
+
const data = JSON.parse(text);
|
|
656
|
+
if (data?.['subsonic-response']?.status === 'ok') {
|
|
657
|
+
out.classList.add('success');
|
|
658
|
+
} else {
|
|
659
|
+
out.classList.remove('success');
|
|
660
|
+
}
|
|
661
|
+
} catch {
|
|
662
|
+
out.classList.remove('success');
|
|
663
|
+
}
|
|
633
664
|
} catch (err) {
|
|
634
665
|
out.textContent = 'Request failed: ' + err.message;
|
|
666
|
+
out.classList.remove('success');
|
|
635
667
|
}
|
|
636
668
|
}
|
|
637
669
|
|
package/src/server.js
CHANGED
|
@@ -7150,6 +7150,134 @@ export async function buildServer(config = loadConfig()) {
|
|
|
7150
7150
|
}
|
|
7151
7151
|
});
|
|
7152
7152
|
|
|
7153
|
+
app.get('/rest/download.view', async (request, reply) => {
|
|
7154
|
+
const account = await authenticateSubsonicRequest(request, reply, repo, tokenCipher);
|
|
7155
|
+
if (!account) {
|
|
7156
|
+
return;
|
|
7157
|
+
}
|
|
7158
|
+
|
|
7159
|
+
const trackId = getQueryString(request, 'id');
|
|
7160
|
+
if (!trackId) {
|
|
7161
|
+
return sendSubsonicError(reply, 70, 'Missing track id');
|
|
7162
|
+
}
|
|
7163
|
+
|
|
7164
|
+
const context = repo.getAccountPlexContext(account.id);
|
|
7165
|
+
const plexState = requiredPlexStateForSubsonic(reply, context, tokenCipher);
|
|
7166
|
+
if (!plexState) {
|
|
7167
|
+
return;
|
|
7168
|
+
}
|
|
7169
|
+
|
|
7170
|
+
try {
|
|
7171
|
+
const track = await getTrack({
|
|
7172
|
+
baseUrl: plexState.baseUrl,
|
|
7173
|
+
plexToken: plexState.plexToken,
|
|
7174
|
+
trackId,
|
|
7175
|
+
});
|
|
7176
|
+
|
|
7177
|
+
if (!track) {
|
|
7178
|
+
return sendSubsonicError(reply, 70, 'Track not found');
|
|
7179
|
+
}
|
|
7180
|
+
|
|
7181
|
+
const part = partFromTrack(track);
|
|
7182
|
+
const partKey = part?.key;
|
|
7183
|
+
|
|
7184
|
+
if (!partKey) {
|
|
7185
|
+
return sendSubsonicError(reply, 70, 'Track has no downloadable part');
|
|
7186
|
+
}
|
|
7187
|
+
|
|
7188
|
+
const streamUrl = buildPmsAssetUrl(plexState.baseUrl, plexState.plexToken, partKey);
|
|
7189
|
+
const rangeHeader = request.headers.range;
|
|
7190
|
+
const upstreamController = new AbortController();
|
|
7191
|
+
const abortUpstreamOnDisconnect = () => {
|
|
7192
|
+
if (!upstreamController.signal.aborted) {
|
|
7193
|
+
upstreamController.abort();
|
|
7194
|
+
}
|
|
7195
|
+
};
|
|
7196
|
+
request.raw.once('aborted', abortUpstreamOnDisconnect);
|
|
7197
|
+
request.raw.once('close', abortUpstreamOnDisconnect);
|
|
7198
|
+
reply.raw.once('close', abortUpstreamOnDisconnect);
|
|
7199
|
+
|
|
7200
|
+
const upstream = await fetchWithRetry({
|
|
7201
|
+
url: streamUrl,
|
|
7202
|
+
options: {
|
|
7203
|
+
headers: {
|
|
7204
|
+
...(rangeHeader ? { Range: rangeHeader } : {}),
|
|
7205
|
+
},
|
|
7206
|
+
signal: upstreamController.signal,
|
|
7207
|
+
},
|
|
7208
|
+
request,
|
|
7209
|
+
context: 'track download proxy',
|
|
7210
|
+
maxAttempts: 3,
|
|
7211
|
+
baseDelayMs: 250,
|
|
7212
|
+
});
|
|
7213
|
+
|
|
7214
|
+
if (!upstream.ok || !upstream.body) {
|
|
7215
|
+
request.log.warn({ status: upstream.status }, 'Failed to proxy track download');
|
|
7216
|
+
return sendSubsonicError(reply, 70, 'Track download unavailable');
|
|
7217
|
+
}
|
|
7218
|
+
|
|
7219
|
+
reply.code(upstream.status);
|
|
7220
|
+
|
|
7221
|
+
for (const headerName of [
|
|
7222
|
+
'content-type',
|
|
7223
|
+
'content-length',
|
|
7224
|
+
'content-range',
|
|
7225
|
+
'accept-ranges',
|
|
7226
|
+
'etag',
|
|
7227
|
+
'last-modified',
|
|
7228
|
+
]) {
|
|
7229
|
+
const value = upstream.headers.get(headerName);
|
|
7230
|
+
if (value) {
|
|
7231
|
+
reply.header(headerName, value);
|
|
7232
|
+
}
|
|
7233
|
+
}
|
|
7234
|
+
|
|
7235
|
+
const fileName = part?.file ? part.file.split(/[/\\]/).pop() : null;
|
|
7236
|
+
if (fileName) {
|
|
7237
|
+
reply.header(
|
|
7238
|
+
'content-disposition',
|
|
7239
|
+
`attachment; filename*=UTF-8''${encodeURIComponent(fileName)}`,
|
|
7240
|
+
);
|
|
7241
|
+
}
|
|
7242
|
+
|
|
7243
|
+
const proxiedBody = Readable.fromWeb(upstream.body);
|
|
7244
|
+
const responseBody = new PassThrough();
|
|
7245
|
+
|
|
7246
|
+
proxiedBody.on('error', (streamError) => {
|
|
7247
|
+
if (
|
|
7248
|
+
isAbortError(streamError) ||
|
|
7249
|
+
isUpstreamTerminationError(streamError) ||
|
|
7250
|
+
isClientDisconnected(request, reply)
|
|
7251
|
+
) {
|
|
7252
|
+
responseBody.end();
|
|
7253
|
+
return;
|
|
7254
|
+
}
|
|
7255
|
+
request.log.warn(streamError, 'Upstream stream error while proxying track download');
|
|
7256
|
+
responseBody.destroy(streamError);
|
|
7257
|
+
});
|
|
7258
|
+
|
|
7259
|
+
responseBody.on('error', (streamError) => {
|
|
7260
|
+
if (
|
|
7261
|
+
isAbortError(streamError) ||
|
|
7262
|
+
isUpstreamTerminationError(streamError) ||
|
|
7263
|
+
isClientDisconnected(request, reply)
|
|
7264
|
+
) {
|
|
7265
|
+
return;
|
|
7266
|
+
}
|
|
7267
|
+
request.log.warn(streamError, 'Response stream error while proxying track download');
|
|
7268
|
+
});
|
|
7269
|
+
|
|
7270
|
+
proxiedBody.pipe(responseBody);
|
|
7271
|
+
return reply.send(responseBody);
|
|
7272
|
+
} catch (error) {
|
|
7273
|
+
if (isAbortError(error) || isUpstreamTerminationError(error) || isClientDisconnected(request, reply)) {
|
|
7274
|
+
return;
|
|
7275
|
+
}
|
|
7276
|
+
request.log.error(error, 'Failed to proxy download');
|
|
7277
|
+
return sendSubsonicError(reply, 10, 'Download proxy failed');
|
|
7278
|
+
}
|
|
7279
|
+
});
|
|
7280
|
+
|
|
7153
7281
|
app.get('/rest/stream.view', async (request, reply) => {
|
|
7154
7282
|
const account = await authenticateSubsonicRequest(request, reply, repo, tokenCipher);
|
|
7155
7283
|
if (!account) {
|