plexsonic 0.1.10 → 0.1.12
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 +412 -164
- package/src/subsonic-xml.js +24 -65
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
|
@@ -197,6 +197,10 @@ function parsePlexWebhookPayload(body) {
|
|
|
197
197
|
if (!value || typeof value !== 'string') {
|
|
198
198
|
return null;
|
|
199
199
|
}
|
|
200
|
+
if (!/^\d+$/.test(albumId)) {
|
|
201
|
+
return sendSubsonicOk(reply, node('album'));
|
|
202
|
+
}
|
|
203
|
+
|
|
200
204
|
try {
|
|
201
205
|
return JSON.parse(value);
|
|
202
206
|
} catch {
|
|
@@ -406,7 +410,7 @@ function requestPublicOrigin(request, config) {
|
|
|
406
410
|
if (refererHeader) {
|
|
407
411
|
try {
|
|
408
412
|
host = new URL(refererHeader).host || '';
|
|
409
|
-
} catch {}
|
|
413
|
+
} catch { }
|
|
410
414
|
}
|
|
411
415
|
}
|
|
412
416
|
|
|
@@ -703,7 +707,7 @@ function groupArtistsForSubsonic(artists) {
|
|
|
703
707
|
});
|
|
704
708
|
|
|
705
709
|
return keys.map((key) => {
|
|
706
|
-
const
|
|
710
|
+
const artistNodes = groups
|
|
707
711
|
.get(key)
|
|
708
712
|
.sort((a, b) => a.name.localeCompare(b.name))
|
|
709
713
|
.map((artist) =>
|
|
@@ -714,10 +718,9 @@ function groupArtistsForSubsonic(artists) {
|
|
|
714
718
|
coverArt: artist.id,
|
|
715
719
|
...subsonicRatingAttrs(artist.artist),
|
|
716
720
|
}),
|
|
717
|
-
)
|
|
718
|
-
.join('');
|
|
721
|
+
);
|
|
719
722
|
|
|
720
|
-
return node('index', { name: key },
|
|
723
|
+
return node('index', { name: key }, artistNodes);
|
|
721
724
|
});
|
|
722
725
|
}
|
|
723
726
|
|
|
@@ -752,7 +755,7 @@ function groupNamedEntriesForSubsonic(entries) {
|
|
|
752
755
|
});
|
|
753
756
|
|
|
754
757
|
return keys.map((key) => {
|
|
755
|
-
const
|
|
758
|
+
const items = groups
|
|
756
759
|
.get(key)
|
|
757
760
|
.sort((a, b) => a.name.localeCompare(b.name))
|
|
758
761
|
.map((item) =>
|
|
@@ -761,10 +764,9 @@ function groupNamedEntriesForSubsonic(entries) {
|
|
|
761
764
|
name: item.name,
|
|
762
765
|
coverArt: item.coverArt,
|
|
763
766
|
}),
|
|
764
|
-
)
|
|
765
|
-
.join('');
|
|
767
|
+
);
|
|
766
768
|
|
|
767
|
-
return node('index', { name: key },
|
|
769
|
+
return node('index', { name: key }, items);
|
|
768
770
|
});
|
|
769
771
|
}
|
|
770
772
|
|
|
@@ -1071,6 +1073,50 @@ function albumAttrs(album, fallbackArtistId = null, fallbackArtistName = null) {
|
|
|
1071
1073
|
};
|
|
1072
1074
|
}
|
|
1073
1075
|
|
|
1076
|
+
function buildArtistEntry(idCandidate, nameCandidate) {
|
|
1077
|
+
const name = firstNonEmptyText([nameCandidate], '');
|
|
1078
|
+
if (!name) {
|
|
1079
|
+
return null;
|
|
1080
|
+
}
|
|
1081
|
+
const id = String(idCandidate || '').trim();
|
|
1082
|
+
return {
|
|
1083
|
+
id: id || undefined,
|
|
1084
|
+
name,
|
|
1085
|
+
};
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
function albumArtistEntries(album, fallbackArtistId = null, fallbackArtistName = null) {
|
|
1089
|
+
const artistId = firstNonEmptyText(
|
|
1090
|
+
[album?.parentRatingKey, album?.artistId, fallbackArtistId, album?.grandparentRatingKey],
|
|
1091
|
+
'',
|
|
1092
|
+
);
|
|
1093
|
+
const artistName = firstNonEmptyText(
|
|
1094
|
+
[album?.parentTitle, album?.artist, fallbackArtistName, album?.grandparentTitle],
|
|
1095
|
+
'',
|
|
1096
|
+
);
|
|
1097
|
+
const entry = buildArtistEntry(artistId, artistName);
|
|
1098
|
+
return entry ? [entry] : [];
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
function renderArtistListNode(listName, entries, options = {}) {
|
|
1102
|
+
if (!entries || entries.length === 0) {
|
|
1103
|
+
return null;
|
|
1104
|
+
}
|
|
1105
|
+
const artistNodes = entries.map((entry) => emptyNode('artist', { id: entry.id, name: entry.name }));
|
|
1106
|
+
const attrs = options.flatten ? { flatten: 'true' } : {};
|
|
1107
|
+
return node(listName, attrs, artistNodes);
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
function albumNode(name, album, attrs, fallbackArtistId = null, fallbackArtistName = null, extra = null) {
|
|
1111
|
+
const artistEntries = albumArtistEntries(album, fallbackArtistId, fallbackArtistName);
|
|
1112
|
+
const artistsNode = renderArtistListNode('artists', artistEntries, { flatten: true });
|
|
1113
|
+
const inner = [artistsNode, extra].filter(Boolean);
|
|
1114
|
+
if (inner.length === 0) {
|
|
1115
|
+
return emptyNode(name, attrs);
|
|
1116
|
+
}
|
|
1117
|
+
return node(name, attrs, inner);
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1074
1120
|
function artistAlbumCountValue(artist) {
|
|
1075
1121
|
return parseNonNegativeInt(
|
|
1076
1122
|
artist?.albumCount ?? artist?.childCount ?? artist?.album_count ?? artist?.leafCount,
|
|
@@ -1164,7 +1210,7 @@ function songAttrs(track, albumTitle = null, albumCoverArt = null, albumMetadata
|
|
|
1164
1210
|
'Unknown Album',
|
|
1165
1211
|
);
|
|
1166
1212
|
const normalizedArtist = firstNonEmptyText(
|
|
1167
|
-
[track?.
|
|
1213
|
+
[track?.originalTitle, track?.grandparentTitle],
|
|
1168
1214
|
'Unknown Artist',
|
|
1169
1215
|
);
|
|
1170
1216
|
const coverArt = firstNonEmptyText(
|
|
@@ -1276,6 +1322,58 @@ function songAttrs(track, albumTitle = null, albumCoverArt = null, albumMetadata
|
|
|
1276
1322
|
};
|
|
1277
1323
|
}
|
|
1278
1324
|
|
|
1325
|
+
function trackArtistEntries(track) {
|
|
1326
|
+
const artistId = firstNonEmptyText(
|
|
1327
|
+
[track?.artistId, track?.guid, track?.grandparentRatingKey],
|
|
1328
|
+
'',
|
|
1329
|
+
);
|
|
1330
|
+
const artistName = firstNonEmptyText(
|
|
1331
|
+
[track?.originalTitle, track?.artist, track?.grandparentTitle],
|
|
1332
|
+
'',
|
|
1333
|
+
);
|
|
1334
|
+
const entry = buildArtistEntry(artistId, artistName);
|
|
1335
|
+
return entry ? [entry] : [];
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
function trackAlbumArtistEntries(track, albumMetadata = null) {
|
|
1339
|
+
const albumArtistId = firstNonEmptyText(
|
|
1340
|
+
[albumMetadata?.artistId, albumMetadata?.parentRatingKey, track?.artistId, track?.grandparentRatingKey],
|
|
1341
|
+
'',
|
|
1342
|
+
);
|
|
1343
|
+
const albumArtistName = firstNonEmptyText(
|
|
1344
|
+
[albumMetadata?.artist, albumMetadata?.parentTitle, track?.artist, track?.grandparentTitle],
|
|
1345
|
+
'',
|
|
1346
|
+
);
|
|
1347
|
+
const entry = buildArtistEntry(albumArtistId, albumArtistName);
|
|
1348
|
+
return entry ? [entry] : [];
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
function buildSongArtistChildren(track, albumMetadata = null) {
|
|
1352
|
+
const artistsNode = renderArtistListNode('artists', trackArtistEntries(track), { flatten: true });
|
|
1353
|
+
const albumArtistsNode = renderArtistListNode('albumArtists', trackAlbumArtistEntries(track, albumMetadata), {
|
|
1354
|
+
flatten: true,
|
|
1355
|
+
});
|
|
1356
|
+
return [artistsNode, albumArtistsNode].filter(Boolean);
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
function songNode(track, albumTitle = null, albumCoverArt = null, albumMetadata = null) {
|
|
1360
|
+
const attrs = songAttrs(track, albumTitle, albumCoverArt, albumMetadata);
|
|
1361
|
+
const inner = buildSongArtistChildren(track, albumMetadata);
|
|
1362
|
+
if (inner.length === 0) {
|
|
1363
|
+
return emptyNode('song', attrs);
|
|
1364
|
+
}
|
|
1365
|
+
return node('song', attrs, inner);
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
function songChildNode(track, albumTitle = null, albumCoverArt = null, albumMetadata = null, extraAttrs = {}) {
|
|
1369
|
+
const attrs = { ...songAttrs(track, albumTitle, albumCoverArt, albumMetadata), ...extraAttrs };
|
|
1370
|
+
const inner = buildSongArtistChildren(track, albumMetadata);
|
|
1371
|
+
if (inner.length === 0) {
|
|
1372
|
+
return emptyNode('child', attrs);
|
|
1373
|
+
}
|
|
1374
|
+
return node('child', attrs, inner);
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1279
1377
|
function sortTracksByDiscAndIndex(tracks) {
|
|
1280
1378
|
return [...(Array.isArray(tracks) ? tracks : [])].sort((a, b) => {
|
|
1281
1379
|
const discA = parsePositiveInt(a?.parentIndex ?? a?.discNumber, 1);
|
|
@@ -1820,39 +1918,39 @@ async function runPlexSearch({
|
|
|
1820
1918
|
const [extraArtists, extraAlbums, extraTracks] = await Promise.all([
|
|
1821
1919
|
missingArtist
|
|
1822
1920
|
? searchSectionMetadata({
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1921
|
+
baseUrl: plexState.baseUrl,
|
|
1922
|
+
plexToken: plexState.plexToken,
|
|
1923
|
+
sectionId: plexState.musicSectionId,
|
|
1924
|
+
type: 8,
|
|
1925
|
+
query,
|
|
1926
|
+
offset: 0,
|
|
1927
|
+
limit: artistWindow,
|
|
1928
|
+
signal,
|
|
1929
|
+
})
|
|
1832
1930
|
: [],
|
|
1833
1931
|
missingAlbum
|
|
1834
1932
|
? searchSectionMetadata({
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1933
|
+
baseUrl: plexState.baseUrl,
|
|
1934
|
+
plexToken: plexState.plexToken,
|
|
1935
|
+
sectionId: plexState.musicSectionId,
|
|
1936
|
+
type: 9,
|
|
1937
|
+
query,
|
|
1938
|
+
offset: 0,
|
|
1939
|
+
limit: albumWindow,
|
|
1940
|
+
signal,
|
|
1941
|
+
})
|
|
1844
1942
|
: [],
|
|
1845
1943
|
missingTrack
|
|
1846
1944
|
? searchSectionMetadata({
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1945
|
+
baseUrl: plexState.baseUrl,
|
|
1946
|
+
plexToken: plexState.plexToken,
|
|
1947
|
+
sectionId: plexState.musicSectionId,
|
|
1948
|
+
type: 10,
|
|
1949
|
+
query,
|
|
1950
|
+
offset: 0,
|
|
1951
|
+
limit: songWindow,
|
|
1952
|
+
signal,
|
|
1953
|
+
})
|
|
1856
1954
|
: [],
|
|
1857
1955
|
]);
|
|
1858
1956
|
|
|
@@ -1868,6 +1966,10 @@ function isAbortError(error) {
|
|
|
1868
1966
|
return error?.name === 'AbortError' || error?.code === 'ABORT_ERR';
|
|
1869
1967
|
}
|
|
1870
1968
|
|
|
1969
|
+
function isNumericRatingKey(value) {
|
|
1970
|
+
return /^\d+$/.test(String(value || '').trim());
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1871
1973
|
function isUpstreamTerminationError(error) {
|
|
1872
1974
|
if (!error) {
|
|
1873
1975
|
return false;
|
|
@@ -1929,7 +2031,7 @@ async function fetchWithRetry({
|
|
|
1929
2031
|
|
|
1930
2032
|
try {
|
|
1931
2033
|
await response.body?.cancel?.();
|
|
1932
|
-
} catch {}
|
|
2034
|
+
} catch { }
|
|
1933
2035
|
} catch (error) {
|
|
1934
2036
|
if (isAbortError(error)) {
|
|
1935
2037
|
throw error;
|
|
@@ -2359,13 +2461,13 @@ function requiredPlexStateForSubsonic(reply, plexContext, tokenCipher) {
|
|
|
2359
2461
|
try {
|
|
2360
2462
|
const serverToken = decodePlexTokenOrThrow(tokenCipher, plexContext.server_token_enc);
|
|
2361
2463
|
plexTokenCandidates.push(serverToken);
|
|
2362
|
-
} catch {}
|
|
2464
|
+
} catch { }
|
|
2363
2465
|
}
|
|
2364
2466
|
if (plexContext.plex_token_enc) {
|
|
2365
2467
|
try {
|
|
2366
2468
|
const accountToken = decodePlexTokenOrThrow(tokenCipher, plexContext.plex_token_enc);
|
|
2367
2469
|
plexTokenCandidates.push(accountToken);
|
|
2368
|
-
} catch {}
|
|
2470
|
+
} catch { }
|
|
2369
2471
|
}
|
|
2370
2472
|
|
|
2371
2473
|
const plexToken = uniqueNonEmptyValues(plexTokenCandidates);
|
|
@@ -4174,7 +4276,7 @@ export async function buildServer(config = loadConfig()) {
|
|
|
4174
4276
|
if (selectedServer.server_token_enc) {
|
|
4175
4277
|
try {
|
|
4176
4278
|
selectedServerToken = decodePlexTokenOrThrow(tokenCipher, selectedServer.server_token_enc);
|
|
4177
|
-
} catch {}
|
|
4279
|
+
} catch { }
|
|
4178
4280
|
}
|
|
4179
4281
|
|
|
4180
4282
|
try {
|
|
@@ -4504,8 +4606,7 @@ export async function buildServer(config = loadConfig()) {
|
|
|
4504
4606
|
playerId: session?.clientIdentifier || undefined,
|
|
4505
4607
|
minutesAgo,
|
|
4506
4608
|
});
|
|
4507
|
-
})
|
|
4508
|
-
.join('');
|
|
4609
|
+
});
|
|
4509
4610
|
|
|
4510
4611
|
return sendSubsonicOk(reply, node('nowPlaying', {}, entries));
|
|
4511
4612
|
} catch (error) {
|
|
@@ -4599,20 +4700,17 @@ export async function buildServer(config = loadConfig()) {
|
|
|
4599
4700
|
coverArt: artist.ratingKey,
|
|
4600
4701
|
...subsonicRatingAttrs(artist),
|
|
4601
4702
|
}),
|
|
4602
|
-
)
|
|
4603
|
-
.join('');
|
|
4703
|
+
);
|
|
4604
4704
|
|
|
4605
4705
|
const starredAlbums = albums
|
|
4606
4706
|
.filter((album) => isPlexLiked(album.userRating))
|
|
4607
|
-
.map((album) =>
|
|
4608
|
-
.join('');
|
|
4707
|
+
.map((album) => albumNode('album', album, albumAttrs(album)));
|
|
4609
4708
|
|
|
4610
4709
|
const starredSongs = tracks
|
|
4611
4710
|
.filter((track) => isPlexLiked(track.userRating))
|
|
4612
|
-
.map((track) =>
|
|
4613
|
-
.join('');
|
|
4711
|
+
.map((track) => songNode(track));
|
|
4614
4712
|
|
|
4615
|
-
return sendSubsonicOk(reply, node('starred', {},
|
|
4713
|
+
return sendSubsonicOk(reply, node('starred', {}, [...starredArtists, ...starredAlbums, ...starredSongs]));
|
|
4616
4714
|
} catch (error) {
|
|
4617
4715
|
request.log.error(error, 'Failed to load starred items');
|
|
4618
4716
|
return sendSubsonicError(reply, 10, 'Failed to load starred items');
|
|
@@ -4648,20 +4746,17 @@ export async function buildServer(config = loadConfig()) {
|
|
|
4648
4746
|
coverArt: artist.ratingKey,
|
|
4649
4747
|
...subsonicRatingAttrs(artist),
|
|
4650
4748
|
}),
|
|
4651
|
-
)
|
|
4652
|
-
.join('');
|
|
4749
|
+
);
|
|
4653
4750
|
|
|
4654
4751
|
const starredAlbums = albums
|
|
4655
4752
|
.filter((album) => isPlexLiked(album.userRating))
|
|
4656
|
-
.map((album) =>
|
|
4657
|
-
.join('');
|
|
4753
|
+
.map((album) => albumNode('album', album, albumId3Attrs(album)));
|
|
4658
4754
|
|
|
4659
4755
|
const starredSongs = tracks
|
|
4660
4756
|
.filter((track) => isPlexLiked(track.userRating))
|
|
4661
|
-
.map((track) =>
|
|
4662
|
-
.join('');
|
|
4757
|
+
.map((track) => songNode(track));
|
|
4663
4758
|
|
|
4664
|
-
return sendSubsonicOk(reply, node('starred2', {},
|
|
4759
|
+
return sendSubsonicOk(reply, node('starred2', {}, [...starredArtists, ...starredAlbums, ...starredSongs]));
|
|
4665
4760
|
} catch (error) {
|
|
4666
4761
|
request.log.error(error, 'Failed to load starred items');
|
|
4667
4762
|
return sendSubsonicError(reply, 10, 'Failed to load starred items');
|
|
@@ -4712,7 +4807,7 @@ export async function buildServer(config = loadConfig()) {
|
|
|
4712
4807
|
}
|
|
4713
4808
|
}
|
|
4714
4809
|
|
|
4715
|
-
const
|
|
4810
|
+
const genreNodes = [...counts.values()]
|
|
4716
4811
|
.sort((a, b) => a.name.localeCompare(b.name))
|
|
4717
4812
|
.map((genre) =>
|
|
4718
4813
|
node(
|
|
@@ -4723,10 +4818,9 @@ export async function buildServer(config = loadConfig()) {
|
|
|
4723
4818
|
},
|
|
4724
4819
|
genre.name,
|
|
4725
4820
|
),
|
|
4726
|
-
)
|
|
4727
|
-
.join('');
|
|
4821
|
+
);
|
|
4728
4822
|
|
|
4729
|
-
return sendSubsonicOk(reply, node('genres', {},
|
|
4823
|
+
return sendSubsonicOk(reply, node('genres', {}, genreNodes));
|
|
4730
4824
|
} catch (error) {
|
|
4731
4825
|
request.log.error(error, 'Failed to load genres');
|
|
4732
4826
|
return sendSubsonicError(reply, 10, 'Failed to load genres');
|
|
@@ -4774,8 +4868,8 @@ export async function buildServer(config = loadConfig()) {
|
|
|
4774
4868
|
const sortedSongs = sortTracksForLibraryBrowse(matchedSongs);
|
|
4775
4869
|
const page = takePage(sortedSongs, offset, count)
|
|
4776
4870
|
.map((track) => withResolvedTrackGenres(track, albumGenreTagMap));
|
|
4777
|
-
const
|
|
4778
|
-
return sendSubsonicOk(reply, node('songsByGenre', {},
|
|
4871
|
+
const songs = page.map((track) => songNode(track));
|
|
4872
|
+
return sendSubsonicOk(reply, node('songsByGenre', {}, songs));
|
|
4779
4873
|
} catch (error) {
|
|
4780
4874
|
request.log.error(error, 'Failed to load songs by genre');
|
|
4781
4875
|
return sendSubsonicError(reply, 10, 'Failed to load songs by genre');
|
|
@@ -4801,8 +4895,8 @@ export async function buildServer(config = loadConfig()) {
|
|
|
4801
4895
|
const allTracks = await getCachedLibraryTracks({ accountId: account.id, plexState, request });
|
|
4802
4896
|
|
|
4803
4897
|
const randomTracks = shuffleInPlace(allTracks.slice()).slice(0, size);
|
|
4804
|
-
const
|
|
4805
|
-
return sendSubsonicOk(reply, node('randomSongs', {},
|
|
4898
|
+
const songs = randomTracks.map((track) => songNode(track));
|
|
4899
|
+
return sendSubsonicOk(reply, node('randomSongs', {}, songs));
|
|
4806
4900
|
} catch (error) {
|
|
4807
4901
|
request.log.error(error, 'Failed to load random songs');
|
|
4808
4902
|
return sendSubsonicError(reply, 10, 'Failed to load random songs');
|
|
@@ -4845,8 +4939,8 @@ export async function buildServer(config = loadConfig()) {
|
|
|
4845
4939
|
applyCachedRatingOverridesForAccount({ accountId: account.id, plexState, items: tracks });
|
|
4846
4940
|
|
|
4847
4941
|
const topTracks = tracks.slice(0, size);
|
|
4848
|
-
const
|
|
4849
|
-
return sendSubsonicOk(reply, node('topSongs', {},
|
|
4942
|
+
const songs = topTracks.map((track) => songNode(track));
|
|
4943
|
+
return sendSubsonicOk(reply, node('topSongs', {}, songs));
|
|
4850
4944
|
} catch (error) {
|
|
4851
4945
|
request.log.error(error, 'Failed to load top songs');
|
|
4852
4946
|
return sendSubsonicError(reply, 10, 'Failed to load top songs');
|
|
@@ -4966,8 +5060,8 @@ export async function buildServer(config = loadConfig()) {
|
|
|
4966
5060
|
}
|
|
4967
5061
|
applyCachedRatingOverridesForAccount({ accountId: account.id, plexState, items: tracks });
|
|
4968
5062
|
|
|
4969
|
-
const
|
|
4970
|
-
return sendSubsonicOk(reply, node('similarSongs', {},
|
|
5063
|
+
const songs = tracks.map((track) => songNode(track));
|
|
5064
|
+
return sendSubsonicOk(reply, node('similarSongs', {}, songs));
|
|
4971
5065
|
} catch (error) {
|
|
4972
5066
|
request.log.error(error, 'Failed to load similar songs');
|
|
4973
5067
|
return sendSubsonicError(reply, 10, 'Failed to load similar songs');
|
|
@@ -5010,8 +5104,8 @@ export async function buildServer(config = loadConfig()) {
|
|
|
5010
5104
|
}
|
|
5011
5105
|
applyCachedRatingOverridesForAccount({ accountId: account.id, plexState, items: tracks });
|
|
5012
5106
|
|
|
5013
|
-
const
|
|
5014
|
-
return sendSubsonicOk(reply, node('similarSongs2', {},
|
|
5107
|
+
const songs = tracks.map((track) => songNode(track));
|
|
5108
|
+
return sendSubsonicOk(reply, node('similarSongs2', {}, songs));
|
|
5015
5109
|
} catch (error) {
|
|
5016
5110
|
request.log.error(error, 'Failed to load similar songs2');
|
|
5017
5111
|
return sendSubsonicError(reply, 10, 'Failed to load similar songs');
|
|
@@ -5143,11 +5237,11 @@ export async function buildServer(config = loadConfig()) {
|
|
|
5143
5237
|
|
|
5144
5238
|
const lyricCandidates = matchedTrackId
|
|
5145
5239
|
? await fetchPlexTrackLyricsCandidates({
|
|
5146
|
-
|
|
5147
|
-
|
|
5148
|
-
|
|
5149
|
-
|
|
5150
|
-
|
|
5240
|
+
baseUrl: plexState.baseUrl,
|
|
5241
|
+
plexToken: plexState.plexToken,
|
|
5242
|
+
trackId: matchedTrackId,
|
|
5243
|
+
signal: searchScope.signal,
|
|
5244
|
+
})
|
|
5151
5245
|
: [];
|
|
5152
5246
|
|
|
5153
5247
|
const plainLyrics = buildPlainLyricsFromStructuredLyrics(
|
|
@@ -5232,9 +5326,9 @@ export async function buildServer(config = loadConfig()) {
|
|
|
5232
5326
|
});
|
|
5233
5327
|
|
|
5234
5328
|
const structuredLyrics = extractStructuredLyricsFromTrack(track, lyricCandidates);
|
|
5235
|
-
const
|
|
5329
|
+
const structuredLyricsNodes = structuredLyrics
|
|
5236
5330
|
.map((lyrics) => {
|
|
5237
|
-
const
|
|
5331
|
+
const lineNodes = lyrics.lines
|
|
5238
5332
|
.map((line) =>
|
|
5239
5333
|
node(
|
|
5240
5334
|
'line',
|
|
@@ -5243,8 +5337,7 @@ export async function buildServer(config = loadConfig()) {
|
|
|
5243
5337
|
},
|
|
5244
5338
|
line.value,
|
|
5245
5339
|
),
|
|
5246
|
-
)
|
|
5247
|
-
.join('');
|
|
5340
|
+
);
|
|
5248
5341
|
|
|
5249
5342
|
return node(
|
|
5250
5343
|
'structuredLyrics',
|
|
@@ -5255,12 +5348,11 @@ export async function buildServer(config = loadConfig()) {
|
|
|
5255
5348
|
synced: lyrics.synced,
|
|
5256
5349
|
offset: lyrics.offset,
|
|
5257
5350
|
},
|
|
5258
|
-
|
|
5351
|
+
lineNodes,
|
|
5259
5352
|
);
|
|
5260
|
-
})
|
|
5261
|
-
.join('');
|
|
5353
|
+
});
|
|
5262
5354
|
|
|
5263
|
-
return sendSubsonicOk(reply, node('lyricsList', {},
|
|
5355
|
+
return sendSubsonicOk(reply, node('lyricsList', {}, structuredLyricsNodes));
|
|
5264
5356
|
} catch (error) {
|
|
5265
5357
|
if (isAbortError(error)) {
|
|
5266
5358
|
const reason = String(lyricsScope.reason() || '');
|
|
@@ -5364,7 +5456,7 @@ export async function buildServer(config = loadConfig()) {
|
|
|
5364
5456
|
);
|
|
5365
5457
|
}
|
|
5366
5458
|
|
|
5367
|
-
const
|
|
5459
|
+
const artistNodes = matchedArtists
|
|
5368
5460
|
.map((artist) =>
|
|
5369
5461
|
emptyNode('artist', {
|
|
5370
5462
|
id: artist.ratingKey,
|
|
@@ -5373,16 +5465,13 @@ export async function buildServer(config = loadConfig()) {
|
|
|
5373
5465
|
coverArt: artist.ratingKey,
|
|
5374
5466
|
...subsonicRatingAttrs(artist),
|
|
5375
5467
|
}),
|
|
5376
|
-
)
|
|
5377
|
-
|
|
5378
|
-
|
|
5379
|
-
|
|
5380
|
-
.
|
|
5381
|
-
|
|
5382
|
-
|
|
5383
|
-
.join('');
|
|
5384
|
-
|
|
5385
|
-
return sendSubsonicOk(reply, node('searchResult3', {}, `${artistXml}${albumXml}${songXml}`));
|
|
5468
|
+
);
|
|
5469
|
+
const albumNodes = matchedAlbums
|
|
5470
|
+
.map((album) => albumNode('album', album, albumId3Attrs(album)));
|
|
5471
|
+
const songNodes = matchedTracks
|
|
5472
|
+
.map((track) => songNode(track));
|
|
5473
|
+
|
|
5474
|
+
return sendSubsonicOk(reply, node('searchResult3', {}, [...artistNodes, ...albumNodes, ...songNodes]));
|
|
5386
5475
|
} catch (error) {
|
|
5387
5476
|
if (isAbortError(error)) {
|
|
5388
5477
|
const reason = String(searchScope.reason() || '');
|
|
@@ -5462,7 +5551,7 @@ export async function buildServer(config = loadConfig()) {
|
|
|
5462
5551
|
songCount,
|
|
5463
5552
|
);
|
|
5464
5553
|
|
|
5465
|
-
const
|
|
5554
|
+
const artistNodes = matchedArtists
|
|
5466
5555
|
.map((artist) =>
|
|
5467
5556
|
emptyNode('artist', {
|
|
5468
5557
|
id: artist.ratingKey,
|
|
@@ -5470,16 +5559,13 @@ export async function buildServer(config = loadConfig()) {
|
|
|
5470
5559
|
coverArt: artist.ratingKey,
|
|
5471
5560
|
...subsonicRatingAttrs(artist),
|
|
5472
5561
|
}),
|
|
5473
|
-
)
|
|
5474
|
-
|
|
5475
|
-
|
|
5476
|
-
|
|
5477
|
-
.
|
|
5478
|
-
|
|
5479
|
-
|
|
5480
|
-
.join('');
|
|
5481
|
-
|
|
5482
|
-
return sendSubsonicOk(reply, node('searchResult2', {}, `${artistXml}${albumXml}${songXml}`));
|
|
5562
|
+
);
|
|
5563
|
+
const albumNodes = matchedAlbums
|
|
5564
|
+
.map((album) => albumNode('album', album, albumAttrs(album)));
|
|
5565
|
+
const songNodes = matchedTracks
|
|
5566
|
+
.map((track) => songNode(track));
|
|
5567
|
+
|
|
5568
|
+
return sendSubsonicOk(reply, node('searchResult2', {}, [...artistNodes, ...albumNodes, ...songNodes]));
|
|
5483
5569
|
} catch (error) {
|
|
5484
5570
|
if (isAbortError(error)) {
|
|
5485
5571
|
const reason = String(searchScope.reason() || '');
|
|
@@ -5559,7 +5645,7 @@ export async function buildServer(config = loadConfig()) {
|
|
|
5559
5645
|
songCount,
|
|
5560
5646
|
);
|
|
5561
5647
|
|
|
5562
|
-
const
|
|
5648
|
+
const artistNodes = matchedArtists
|
|
5563
5649
|
.map((artist) =>
|
|
5564
5650
|
emptyNode('artist', {
|
|
5565
5651
|
id: artist.ratingKey,
|
|
@@ -5567,12 +5653,10 @@ export async function buildServer(config = loadConfig()) {
|
|
|
5567
5653
|
coverArt: artist.ratingKey,
|
|
5568
5654
|
...subsonicRatingAttrs(artist),
|
|
5569
5655
|
}),
|
|
5570
|
-
)
|
|
5571
|
-
|
|
5572
|
-
|
|
5573
|
-
|
|
5574
|
-
.join('');
|
|
5575
|
-
const matchXml = matchedTracks
|
|
5656
|
+
);
|
|
5657
|
+
const albumNodes = matchedAlbums
|
|
5658
|
+
.map((album) => albumNode('album', album, albumAttrs(album)));
|
|
5659
|
+
const matchNodes = matchedTracks
|
|
5576
5660
|
.map((track) =>
|
|
5577
5661
|
emptyNode('match', {
|
|
5578
5662
|
id: track.ratingKey,
|
|
@@ -5580,10 +5664,9 @@ export async function buildServer(config = loadConfig()) {
|
|
|
5580
5664
|
album: track.parentTitle,
|
|
5581
5665
|
artist: track.grandparentTitle,
|
|
5582
5666
|
}),
|
|
5583
|
-
)
|
|
5584
|
-
.join('');
|
|
5667
|
+
);
|
|
5585
5668
|
|
|
5586
|
-
return sendSubsonicOk(reply, node('searchResult', {},
|
|
5669
|
+
return sendSubsonicOk(reply, node('searchResult', {}, [...artistNodes, ...albumNodes, ...matchNodes]));
|
|
5587
5670
|
} catch (error) {
|
|
5588
5671
|
if (isAbortError(error)) {
|
|
5589
5672
|
const reason = String(searchScope.reason() || '');
|
|
@@ -5656,8 +5739,7 @@ export async function buildServer(config = loadConfig()) {
|
|
|
5656
5739
|
});
|
|
5657
5740
|
|
|
5658
5741
|
const entries = tracks
|
|
5659
|
-
.map((track) => emptyNode('entry', songAttrs(track)))
|
|
5660
|
-
.join('');
|
|
5742
|
+
.map((track) => emptyNode('entry', songAttrs(track)));
|
|
5661
5743
|
const changedIso = new Date(Number(queueState.updatedAt || Date.now())).toISOString();
|
|
5662
5744
|
|
|
5663
5745
|
return sendSubsonicOk(
|
|
@@ -6196,11 +6278,10 @@ export async function buildServer(config = loadConfig()) {
|
|
|
6196
6278
|
});
|
|
6197
6279
|
|
|
6198
6280
|
const nowIso = new Date().toISOString();
|
|
6199
|
-
const
|
|
6200
|
-
.map((playlist) => emptyNode('playlist', playlistAttrs(playlist, account.username, nowIso)))
|
|
6201
|
-
.join('');
|
|
6281
|
+
const playlistNodes = playlists
|
|
6282
|
+
.map((playlist) => emptyNode('playlist', playlistAttrs(playlist, account.username, nowIso)));
|
|
6202
6283
|
|
|
6203
|
-
return sendSubsonicOk(reply, node('playlists', {},
|
|
6284
|
+
return sendSubsonicOk(reply, node('playlists', {}, playlistNodes));
|
|
6204
6285
|
} catch (error) {
|
|
6205
6286
|
request.log.error(error, 'Failed to load playlists');
|
|
6206
6287
|
return sendSubsonicError(reply, 10, 'Failed to load playlists');
|
|
@@ -6247,8 +6328,7 @@ export async function buildServer(config = loadConfig()) {
|
|
|
6247
6328
|
.map((track) => {
|
|
6248
6329
|
const attrs = songAttrs(track, track.parentTitle || undefined, track.parentRatingKey || undefined);
|
|
6249
6330
|
return emptyNode('entry', attrs);
|
|
6250
|
-
})
|
|
6251
|
-
.join('');
|
|
6331
|
+
});
|
|
6252
6332
|
|
|
6253
6333
|
return sendSubsonicOk(reply, node('playlist', playlistAttrs(playlist, account.username, nowIso), entries));
|
|
6254
6334
|
} catch (error) {
|
|
@@ -6421,7 +6501,7 @@ export async function buildServer(config = loadConfig()) {
|
|
|
6421
6501
|
try {
|
|
6422
6502
|
const artists = await getCachedLibraryArtists({ accountId: account.id, plexState, request });
|
|
6423
6503
|
|
|
6424
|
-
const indexes = groupArtistsForSubsonic(artists)
|
|
6504
|
+
const indexes = groupArtistsForSubsonic(artists);
|
|
6425
6505
|
return sendSubsonicOk(reply, node('artists', { ignoredArticles: 'The El La Los Las Le Les' }, indexes));
|
|
6426
6506
|
} catch (error) {
|
|
6427
6507
|
request.log.error(error, 'Failed to load artists from Plex');
|
|
@@ -6465,7 +6545,7 @@ export async function buildServer(config = loadConfig()) {
|
|
|
6465
6545
|
})
|
|
6466
6546
|
.filter(Boolean);
|
|
6467
6547
|
|
|
6468
|
-
const indexes = groupNamedEntriesForSubsonic(folderEntries)
|
|
6548
|
+
const indexes = groupNamedEntriesForSubsonic(folderEntries);
|
|
6469
6549
|
return sendSubsonicOk(
|
|
6470
6550
|
reply,
|
|
6471
6551
|
node(
|
|
@@ -6494,6 +6574,10 @@ export async function buildServer(config = loadConfig()) {
|
|
|
6494
6574
|
return sendSubsonicError(reply, 70, 'Missing artist id');
|
|
6495
6575
|
}
|
|
6496
6576
|
|
|
6577
|
+
if (!isNumericRatingKey(artistId)) {
|
|
6578
|
+
return sendSubsonicOk(reply, node('artist', {}, ''));
|
|
6579
|
+
}
|
|
6580
|
+
|
|
6497
6581
|
const context = repo.getAccountPlexContext(account.id);
|
|
6498
6582
|
const plexState = requiredPlexStateForSubsonic(reply, context, tokenCipher);
|
|
6499
6583
|
if (!plexState) {
|
|
@@ -6549,8 +6633,15 @@ export async function buildServer(config = loadConfig()) {
|
|
|
6549
6633
|
}
|
|
6550
6634
|
|
|
6551
6635
|
const albumXml = finalAlbums
|
|
6552
|
-
.map((album) =>
|
|
6553
|
-
|
|
6636
|
+
.map((album) =>
|
|
6637
|
+
albumNode(
|
|
6638
|
+
'album',
|
|
6639
|
+
album,
|
|
6640
|
+
albumId3Attrs(album, artist.ratingKey, artist.title),
|
|
6641
|
+
artist.ratingKey,
|
|
6642
|
+
artist.title,
|
|
6643
|
+
),
|
|
6644
|
+
);
|
|
6554
6645
|
|
|
6555
6646
|
return sendSubsonicOk(
|
|
6556
6647
|
reply,
|
|
@@ -6583,6 +6674,10 @@ export async function buildServer(config = loadConfig()) {
|
|
|
6583
6674
|
return sendSubsonicError(reply, 70, 'Missing artist id');
|
|
6584
6675
|
}
|
|
6585
6676
|
|
|
6677
|
+
if (!isNumericRatingKey(artistId)) {
|
|
6678
|
+
return sendSubsonicOk(reply, node('artistInfo'));
|
|
6679
|
+
}
|
|
6680
|
+
|
|
6586
6681
|
const context = repo.getAccountPlexContext(account.id);
|
|
6587
6682
|
const plexState = requiredPlexStateForSubsonic(reply, context, tokenCipher);
|
|
6588
6683
|
if (!plexState) {
|
|
@@ -6622,11 +6717,9 @@ export async function buildServer(config = loadConfig()) {
|
|
|
6622
6717
|
const biography = artistBioFromPlex(artist);
|
|
6623
6718
|
const musicBrainzId = extractMusicBrainzArtistId(artist);
|
|
6624
6719
|
const children = [
|
|
6625
|
-
biography ? node('biography', {}, biography) :
|
|
6626
|
-
musicBrainzId ? node('musicBrainzId', {}, musicBrainzId) :
|
|
6627
|
-
]
|
|
6628
|
-
.filter(Boolean)
|
|
6629
|
-
.join('');
|
|
6720
|
+
biography ? node('biography', {}, biography) : null,
|
|
6721
|
+
musicBrainzId ? node('musicBrainzId', {}, musicBrainzId) : null,
|
|
6722
|
+
].filter(Boolean);
|
|
6630
6723
|
|
|
6631
6724
|
return sendSubsonicOk(reply, node('artistInfo', {}, children));
|
|
6632
6725
|
} catch (error) {
|
|
@@ -6646,6 +6739,10 @@ export async function buildServer(config = loadConfig()) {
|
|
|
6646
6739
|
return sendSubsonicError(reply, 70, 'Missing artist id');
|
|
6647
6740
|
}
|
|
6648
6741
|
|
|
6742
|
+
if (!isNumericRatingKey(artistId)) {
|
|
6743
|
+
return sendSubsonicOk(reply, node('artistInfo2'));
|
|
6744
|
+
}
|
|
6745
|
+
|
|
6649
6746
|
const context = repo.getAccountPlexContext(account.id);
|
|
6650
6747
|
const plexState = requiredPlexStateForSubsonic(reply, context, tokenCipher);
|
|
6651
6748
|
if (!plexState) {
|
|
@@ -6685,11 +6782,9 @@ export async function buildServer(config = loadConfig()) {
|
|
|
6685
6782
|
const biography = artistBioFromPlex(artist);
|
|
6686
6783
|
const musicBrainzId = extractMusicBrainzArtistId(artist);
|
|
6687
6784
|
const children = [
|
|
6688
|
-
biography ? node('biography', {}, biography) :
|
|
6689
|
-
musicBrainzId ? node('musicBrainzId', {}, musicBrainzId) :
|
|
6690
|
-
]
|
|
6691
|
-
.filter(Boolean)
|
|
6692
|
-
.join('');
|
|
6785
|
+
biography ? node('biography', {}, biography) : null,
|
|
6786
|
+
musicBrainzId ? node('musicBrainzId', {}, musicBrainzId) : null,
|
|
6787
|
+
].filter(Boolean);
|
|
6693
6788
|
|
|
6694
6789
|
return sendSubsonicOk(reply, node('artistInfo2', {}, children));
|
|
6695
6790
|
} catch (error) {
|
|
@@ -6709,6 +6804,10 @@ export async function buildServer(config = loadConfig()) {
|
|
|
6709
6804
|
return sendSubsonicError(reply, 70, 'Missing album id');
|
|
6710
6805
|
}
|
|
6711
6806
|
|
|
6807
|
+
if (!isNumericRatingKey(albumId)) {
|
|
6808
|
+
return sendSubsonicOk(reply, node('album'));
|
|
6809
|
+
}
|
|
6810
|
+
|
|
6712
6811
|
const context = repo.getAccountPlexContext(account.id);
|
|
6713
6812
|
const plexState = requiredPlexStateForSubsonic(reply, context, tokenCipher);
|
|
6714
6813
|
if (!plexState) {
|
|
@@ -6742,20 +6841,22 @@ export async function buildServer(config = loadConfig()) {
|
|
|
6742
6841
|
|
|
6743
6842
|
const totalDuration = sortedTracks.reduce((sum, track) => sum + durationSeconds(track.duration), 0);
|
|
6744
6843
|
|
|
6745
|
-
const
|
|
6746
|
-
.map((track) =>
|
|
6747
|
-
.join('');
|
|
6844
|
+
const songNodes = sortedTracks
|
|
6845
|
+
.map((track) => songNode(track, album.title, album.ratingKey, album));
|
|
6748
6846
|
|
|
6749
6847
|
return sendSubsonicOk(
|
|
6750
6848
|
reply,
|
|
6751
|
-
|
|
6849
|
+
albumNode(
|
|
6752
6850
|
'album',
|
|
6851
|
+
album,
|
|
6753
6852
|
{
|
|
6754
6853
|
...albumId3Attrs(album, album.parentRatingKey || null, album.parentTitle || null),
|
|
6755
6854
|
songCount: sortedTracks.length,
|
|
6756
6855
|
duration: totalDuration,
|
|
6757
6856
|
},
|
|
6758
|
-
|
|
6857
|
+
album.parentRatingKey || null,
|
|
6858
|
+
album.parentTitle || null,
|
|
6859
|
+
songNodes,
|
|
6759
6860
|
),
|
|
6760
6861
|
);
|
|
6761
6862
|
} catch (error) {
|
|
@@ -6804,15 +6905,20 @@ export async function buildServer(config = loadConfig()) {
|
|
|
6804
6905
|
const children = folderResult.items
|
|
6805
6906
|
.map((item) => {
|
|
6806
6907
|
if (isLikelyPlexTrack(item)) {
|
|
6807
|
-
return
|
|
6808
|
-
|
|
6809
|
-
|
|
6810
|
-
|
|
6811
|
-
|
|
6908
|
+
return songChildNode(
|
|
6909
|
+
item,
|
|
6910
|
+
item.parentTitle || null,
|
|
6911
|
+
item.parentRatingKey || null,
|
|
6912
|
+
null,
|
|
6913
|
+
{
|
|
6914
|
+
isDir: false,
|
|
6915
|
+
parent: currentDirectoryId,
|
|
6916
|
+
},
|
|
6917
|
+
);
|
|
6812
6918
|
}
|
|
6813
6919
|
|
|
6814
6920
|
if (isLikelyPlexAlbum(item)) {
|
|
6815
|
-
return
|
|
6921
|
+
return albumNode('child', item, {
|
|
6816
6922
|
...albumAttrs(item),
|
|
6817
6923
|
isDir: true,
|
|
6818
6924
|
parent: currentDirectoryId,
|
|
@@ -6837,8 +6943,7 @@ export async function buildServer(config = loadConfig()) {
|
|
|
6837
6943
|
name: title,
|
|
6838
6944
|
});
|
|
6839
6945
|
})
|
|
6840
|
-
.filter(Boolean)
|
|
6841
|
-
.join('');
|
|
6946
|
+
.filter(Boolean);
|
|
6842
6947
|
|
|
6843
6948
|
const container = folderResult.container || {};
|
|
6844
6949
|
const directoryName = isRootFolder
|
|
@@ -6892,8 +6997,15 @@ export async function buildServer(config = loadConfig()) {
|
|
|
6892
6997
|
}
|
|
6893
6998
|
|
|
6894
6999
|
const children = finalAlbums
|
|
6895
|
-
.map((album) =>
|
|
6896
|
-
|
|
7000
|
+
.map((album) =>
|
|
7001
|
+
albumNode(
|
|
7002
|
+
'child',
|
|
7003
|
+
album,
|
|
7004
|
+
albumAttrs(album, artist.ratingKey, artist.title),
|
|
7005
|
+
artist.ratingKey,
|
|
7006
|
+
artist.title,
|
|
7007
|
+
),
|
|
7008
|
+
);
|
|
6897
7009
|
|
|
6898
7010
|
return sendSubsonicOk(
|
|
6899
7011
|
reply,
|
|
@@ -6919,8 +7031,15 @@ export async function buildServer(config = loadConfig()) {
|
|
|
6919
7031
|
if (fallback?.artist) {
|
|
6920
7032
|
const fallbackAlbums = fallback.albums || [];
|
|
6921
7033
|
const children = fallbackAlbums
|
|
6922
|
-
.map((album) =>
|
|
6923
|
-
|
|
7034
|
+
.map((album) =>
|
|
7035
|
+
albumNode(
|
|
7036
|
+
'child',
|
|
7037
|
+
album,
|
|
7038
|
+
albumAttrs(album, fallback.artist.ratingKey, fallback.artist.title),
|
|
7039
|
+
fallback.artist.ratingKey,
|
|
7040
|
+
fallback.artist.title,
|
|
7041
|
+
),
|
|
7042
|
+
);
|
|
6924
7043
|
|
|
6925
7044
|
return sendSubsonicOk(
|
|
6926
7045
|
reply,
|
|
@@ -6960,13 +7079,11 @@ export async function buildServer(config = loadConfig()) {
|
|
|
6960
7079
|
|
|
6961
7080
|
const children = sortedTracks
|
|
6962
7081
|
.map((track) =>
|
|
6963
|
-
|
|
6964
|
-
...songAttrs(track, album.title, album.ratingKey, album),
|
|
7082
|
+
songChildNode(track, album.title, album.ratingKey, album, {
|
|
6965
7083
|
isDir: false,
|
|
6966
7084
|
parent: album.ratingKey,
|
|
6967
7085
|
}),
|
|
6968
|
-
)
|
|
6969
|
-
.join('');
|
|
7086
|
+
);
|
|
6970
7087
|
|
|
6971
7088
|
return sendSubsonicOk(
|
|
6972
7089
|
reply,
|
|
@@ -7041,13 +7158,16 @@ export async function buildServer(config = loadConfig()) {
|
|
|
7041
7158
|
});
|
|
7042
7159
|
const page = sliceAlbumPage(filtered, offset, size);
|
|
7043
7160
|
reply.header('x-total-count', String(filtered.length));
|
|
7044
|
-
const
|
|
7161
|
+
const albumNodes = page
|
|
7045
7162
|
.map((album) =>
|
|
7046
|
-
|
|
7047
|
-
|
|
7048
|
-
|
|
7163
|
+
albumNode(
|
|
7164
|
+
'album',
|
|
7165
|
+
album,
|
|
7166
|
+
containerName === 'albumList2' ? albumId3Attrs(album) : albumAttrs(album),
|
|
7167
|
+
),
|
|
7168
|
+
);
|
|
7049
7169
|
|
|
7050
|
-
return sendSubsonicOk(reply, node(containerName, {},
|
|
7170
|
+
return sendSubsonicOk(reply, node(containerName, {}, albumNodes));
|
|
7051
7171
|
} catch (error) {
|
|
7052
7172
|
request.log.error(error, 'Failed to load album list');
|
|
7053
7173
|
return sendSubsonicError(reply, 10, 'Failed to load album list');
|
|
@@ -7150,6 +7270,134 @@ export async function buildServer(config = loadConfig()) {
|
|
|
7150
7270
|
}
|
|
7151
7271
|
});
|
|
7152
7272
|
|
|
7273
|
+
app.get('/rest/download.view', async (request, reply) => {
|
|
7274
|
+
const account = await authenticateSubsonicRequest(request, reply, repo, tokenCipher);
|
|
7275
|
+
if (!account) {
|
|
7276
|
+
return;
|
|
7277
|
+
}
|
|
7278
|
+
|
|
7279
|
+
const trackId = getQueryString(request, 'id');
|
|
7280
|
+
if (!trackId) {
|
|
7281
|
+
return sendSubsonicError(reply, 70, 'Missing track id');
|
|
7282
|
+
}
|
|
7283
|
+
|
|
7284
|
+
const context = repo.getAccountPlexContext(account.id);
|
|
7285
|
+
const plexState = requiredPlexStateForSubsonic(reply, context, tokenCipher);
|
|
7286
|
+
if (!plexState) {
|
|
7287
|
+
return;
|
|
7288
|
+
}
|
|
7289
|
+
|
|
7290
|
+
try {
|
|
7291
|
+
const track = await getTrack({
|
|
7292
|
+
baseUrl: plexState.baseUrl,
|
|
7293
|
+
plexToken: plexState.plexToken,
|
|
7294
|
+
trackId,
|
|
7295
|
+
});
|
|
7296
|
+
|
|
7297
|
+
if (!track) {
|
|
7298
|
+
return sendSubsonicError(reply, 70, 'Track not found');
|
|
7299
|
+
}
|
|
7300
|
+
|
|
7301
|
+
const part = partFromTrack(track);
|
|
7302
|
+
const partKey = part?.key;
|
|
7303
|
+
|
|
7304
|
+
if (!partKey) {
|
|
7305
|
+
return sendSubsonicError(reply, 70, 'Track has no downloadable part');
|
|
7306
|
+
}
|
|
7307
|
+
|
|
7308
|
+
const streamUrl = buildPmsAssetUrl(plexState.baseUrl, plexState.plexToken, partKey);
|
|
7309
|
+
const rangeHeader = request.headers.range;
|
|
7310
|
+
const upstreamController = new AbortController();
|
|
7311
|
+
const abortUpstreamOnDisconnect = () => {
|
|
7312
|
+
if (!upstreamController.signal.aborted) {
|
|
7313
|
+
upstreamController.abort();
|
|
7314
|
+
}
|
|
7315
|
+
};
|
|
7316
|
+
request.raw.once('aborted', abortUpstreamOnDisconnect);
|
|
7317
|
+
request.raw.once('close', abortUpstreamOnDisconnect);
|
|
7318
|
+
reply.raw.once('close', abortUpstreamOnDisconnect);
|
|
7319
|
+
|
|
7320
|
+
const upstream = await fetchWithRetry({
|
|
7321
|
+
url: streamUrl,
|
|
7322
|
+
options: {
|
|
7323
|
+
headers: {
|
|
7324
|
+
...(rangeHeader ? { Range: rangeHeader } : {}),
|
|
7325
|
+
},
|
|
7326
|
+
signal: upstreamController.signal,
|
|
7327
|
+
},
|
|
7328
|
+
request,
|
|
7329
|
+
context: 'track download proxy',
|
|
7330
|
+
maxAttempts: 3,
|
|
7331
|
+
baseDelayMs: 250,
|
|
7332
|
+
});
|
|
7333
|
+
|
|
7334
|
+
if (!upstream.ok || !upstream.body) {
|
|
7335
|
+
request.log.warn({ status: upstream.status }, 'Failed to proxy track download');
|
|
7336
|
+
return sendSubsonicError(reply, 70, 'Track download unavailable');
|
|
7337
|
+
}
|
|
7338
|
+
|
|
7339
|
+
reply.code(upstream.status);
|
|
7340
|
+
|
|
7341
|
+
for (const headerName of [
|
|
7342
|
+
'content-type',
|
|
7343
|
+
'content-length',
|
|
7344
|
+
'content-range',
|
|
7345
|
+
'accept-ranges',
|
|
7346
|
+
'etag',
|
|
7347
|
+
'last-modified',
|
|
7348
|
+
]) {
|
|
7349
|
+
const value = upstream.headers.get(headerName);
|
|
7350
|
+
if (value) {
|
|
7351
|
+
reply.header(headerName, value);
|
|
7352
|
+
}
|
|
7353
|
+
}
|
|
7354
|
+
|
|
7355
|
+
const fileName = part?.file ? part.file.split(/[/\\]/).pop() : null;
|
|
7356
|
+
if (fileName) {
|
|
7357
|
+
reply.header(
|
|
7358
|
+
'content-disposition',
|
|
7359
|
+
`attachment; filename*=UTF-8''${encodeURIComponent(fileName)}`,
|
|
7360
|
+
);
|
|
7361
|
+
}
|
|
7362
|
+
|
|
7363
|
+
const proxiedBody = Readable.fromWeb(upstream.body);
|
|
7364
|
+
const responseBody = new PassThrough();
|
|
7365
|
+
|
|
7366
|
+
proxiedBody.on('error', (streamError) => {
|
|
7367
|
+
if (
|
|
7368
|
+
isAbortError(streamError) ||
|
|
7369
|
+
isUpstreamTerminationError(streamError) ||
|
|
7370
|
+
isClientDisconnected(request, reply)
|
|
7371
|
+
) {
|
|
7372
|
+
responseBody.end();
|
|
7373
|
+
return;
|
|
7374
|
+
}
|
|
7375
|
+
request.log.warn(streamError, 'Upstream stream error while proxying track download');
|
|
7376
|
+
responseBody.destroy(streamError);
|
|
7377
|
+
});
|
|
7378
|
+
|
|
7379
|
+
responseBody.on('error', (streamError) => {
|
|
7380
|
+
if (
|
|
7381
|
+
isAbortError(streamError) ||
|
|
7382
|
+
isUpstreamTerminationError(streamError) ||
|
|
7383
|
+
isClientDisconnected(request, reply)
|
|
7384
|
+
) {
|
|
7385
|
+
return;
|
|
7386
|
+
}
|
|
7387
|
+
request.log.warn(streamError, 'Response stream error while proxying track download');
|
|
7388
|
+
});
|
|
7389
|
+
|
|
7390
|
+
proxiedBody.pipe(responseBody);
|
|
7391
|
+
return reply.send(responseBody);
|
|
7392
|
+
} catch (error) {
|
|
7393
|
+
if (isAbortError(error) || isUpstreamTerminationError(error) || isClientDisconnected(request, reply)) {
|
|
7394
|
+
return;
|
|
7395
|
+
}
|
|
7396
|
+
request.log.error(error, 'Failed to proxy download');
|
|
7397
|
+
return sendSubsonicError(reply, 10, 'Download proxy failed');
|
|
7398
|
+
}
|
|
7399
|
+
});
|
|
7400
|
+
|
|
7153
7401
|
app.get('/rest/stream.view', async (request, reply) => {
|
|
7154
7402
|
const account = await authenticateSubsonicRequest(request, reply, repo, tokenCipher);
|
|
7155
7403
|
if (!account) {
|
package/src/subsonic-xml.js
CHANGED
|
@@ -27,12 +27,7 @@ const SERVER_TYPE = 'Plexsonic';
|
|
|
27
27
|
const SERVER_VERSION = APP_VERSION;
|
|
28
28
|
const OPEN_SUBSONIC = true;
|
|
29
29
|
|
|
30
|
-
const TOKEN_START = '\u0001';
|
|
31
|
-
const TOKEN_END = '\u0002';
|
|
32
|
-
const TOKEN_PATTERN = /\u0001(\d+)\u0002/g;
|
|
33
30
|
|
|
34
|
-
let nodeSeq = 0;
|
|
35
|
-
const nodeRegistry = new Map();
|
|
36
31
|
|
|
37
32
|
export function xmlEscape(value) {
|
|
38
33
|
const sanitized = sanitizeXmlText(String(value));
|
|
@@ -62,54 +57,19 @@ function sanitizeXmlText(value) {
|
|
|
62
57
|
return output;
|
|
63
58
|
}
|
|
64
59
|
|
|
65
|
-
function storeNode(node) {
|
|
66
|
-
const id = ++nodeSeq;
|
|
67
|
-
nodeRegistry.set(id, node);
|
|
68
|
-
return `${TOKEN_START}${id}${TOKEN_END}`;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function parseTokenText(text) {
|
|
72
|
-
const out = [];
|
|
73
|
-
let index = 0;
|
|
74
|
-
let match;
|
|
75
|
-
|
|
76
|
-
TOKEN_PATTERN.lastIndex = 0;
|
|
77
|
-
while ((match = TOKEN_PATTERN.exec(text)) !== null) {
|
|
78
|
-
const before = text.slice(index, match.index);
|
|
79
|
-
if (before) {
|
|
80
|
-
out.push(before);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const nodeId = Number(match[1]);
|
|
84
|
-
const tokenNode = nodeRegistry.get(nodeId);
|
|
85
|
-
if (tokenNode) {
|
|
86
|
-
out.push(tokenNode);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
index = match.index + match[0].length;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
const tail = text.slice(index);
|
|
93
|
-
if (tail) {
|
|
94
|
-
out.push(tail);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
return out;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
60
|
function normalizeChildren(input) {
|
|
101
|
-
if (input == null) {
|
|
61
|
+
if (input == null || input === '') {
|
|
102
62
|
return [];
|
|
103
63
|
}
|
|
104
64
|
if (Array.isArray(input)) {
|
|
105
65
|
return input.flatMap((item) => normalizeChildren(item));
|
|
106
66
|
}
|
|
107
|
-
if (typeof input === 'string') {
|
|
108
|
-
return parseTokenText(input);
|
|
109
|
-
}
|
|
110
67
|
if (typeof input === 'object' && input.kind === 'node') {
|
|
111
68
|
return [input];
|
|
112
69
|
}
|
|
70
|
+
if (typeof input === 'string') {
|
|
71
|
+
return [input];
|
|
72
|
+
}
|
|
113
73
|
return [String(input)];
|
|
114
74
|
}
|
|
115
75
|
|
|
@@ -137,9 +97,7 @@ function renderXmlPart(part) {
|
|
|
137
97
|
return `<${part.name}${attrs}>${inner}</${part.name}>`;
|
|
138
98
|
}
|
|
139
99
|
|
|
140
|
-
|
|
141
|
-
return normalizeChildren(inner).map(renderXmlPart).join('');
|
|
142
|
-
}
|
|
100
|
+
|
|
143
101
|
|
|
144
102
|
const NUMERIC_ATTRS = new Set([
|
|
145
103
|
'code',
|
|
@@ -200,6 +158,7 @@ const ARRAY_CHILDREN_BY_PARENT = {
|
|
|
200
158
|
index: new Set(['artist']),
|
|
201
159
|
artist: new Set(['album']),
|
|
202
160
|
album: new Set(['song']),
|
|
161
|
+
albumArtists: new Set(['artist']),
|
|
203
162
|
songsByGenre: new Set(['song']),
|
|
204
163
|
randomSongs: new Set(['song']),
|
|
205
164
|
topSongs: new Set(['song']),
|
|
@@ -318,6 +277,19 @@ function nodeToJson(node) {
|
|
|
318
277
|
return out.value;
|
|
319
278
|
}
|
|
320
279
|
|
|
280
|
+
if (node.name === 'artists' || node.name === 'albumArtists') {
|
|
281
|
+
if (node.attrs?.flatten === 'true') {
|
|
282
|
+
const artistValue = out.artist;
|
|
283
|
+
if (Array.isArray(artistValue)) {
|
|
284
|
+
return artistValue;
|
|
285
|
+
}
|
|
286
|
+
if (artistValue != null) {
|
|
287
|
+
return [artistValue];
|
|
288
|
+
}
|
|
289
|
+
return [];
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
321
293
|
if (node.name === 'openSubsonicExtensions') {
|
|
322
294
|
const extensions = out.openSubsonicExtension;
|
|
323
295
|
if (Array.isArray(extensions)) {
|
|
@@ -347,23 +319,23 @@ function subsonicRoot(status, children = []) {
|
|
|
347
319
|
}
|
|
348
320
|
|
|
349
321
|
export function emptyNode(name, attrs = {}) {
|
|
350
|
-
return
|
|
322
|
+
return {
|
|
351
323
|
kind: 'node',
|
|
352
324
|
name,
|
|
353
325
|
attrs,
|
|
354
326
|
children: [],
|
|
355
327
|
selfClosing: true,
|
|
356
|
-
}
|
|
328
|
+
};
|
|
357
329
|
}
|
|
358
330
|
|
|
359
|
-
export function node(name, attrs = {}, inner =
|
|
360
|
-
return
|
|
331
|
+
export function node(name, attrs = {}, inner = null) {
|
|
332
|
+
return {
|
|
361
333
|
kind: 'node',
|
|
362
334
|
name,
|
|
363
335
|
attrs,
|
|
364
336
|
children: normalizeChildren(inner),
|
|
365
337
|
selfClosing: false,
|
|
366
|
-
}
|
|
338
|
+
};
|
|
367
339
|
}
|
|
368
340
|
|
|
369
341
|
export function okResponse(inner = '') {
|
|
@@ -410,16 +382,3 @@ export function failedResponseJson(code, message) {
|
|
|
410
382
|
return { 'subsonic-response': json };
|
|
411
383
|
}
|
|
412
384
|
|
|
413
|
-
// Backward-compatible export for older callsites.
|
|
414
|
-
export function responseJson(xml) {
|
|
415
|
-
// Kept only for compatibility during migration; no XML parsing path is used by server responses.
|
|
416
|
-
return {
|
|
417
|
-
'subsonic-response': {
|
|
418
|
-
status: 'failed',
|
|
419
|
-
error: {
|
|
420
|
-
code: 10,
|
|
421
|
-
message: 'responseJson(xml) is deprecated',
|
|
422
|
-
},
|
|
423
|
-
},
|
|
424
|
-
};
|
|
425
|
-
}
|