plexsonic 0.1.11 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plexsonic",
3
- "version": "0.1.11",
3
+ "version": "0.1.12",
4
4
  "description": "PlexMusic to OpenSubsonic bridge",
5
5
  "main": "./src/index.js",
6
6
  "bin": {
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 artistsXml = groups
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 }, artistsXml);
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 itemsXml = groups
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 }, itemsXml);
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?.grandparentTitle, track?.originalTitle],
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
- baseUrl: plexState.baseUrl,
1824
- plexToken: plexState.plexToken,
1825
- sectionId: plexState.musicSectionId,
1826
- type: 8,
1827
- query,
1828
- offset: 0,
1829
- limit: artistWindow,
1830
- signal,
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
- baseUrl: plexState.baseUrl,
1836
- plexToken: plexState.plexToken,
1837
- sectionId: plexState.musicSectionId,
1838
- type: 9,
1839
- query,
1840
- offset: 0,
1841
- limit: albumWindow,
1842
- signal,
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
- baseUrl: plexState.baseUrl,
1848
- plexToken: plexState.plexToken,
1849
- sectionId: plexState.musicSectionId,
1850
- type: 10,
1851
- query,
1852
- offset: 0,
1853
- limit: songWindow,
1854
- signal,
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) => emptyNode('album', albumAttrs(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) => emptyNode('song', songAttrs(track)))
4613
- .join('');
4711
+ .map((track) => songNode(track));
4614
4712
 
4615
- return sendSubsonicOk(reply, node('starred', {}, `${starredArtists}${starredAlbums}${starredSongs}`));
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) => emptyNode('album', albumId3Attrs(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) => emptyNode('song', songAttrs(track)))
4662
- .join('');
4757
+ .map((track) => songNode(track));
4663
4758
 
4664
- return sendSubsonicOk(reply, node('starred2', {}, `${starredArtists}${starredAlbums}${starredSongs}`));
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 genresXml = [...counts.values()]
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', {}, genresXml));
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 songXml = page.map((track) => emptyNode('song', songAttrs(track))).join('');
4778
- return sendSubsonicOk(reply, node('songsByGenre', {}, songXml));
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 songXml = randomTracks.map((track) => emptyNode('song', songAttrs(track))).join('');
4805
- return sendSubsonicOk(reply, node('randomSongs', {}, songXml));
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 songXml = topTracks.map((track) => emptyNode('song', songAttrs(track))).join('');
4849
- return sendSubsonicOk(reply, node('topSongs', {}, songXml));
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 songXml = tracks.map((track) => emptyNode('song', songAttrs(track))).join('');
4970
- return sendSubsonicOk(reply, node('similarSongs', {}, songXml));
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 songXml = tracks.map((track) => emptyNode('song', songAttrs(track))).join('');
5014
- return sendSubsonicOk(reply, node('similarSongs2', {}, songXml));
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
- baseUrl: plexState.baseUrl,
5147
- plexToken: plexState.plexToken,
5148
- trackId: matchedTrackId,
5149
- signal: searchScope.signal,
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 structuredLyricsXml = structuredLyrics
5329
+ const structuredLyricsNodes = structuredLyrics
5236
5330
  .map((lyrics) => {
5237
- const lineXml = lyrics.lines
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
- lineXml,
5351
+ lineNodes,
5259
5352
  );
5260
- })
5261
- .join('');
5353
+ });
5262
5354
 
5263
- return sendSubsonicOk(reply, node('lyricsList', {}, structuredLyricsXml));
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 artistXml = matchedArtists
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
- .join('');
5378
- const albumXml = matchedAlbums
5379
- .map((album) => emptyNode('album', albumId3Attrs(album)))
5380
- .join('');
5381
- const songXml = matchedTracks
5382
- .map((track) => emptyNode('song', songAttrs(track)))
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 artistXml = matchedArtists
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
- .join('');
5475
- const albumXml = matchedAlbums
5476
- .map((album) => emptyNode('album', albumAttrs(album)))
5477
- .join('');
5478
- const songXml = matchedTracks
5479
- .map((track) => emptyNode('song', songAttrs(track)))
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 artistXml = matchedArtists
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
- .join('');
5572
- const albumXml = matchedAlbums
5573
- .map((album) => emptyNode('album', albumAttrs(album)))
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', {}, `${artistXml}${albumXml}${matchXml}`));
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 playlistXml = playlists
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', {}, playlistXml));
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).join('');
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).join('');
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) => emptyNode('album', albumId3Attrs(album, artist.ratingKey, artist.title)))
6553
- .join('');
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 songXml = sortedTracks
6746
- .map((track) => emptyNode('song', songAttrs(track, album.title, album.ratingKey, album)))
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
- node(
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
- songXml,
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 emptyNode('child', {
6808
- ...songAttrs(item, item.parentTitle || null, item.parentRatingKey || null),
6809
- isDir: false,
6810
- parent: currentDirectoryId,
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 emptyNode('child', {
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) => emptyNode('child', albumAttrs(album, artist.ratingKey, artist.title)))
6896
- .join('');
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) => emptyNode('child', albumAttrs(album, fallback.artist.ratingKey, fallback.artist.title)))
6923
- .join('');
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
- emptyNode('child', {
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 albumXml = page
7161
+ const albumNodes = page
7045
7162
  .map((album) =>
7046
- emptyNode('album', containerName === 'albumList2' ? albumId3Attrs(album) : albumAttrs(album)),
7047
- )
7048
- .join('');
7163
+ albumNode(
7164
+ 'album',
7165
+ album,
7166
+ containerName === 'albumList2' ? albumId3Attrs(album) : albumAttrs(album),
7167
+ ),
7168
+ );
7049
7169
 
7050
- return sendSubsonicOk(reply, node(containerName, {}, albumXml));
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');
@@ -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
- function renderXmlChildren(inner) {
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 storeNode({
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 storeNode({
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
- }