plexsonic 0.1.9 → 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/README.md +1 -1
- package/package.json +2 -2
- package/src/html.js +35 -3
- package/src/server.js +129 -1
package/README.md
CHANGED
|
@@ -177,7 +177,7 @@ Plexsonic maps Subsonic rating + star state into a single Plex numeric rating:
|
|
|
177
177
|
|
|
178
178
|
Behavior:
|
|
179
179
|
- `setRating(r)` updates stars and keeps current like state when possible.
|
|
180
|
-
- `star` toggles like on and keeps star level. If unrated, it becomes `
|
|
180
|
+
- `star` toggles like on and keeps star level. If unrated, it becomes `10` points (liked + 5+star).
|
|
181
181
|
- `unstar` toggles like off and keeps star level.
|
|
182
182
|
|
|
183
183
|
## Expose to LAN
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "plexsonic",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.11",
|
|
4
4
|
"description": "PlexMusic to OpenSubsonic bridge",
|
|
5
5
|
"main": "./src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"@fastify/session": "^11.1.1",
|
|
26
26
|
"argon2": "^0.44.0",
|
|
27
27
|
"better-sqlite3": "^12.6.2",
|
|
28
|
-
"dotenv": "^17.
|
|
28
|
+
"dotenv": "^17.3.1",
|
|
29
29
|
"fastify": "^5.7.4",
|
|
30
30
|
"pino-pretty": "^13.1.3"
|
|
31
31
|
},
|
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
|
@@ -997,7 +997,7 @@ function subsonicRatingToPlexRating(value, { liked = false } = {}) {
|
|
|
997
997
|
function toLikedPlexRating(value) {
|
|
998
998
|
const normalized = normalizePlexRatingInt(value);
|
|
999
999
|
if (normalized == null || normalized <= 0) {
|
|
1000
|
-
return
|
|
1000
|
+
return 10;
|
|
1001
1001
|
}
|
|
1002
1002
|
const stars = Math.max(1, Math.min(5, Math.ceil(normalized / 2)));
|
|
1003
1003
|
return stars * 2;
|
|
@@ -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) {
|