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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plexsonic",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "PlexMusic to OpenSubsonic bridge",
5
5
  "main": "./src/index.js",
6
6
  "bin": {
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
- ${notice ? `<div class="notice">${escapeHtml(notice)}</div>` : ''}
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
- out.textContent = formatResponse(text);
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) {